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