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

32 KiB
Raw Blame History

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
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:

    /// <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
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

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:

    /// <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
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
    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):

    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:

            _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):
        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)

    /// <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)
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 usings/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 --nologo0 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
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.
// 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+):

        _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 smokedotnet build ... -c Debug. (Visual confirmation is the user's; see Task 6.)

  • Step 6: Commit

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).
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.ResolveTransition/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

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 greendotnet 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

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.