diff --git a/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md b/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md new file mode 100644 index 00000000..b06406d7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md @@ -0,0 +1,615 @@ +# Retail Teleport — Priority Residency + Fade Cover — 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:** Make a teleport materialize the player onto a resident, grounded destination within ~1 s (was 10–14 s) with no movement desync, covered by a retail fade — fixing "long transition," "dropped at the wrong position," and "stops at the portal" in one coherent change. + +**Architecture:** The dormant `TeleportAnimSequencer` (TAS) becomes the transit *driver*: it holds at `Tunnel` until `worldReady` (the destination landblock is resident), fires `Place` (materialize) and `FireLoginComplete`, and outputs a `FadeAlpha` for a fullscreen cover. `worldReady` flips fast because `StreamingController` **priority-applies** the player's destination landblock ahead of the 4/frame budget. `PortalSpace` stays set for the whole transit, so no resolve runs against the empty world; a cell-march hardening guarantees the outbound cell frame can't corrupt even if one does. + +**Tech Stack:** C# .NET 10, Silk.NET OpenGL, xUnit. Spec: `docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md`. + +--- + +## Design decisions locked in (read before starting) + +1. **TAS is the single transit driver.** The existing `TeleportArrivalController` machine (BeginArrival/Tick/place/600-frame timeout) is **retired as the driver**; its readiness *rules* (`TeleportArrivalRules.Decide` + the `TeleportArrivalReadiness` triplet) are **kept** and reused to compute `worldReady`. The TAS's `Place` event replaces `PlaceTeleportArrival`-on-readiness, and `MaxContinue` (5 s) replaces the 600-frame timeout safety net. +2. **`PortalSpace` is the transit suppression and is owned by the TAS lifecycle** — set when the TAS begins, cleared at `FireLoginComplete` (end of `WorldFadeIn`). This is what stops the empty-world resolve: input + outbound movement are suppressed until the world is resident and the cover lifts. +3. **`worldReady` = the player's destination landblock terrain is registered in physics** (outdoor) **+ the EnvCell struct is cached** (indoor). Use the **canonical `EncodeLandblockId` (low 16 = 0xFFFF) key** — never the `& 0xFFFF0000` raw key (the `c880973` bug). +4. **Priority-apply buffers the overflow.** `DrainAndApply`, when a priority id is set, drains enough of the outbox to find it, applies the priority completion **first**, applies up to `MaxCompletionsPerFrame` others, and **holds the remainder in a local buffer** for subsequent frames (no GPU spike, no lost completions). + +--- + +## File structure + +- **Modify** `src/AcDream.App/Streaming/StreamingController.cs` — priority-apply + overflow buffer (Task 1). +- **Modify** `src/AcDream.Core/Physics/PhysicsEngine.cs` — add `IsLandblockTerrainResident(uint)` query (Task 2). +- **Modify** `src/AcDream.App/Rendering/GameWindow.cs` — TAS wiring: `worldReady` computation, drive `TeleportAnimSequencer`, route `Place`/`FireLoginComplete`, set priority id, own `PortalSpace` via TAS, draw fade (Tasks 2–4). +- **Create** `src/AcDream.App/Rendering/FadeOverlay.cs` — fullscreen alpha-quad renderer (Task 4). +- **Modify** the swept-transition cell-march in `src/AcDream.Core/Physics/` (`Transition`/`CellTransit`) — preserve landblock id when no resident landblock (Task 5). +- **Modify** `docs/architecture/retail-divergence-register.md` — retire/add rows (Tasks 3, 4). +- **Tests:** `tests/AcDream.Core.Tests/Streaming/`, `tests/AcDream.Core.Tests/Physics/`, `tests/AcDream.App.Tests/World/`. + +--- + +## Task 0: Baseline green + +- [ ] **Step 1: Confirm build + tests green at the current tree** (the `tp-probe` is present and harmless). + +Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo` +Expected: `Build succeeded. 0 Error(s)`. + +Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --nologo` +Expected: all pass. + +--- + +## Task 1: Priority-apply in StreamingController + +**Files:** +- Modify: `src/AcDream.App/Streaming/StreamingController.cs` +- Test: `tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs` (create) + +The contract: when `PriorityLandblockId` is set, `DrainAndApply` applies that landblock's `Loaded`/`Promoted` completion **this frame** even if it sits past the `MaxCompletionsPerFrame` position in the outbox, applying up to `MaxCompletionsPerFrame` other completions and buffering any remainder for later frames. + +- [ ] **Step 1: Write the failing test** + +```csharp +using System.Collections.Generic; +using AcDream.App.Streaming; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Streaming; + +public class StreamingControllerPriorityApplyTests +{ + private static LandblockStreamResult.Loaded LoadedOf(uint canonicalId) + => new(canonicalId, LandblockStreamTier.Near, + new LoadedLandblock(canonicalId, default!, System.Array.Empty()), + new LandblockMeshData()); + + [Fact] + public void PriorityLandblock_isApplied_eventWhenBeyondPerFrameCap() + { + // Outbox order: 4 fillers, then the priority LB at position 5. + uint priority = StreamingRegion.EncodeLandblockId(169, 180); // 0xA9B4FFFF + var outbox = new Queue(new LandblockStreamResult[] + { + LoadedOf(StreamingRegion.EncodeLandblockId(0, 0)), + LoadedOf(StreamingRegion.EncodeLandblockId(0, 1)), + LoadedOf(StreamingRegion.EncodeLandblockId(0, 2)), + LoadedOf(StreamingRegion.EncodeLandblockId(0, 3)), + LoadedOf(priority), + }); + + var applied = new List(); + var state = new GpuWorldState(); + var ctrl = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (lb, _) => applied.Add(lb.LandblockId), + state: state, nearRadius: 4, farRadius: 12) + { MaxCompletionsPerFrame = 4 }; + + ctrl.PriorityLandblockId = priority; + + // One observer tick at the destination center. + ctrl.Tick(169, 180); + + Assert.Contains(priority, applied); // priority applied THIS tick + Assert.True(applied.Count <= 5); // did not flush the whole outbox blindly + } +} +``` + +- [ ] **Step 2: Run it; verify it fails** + +Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter PriorityLandblock_isApplied_eventWhenBeyondPerFrameCap` +Expected: FAIL — `StreamingController` has no `PriorityLandblockId`. + +- [ ] **Step 3: Implement `PriorityLandblockId` + buffered priority drain** + +In `StreamingController.cs`, add a field + property and a local overflow buffer, and rewrite `DrainAndApply`: + +```csharp + /// + /// When set (the active teleport destination, canonical 0xFFFF key), DrainAndApply + /// applies this landblock's completion ahead of the per-frame cap so the player + /// materializes as soon as the worker finishes it — independent of the post-arrival + /// CreateObject flood. Cleared by the caller once the player has materialized. + /// + public uint PriorityLandblockId { get; set; } + + // Completions drained while hunting for the priority id but not yet applied + // (held to honor the per-frame GPU budget instead of flushing all at once). + private readonly List _deferredApply = new(); + + private void DrainAndApply() + { + // Apply any completions deferred from a prior priority-hunt first. + int budget = MaxCompletionsPerFrame; + int i = 0; + for (; i < _deferredApply.Count && budget > 0; i++, budget--) + ApplyResult(_deferredApply[i]); + if (i > 0) _deferredApply.RemoveRange(0, i); + + if (PriorityLandblockId != 0u) + { + // Hunt the outbox for the priority completion. Drain in chunks; apply the + // priority immediately when found; hold non-priority drained items in the + // deferred buffer (applied above, over subsequent frames). + bool found = false; + for (int guard = 0; guard < 64 && !found; guard++) + { + var chunk = _drainCompletions(MaxCompletionsPerFrame); + if (chunk.Count == 0) break; + foreach (var r in chunk) + { + if (!found && ResultLandblockId(r) == PriorityLandblockId) + { + ApplyResult(r); + found = true; + } + else + { + _deferredApply.Add(r); + } + } + } + if (found) return; // priority handled; deferred items drain next frames + // not found this frame — fall through and apply the deferred budget normally + } + + var drained = _drainCompletions(budget); + foreach (var result in drained) + ApplyResult(result); + } + + private static uint ResultLandblockId(LandblockStreamResult r) => r switch + { + LandblockStreamResult.Loaded l => l.Landblock.LandblockId, + LandblockStreamResult.Promoted p => p.LandblockId, + LandblockStreamResult.Unloaded u => u.LandblockId, + LandblockStreamResult.Failed f => f.LandblockId, + _ => 0u, + }; + + private void ApplyResult(LandblockStreamResult result) + { + switch (result) + { + case LandblockStreamResult.Loaded loaded: + _applyTerrain(loaded.Landblock, loaded.MeshData); + _state.AddLandblock(loaded.Landblock); + _onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId); + break; + case LandblockStreamResult.Promoted promoted: + _applyTerrain(promoted.Landblock, promoted.MeshData); + _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); + _onLandblockLoaded?.Invoke(promoted.LandblockId); + break; + case LandblockStreamResult.Unloaded unloaded: + _state.RemoveLandblock(unloaded.LandblockId); + _removeTerrain?.Invoke(unloaded.LandblockId); + break; + case LandblockStreamResult.Failed failed: + Console.WriteLine($"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}"); + break; + case LandblockStreamResult.WorkerCrashed crashed: + Console.WriteLine($"streaming: worker CRASHED: {crashed.Error}"); + break; + } + } +``` + +(Delete the old inline `switch` body of `DrainAndApply`; it is now `ApplyResult`.) + +- [ ] **Step 4: Run the test; verify it passes** + +Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter StreamingControllerPriorityApply` +Expected: PASS. Also run the existing `StreamingController*` tests — all green. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Streaming/StreamingController.cs tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs +git commit -m "feat(teleport A): StreamingController priority-apply for the teleport destination LB" +``` + +--- + +## Task 2: Residency query + worldReady + +**Files:** +- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs` (add `IsLandblockTerrainResident`) +- Test: `tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +```csharp +using System; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class PhysicsEngineResidencyTests +{ + [Fact] + public void IsLandblockTerrainResident_trueOnlyAfterAddLandblock() + { + var eng = new PhysicsEngine(); + // canonical id with the 0xFFFF terminator, matching the streaming key. + uint canonical = 0xA9B4FFFFu; + uint lbId = canonical & 0xFFFF0000u; // physics stores by the AddLandblock id + + Assert.False(eng.IsLandblockTerrainResident(canonical)); + + eng.AddLandblock(lbId, terrain: null!, cells: Array.Empty(), + portals: Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); + + Assert.True(eng.IsLandblockTerrainResident(canonical)); + } +} +``` + +- [ ] **Step 2: Run it; verify it fails** (no such method). + +Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter IsLandblockTerrainResident_trueOnlyAfterAddLandblock` +Expected: FAIL. + +- [ ] **Step 3: Implement the query** (in `PhysicsEngine.cs`, next to `LandblockCount`) + +`AddLandblock` keys `_landblocks` by the id it is given. Streaming applies terrain with `loaded.Landblock.LandblockId`, which is the canonical `0xFFFF` form; component A's `_state.AddLandblock` stores the same. Physics is populated from `ApplyLoadedTerrainLocked` with `lb.LandblockId` (canonical). So accept either form and compare on the high 16 bits: + +```csharp + /// + /// True once the landblock covering has had its + /// terrain + cells registered via . Accepts a canonical + /// (0xFFFF) id, a cell-resolved id, or a bare landblock id — compares on the high 16 + /// bits. This is the teleport "worldReady" gate (the destination is grounded). + /// + public bool IsLandblockTerrainResident(uint cellOrLandblockId) + { + uint prefix = cellOrLandblockId & 0xFFFF0000u; + foreach (var key in _landblocks.Keys) + if ((key & 0xFFFF0000u) == prefix) return true; + return false; + } +``` + +- [ ] **Step 4: Run the test; verify it passes.** + +Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter IsLandblockTerrainResident` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs +git commit -m "feat(teleport B): PhysicsEngine.IsLandblockTerrainResident worldReady query" +``` + +--- + +## Task 3: Drive the TAS as the transit driver (wiring) + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` +- Modify: `docs/architecture/retail-divergence-register.md` +- Test: `tests/AcDream.App.Tests/World/TeleportWorldReadyTests.cs` (create) — unit-test the pure `worldReady` decision; the GameWindow wiring is verified by the live probe in Task 6. + +This task replaces the `TeleportArrivalController`-driven flow with the TAS. It is integration-heavy; keep edits surgical and reuse the existing readiness triplet. + +- [ ] **Step 1: Add fields next to the existing `_teleportArrival`** + +```csharp + private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new(); + private System.Numerics.Vector3 _pendingTeleportPos; + private uint _pendingTeleportCell; + private bool _teleportInProgress; +``` + +- [ ] **Step 2: `OnTeleportStarted` begins the TAS instead of only freezing input** + +Replace the body of `OnTeleportStarted` (`GameWindow.cs:5579-5585`): + +```csharp + private void OnTeleportStarted(uint sequence) + { + if (_playerController is not null) + _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; + _teleportInProgress = true; + _teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal); + Console.WriteLine($"live: teleport started (seq={sequence})"); + } +``` + +- [ ] **Step 3: In the teleport branch of `OnLivePositionUpdated`, record the pending placement instead of calling `BeginArrival`** + +Replace the `EnsureTeleportArrivalController(); _pendingTeleportRot = rot; _teleportArrival!.BeginArrival(...)` block (`GameWindow.cs:5472-5474`, plus the `tp-probe AIM` line stays) with: + +```csharp + _pendingTeleportRot = rot; + _pendingTeleportPos = newWorldPos; + _pendingTeleportCell = p.LandblockId; + if (_streamingController is not null) + _streamingController.PriorityLandblockId = + AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY); + AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( + "AIM", p.LandblockId, + $"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}"); +``` + +- [ ] **Step 4: Tick the TAS each frame and route its events** — in the per-frame update, right after `_streamingController.Tick(...)` (around `GameWindow.cs:7531`): + +```csharp + if (_teleportInProgress) + { + bool worldReady = TeleportWorldReady(_pendingTeleportCell); + var (snap, evts) = _teleportAnim.Tick((float)dt, worldReady); + _teleportFadeAlpha = snap.FadeAlpha; // consumed by the fade overlay (Task 4) + foreach (var e in evts) + { + switch (e) + { + case AcDream.Core.World.TeleportAnimEvent.Place: + PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, forced: false); + if (_streamingController is not null) + _streamingController.PriorityLandblockId = 0u; + break; + case AcDream.Core.World.TeleportAnimEvent.FireLoginComplete: + if (_playerController is not null) + _playerController.State = AcDream.App.Input.PlayerState.InWorld; + _liveSession?.SendGameAction( + AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); + _teleportInProgress = false; + break; + case AcDream.Core.World.TeleportAnimEvent.PlayEnterSound: + case AcDream.Core.World.TeleportAnimEvent.EnterTunnel: + case AcDream.Core.World.TeleportAnimEvent.PlayExitSound: + break; // audio hooks deferred (sounds are polish) + } + } + } +``` + +Add the field `private float _teleportFadeAlpha;` near `_teleportAnim`. + +- [ ] **Step 5: Modify `PlaceTeleportArrival` to NOT flip InWorld or send LoginComplete** (the TAS now owns those at `FireLoginComplete`). Remove the `_playerController.State = InWorld;`, the `PLACED` is fine to keep, and remove the `SendGameAction(GameActionLoginComplete...)` line (`GameWindow.cs:5563-5569`). It keeps only: resolve, set `pe`/controller position, update cameras, log `PLACED` + `teleport complete`. + +- [ ] **Step 6: Add the pure `TeleportWorldReady` helper** (reuses the existing readiness triplet but gates outdoor on terrain residency) + +```csharp + /// worldReady for the TAS: the destination's collision landblock is resident. + /// Indoor → the EnvCell struct is cached (#135); outdoor → the terrain landblock is + /// registered (priority-applied). An impossible claim is treated as ready so the TAS + /// stops holding and the forced placement surfaces the failure. + private bool TeleportWorldReady(uint destCell) + { + if (IsSpawnClaimUnhydratable(destCell)) return true; + bool indoor = (destCell & 0xFFFFu) >= 0x0100u; + return indoor + ? _physicsEngine.IsSpawnCellReady(destCell) + : _physicsEngine.IsLandblockTerrainResident(destCell); + } +``` + +- [ ] **Step 7: Unit-test the readiness decision** (`tests/AcDream.App.Tests/World/TeleportWorldReadyTests.cs`) + +```csharp +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.App.Tests.World; + +public class TeleportWorldReadyTests +{ + [Fact] + public void Outdoor_notReady_untilTerrainResident() + { + var eng = new PhysicsEngine(); + uint outdoorCell = 0xA9B40019u; // low word < 0x0100 → outdoor + Assert.False(eng.IsLandblockTerrainResident(outdoorCell)); + eng.AddLandblock(outdoorCell & 0xFFFF0000u, null!, + System.Array.Empty(), System.Array.Empty(), 0, 0); + Assert.True(eng.IsLandblockTerrainResident(outdoorCell)); + } +} +``` + +(The indoor path is already covered by the existing `IsSpawnCellReady`/dungeon tests; this task only changes outdoor from place-immediately to hold-until-resident.) + +- [ ] **Step 8: Retire the `TeleportArrivalController` driver** — delete the `_teleportArrival` field, `EnsureTeleportArrivalController`, `TeleportArrivalReadiness`, and the now-unused `using`s/refs. Keep `TeleportArrivalRules`/`ArrivalReadiness` only if still referenced; otherwise delete `src/AcDream.App/World/TeleportArrivalController.cs` and its test `tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs`. Verify nothing else references them (`grep -rn TeleportArrivalController src tests`). + +- [ ] **Step 9: Divergence register — retire the place-immediately row.** In `docs/architecture/retail-divergence-register.md`, find the row describing "outdoor teleport places immediately because streaming doesn't progress during a hold" and delete it (the premise was measured false; the new transit-until-resident model is retail-faithful). If no explicit row exists, add a one-line note under the teleport section that the place-immediately divergence was retired by this change. + +- [ ] **Step 10: Build + test green** + +Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo` → `0 Error(s)`. +Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --nologo` and `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --nologo` → all pass. + +- [ ] **Step 11: Commit** + +```bash +git add -A +git commit -m "feat(teleport B/C): TAS drives transit; outdoor holds until terrain resident; retire TeleportArrivalController driver" +``` + +--- + +## Task 4: Fade overlay render + +**Files:** +- Create: `src/AcDream.App/Rendering/FadeOverlay.cs` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (draw it in the 2D pass) +- Modify: `docs/architecture/retail-divergence-register.md` + +The 2D self-contained pass lives near `GameWindow.cs:9013` ("Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush"). The fade is a fullscreen black quad whose alpha is `_teleportFadeAlpha`, drawn AFTER the world and UI so it covers everything. + +- [ ] **Step 1: Implement `FadeOverlay`** — a minimal self-contained GL object: a unit-quad VAO + a trivial shader that outputs `vec4(0,0,0, uAlpha)` in clip space (no projection needed; positions are full-screen NDC `[-1,1]`). Blend `SRC_ALPHA, ONE_MINUS_SRC_ALPHA`, depth test off. Follow the existing self-contained-GL-state pattern (set every state it uses; see `memory/feedback_render_self_contained_gl_state.md`). Expose `Draw(float alpha)` that early-returns when `alpha <= 0`. + +```csharp +// src/AcDream.App/Rendering/FadeOverlay.cs +using System; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// Fullscreen black quad at a given alpha — the teleport fade cover (spec C). +/// Self-contained GL state; draws in NDC so it needs no view/projection. +public sealed class FadeOverlay : IDisposable +{ + private readonly GL _gl; + private readonly uint _vao, _vbo, _prog, _uAlphaLoc; + + public FadeOverlay(GL gl) + { + _gl = gl; + // 1) compile the trivial shader (vert: gl_Position = vec4(aPos,0,1); frag: FragColor = vec4(0,0,0,uAlpha)) + // 2) upload a 2-triangle fullscreen quad in NDC to _vbo/_vao + // 3) cache _uAlphaLoc = glGetUniformLocation(_prog, "uAlpha") + // (full GL boilerplate — mirror an existing minimal renderer, e.g. the terrain-atlas/test quads) + throw new NotImplementedException("fill GL boilerplate during impl"); + } + + public void Draw(float alpha) + { + if (alpha <= 0f) return; + _gl.Disable(EnableCap.DepthTest); + _gl.Enable(EnableCap.Blend); + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + _gl.UseProgram(_prog); + _gl.Uniform1(_uAlphaLoc, Math.Clamp(alpha, 0f, 1f)); + _gl.BindVertexArray(_vao); + _gl.DrawArrays(PrimitiveType.Triangles, 0, 6); + _gl.BindVertexArray(0); + } + + public void Dispose() + { + _gl.DeleteVertexArray(_vao); + _gl.DeleteBuffer(_vbo); + _gl.DeleteProgram(_prog); + } +} +``` + +(The ctor GL boilerplate — shader compile + quad upload — is the only `NotImplementedException` to resolve; copy the shape from the smallest existing self-contained renderer in `src/AcDream.App/Rendering/`. There is no logic to design, only GL plumbing.) + +- [ ] **Step 2: Construct it once** where other render objects are built (near the `TerrainAtlas.Build` / shader init, ~`GameWindow.cs:1719`): `_fadeOverlay = new FadeOverlay(_gl);` + the field `private FadeOverlay? _fadeOverlay;` and dispose it in `OnClosing`. + +- [ ] **Step 3: Draw it last in the 2D pass** — at the end of the self-contained 2D pass (after `UiHost.Draw`/`TextRenderer.Flush`, ~`GameWindow.cs:9013+`): + +```csharp + _fadeOverlay?.Draw(_teleportFadeAlpha); +``` + +- [ ] **Step 4: Divergence register — add the fade-vs-swirl row.** Add a row: acdream covers the teleport transit with a black fade (`FadeOverlay` driven by `TeleportAnimSequencer.ComputeFadeAlpha`) instead of retail's 3D portal-tunnel swirl (`gmSmartBoxUI` tunnel render). Risk: visual-only (no swirl); retire by porting the tunnel. Sibling to AP-49. + +- [ ] **Step 5: Build green + visual smoke** — `dotnet build ... -c Debug`. (Visual confirmation is the user's; see Task 6.) + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat(teleport C): fullscreen fade cover driven by TeleportAnimSequencer" +``` + +--- + +## Task 5: Cell-march landblock-id hardening + +**Files:** +- Modify: the swept-transition cell-march in `src/AcDream.Core/Physics/` (the path that finalizes `result.CellId` for a moving outdoor sphere — `Transition`/`CellTransit.AddAllOutsideCells` / `LandDefs.adjust_to_outside`) +- Test: `tests/AcDream.Core.Tests/Physics/CellMarchLandblockPreservationTests.cs` (create) + +The bug: when the player moves and the swept position is over **no resident landblock**, the cell-march emits a cell id with the landblock-X byte zeroed (`0x00B4…` from a `0xA9B4…` seed — observed live, ACE `MOVEMENT SPEED`). With the transit model the player never moves against an empty world on the normal path, but this is the defense-in-depth fix (also clips the #145 edge residual). + +- [ ] **Step 1: Write the failing test** — drive a swept resolve from a seeded outdoor cell where NO landblock is registered, asserting the returned cell preserves the seed's `lbX`/`lbY` (never `lbX == 0`). + +```csharp +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class CellMarchLandblockPreservationTests +{ + [Fact] + public void SweptResolve_overNoResidentLandblock_preservesSeedLandblock() + { + var eng = new PhysicsEngine(); // no AddLandblock → empty world + uint seedCell = 0xA9B40019u; // outdoor cell in landblock 0xA9B4 + var fromLocal = new Vector3(84f, 7.1f, 94.005f); + var delta = new Vector3(2f, 0f, 0f); // a small step east + + var result = eng.Resolve(fromLocal, seedCell, delta, stepUpHeight: 1.0f); + + Assert.Equal(seedCell & 0xFFFF0000u, result.CellId & 0xFFFF0000u); // lbX/lbY preserved + Assert.NotEqual(0u, (result.CellId >> 24) & 0xFFu); // lbX never zeroed + } +} +``` + +- [ ] **Step 2: Run it; confirm it fails** (reproduces `lbX == 0`). + +Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter SweptResolve_overNoResidentLandblock_preservesSeedLandblock` +Expected: FAIL — `result.CellId` high byte is 0 (or the prefix differs). + +- [ ] **Step 3: Locate the zeroing site.** It is NOT `ResolveCellId` (it returns the fallback unchanged, `PhysicsEngine.cs:330-351`). Trace the swept path from `PhysicsEngine.Resolve` → `Transition`/`SpherePath` → the outdoor cell update (`CellTransit.AddAllOutsideCells` / `LandDefs.adjust_to_outside`, which works in the GLOBAL landcell grid and converts back to a landblock-keyed id). The zeroing is in that global→landblock id reconstruction when the grid lookup finds no resident block. Add a guard: when the swept position resolves to no resident landblock, **return the entering sphere's current cell id** (preserve `lbX`/`lbY`) rather than a reconstructed `lbX=0` id — mirroring `ResolveCellId`'s fallback-preserve behavior and retail's `GotoLostCell`-keeps-last-cell shape. + +- [ ] **Step 4: Run the test; verify it passes.** Then run the full physics suite — `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --nologo` — to confirm no regression in the swept-transition / cellar / doorway tests. + +- [ ] **Step 5: Divergence register** — if the fix introduces any deviation from retail's lost-cell machinery (we have no `GotoLostCell`), add/keep the existing note; otherwise no row needed (this restores correctness). + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "fix(teleport D): cell-march preserves seed landblock id when no resident LB (no more lbX=0 outbound)" +``` + +--- + +## Task 6: Verify, then strip/promote the probe + +**Files:** `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`, `src/AcDream.App/Rendering/GameWindow.cs`, `src/AcDream.App/Streaming/LandblockStreamer.cs`, `docs/ISSUES.md`. + +- [ ] **Step 1: Full build + test green** — `dotnet build` + `dotnet test` for all test projects. + +- [ ] **Step 2: Live acceptance (user-driven launch, probe ON).** Launch with `ACDREAM_PROBE_TELEPORT=1` and teleport into a town and a dungeon. Confirm in the log: + - the destination-LB `APPLY` lands within ~1–2 frames of its `BUILD` (was +10–14 s); + - NO `00B4…` / `MOVEMENT SPEED` / `failed transition` lines in the ACE log after a teleport; + - visually: a faded transition that pops out onto real, grounded terrain — no run-in-place, no desync; + - the death→lifestone building keeps its collision. + - **This is the visual gate — stop and get the user's confirmation before Step 3.** + +- [ ] **Step 3: Strip (or promote) the `tp-probe`.** If not promoted: remove `ProbeTeleportEnabled` + `LogTeleport` from `PhysicsDiagnostics.cs` (and `ResetForTest`), the `AIM`/`PLACED`/`BUILD`/`APPLY`/`ENQ` call sites in `GameWindow.cs` + `LandblockStreamer.cs`, and the `waited/held` branch in `BuildLandblockForStreaming`. Build + test green. + +- [ ] **Step 4: Update ISSUES + roadmap.** Move #138 (teleport-OUT gap) to Recently closed with the commit SHAs; add the deferred follow-ups: (a) CreateObject-flood timeslicing for post-materialization FPS sag (measure in Release first), (b) authentic 3D portal-swirl tunnel. Update the milestones "currently working toward" line if M1.5 advances. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "chore(teleport): strip tp-probe; close #138; file flood-timeslice + swirl follow-ups" +``` + +--- + +## Self-review notes + +- **Spec coverage:** A→Task 1; B→Tasks 2–3; C→Tasks 3–4; D→Task 5; deferrals + register + probe→Tasks 3/4/6. All §1–§9 spec items map to a task. +- **Type consistency:** `IsLandblockTerrainResident` (Tasks 2,3,5-test), `PriorityLandblockId` (Tasks 1,3), `TeleportWorldReady` (Task 3), `_teleportFadeAlpha`/`FadeOverlay.Draw` (Tasks 3,4), `TeleportAnimEvent.Place`/`FireLoginComplete` (Task 3, from the existing TAS) are used consistently. +- **Two impl-time discoveries (not placeholders — test-defined):** the `FadeOverlay` ctor GL boilerplate (Task 4 Step 1) and the swept cell-march zeroing site (Task 5 Step 3). Each has a failing test that defines done. +- **Risk:** Task 3 (retiring `TeleportArrivalController`) is the largest edit; the `grep -rn` in Step 8 guards against dangling refs. Indoor dungeon login (#135) shares `IsSpawnCellReady` and is unchanged by the worldReady mapping — re-verify a dungeon login in Task 6.