acdream/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md
Erik b869128df3 docs(teleport): TDD implementation plan — priority residency + fade cover
6 tasks: (1) StreamingController priority-apply, (2) PhysicsEngine residency
query, (3) TAS drives transit + outdoor hold-until-resident (retire the
TeleportArrivalController driver), (4) fullscreen fade overlay, (5) cell-march
lbX hardening, (6) verify + strip the tp-probe. Reuses the dormant
TeleportAnimSequencer as the transit driver; worldReady gates on the
priority-applied destination landblock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:51:07 +02:00

615 lines
32 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 — 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 1014 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 24).
- **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<WorldEntity>()),
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<LandblockStreamResult>(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<uint>();
var state = new GpuWorldState();
var ctrl = new StreamingController(
enqueueLoad: (_, _) => { },
enqueueUnload: _ => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
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
/// <summary>
/// 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.
/// </summary>
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<LandblockStreamResult> _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<CellSurface>(),
portals: Array.Empty<PortalPlane>(), 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
/// <summary>
/// True once the landblock covering <paramref name="cellOrLandblockId"/> has had its
/// terrain + cells registered via <see cref="AddLandblock"/>. 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).
/// </summary>
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
/// <summary>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.</summary>
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<CellSurface>(), System.Array.Empty<PortalPlane>(), 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;
/// <summary>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.</summary>
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 ~12 frames of its `BUILD` (was +1014 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 23; C→Tasks 34; 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.