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>
32 KiB
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)
- TAS is the single transit driver. The existing
TeleportArrivalControllermachine (BeginArrival/Tick/place/600-frame timeout) is retired as the driver; its readiness rules (TeleportArrivalRules.Decide+ theTeleportArrivalReadinesstriplet) are kept and reused to computeworldReady. The TAS'sPlaceevent replacesPlaceTeleportArrival-on-readiness, andMaxContinue(5 s) replaces the 600-frame timeout safety net. PortalSpaceis the transit suppression and is owned by the TAS lifecycle — set when the TAS begins, cleared atFireLoginComplete(end ofWorldFadeIn). This is what stops the empty-world resolve: input + outbound movement are suppressed until the world is resident and the cover lifts.worldReady= the player's destination landblock terrain is registered in physics (outdoor) + the EnvCell struct is cached (indoor). Use the canonicalEncodeLandblockId(low 16 = 0xFFFF) key — never the& 0xFFFF0000raw key (thec880973bug).- 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 toMaxCompletionsPerFrameothers, 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— addIsLandblockTerrainResident(uint)query (Task 2). - Modify
src/AcDream.App/Rendering/GameWindow.cs— TAS wiring:worldReadycomputation, driveTeleportAnimSequencer, routePlace/FireLoginComplete, set priority id, ownPortalSpacevia 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-probeis 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(addIsLandblockTerrainResident) -
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 toLandblockCount)
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 pureworldReadydecision; 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:
OnTeleportStartedbegins 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 callingBeginArrival
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(...)(aroundGameWindow.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
PlaceTeleportArrivalto NOT flip InWorld or send LoginComplete (the TAS now owns those atFireLoginComplete). Remove the_playerController.State = InWorld;, thePLACEDis fine to keep, and remove theSendGameAction(GameActionLoginComplete...)line (GameWindow.cs:5563-5569). It keeps only: resolve, setpe/controller position, update cameras, logPLACED+teleport complete. -
Step 6: Add the pure
TeleportWorldReadyhelper (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
TeleportArrivalControllerdriver — delete the_teleportArrivalfield,EnsureTeleportArrivalController,TeleportArrivalReadiness, and the now-unusedusings/refs. KeepTeleportArrivalRules/ArrivalReadinessonly if still referenced; otherwise deletesrc/AcDream.App/World/TeleportArrivalController.csand its testtests/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
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 outputsvec4(0,0,0, uAlpha)in clip space (no projection needed; positions are full-screen NDC[-1,1]). BlendSRC_ALPHA, ONE_MINUS_SRC_ALPHA, depth test off. Follow the existing self-contained-GL-state pattern (set every state it uses; seememory/feedback_render_self_contained_gl_state.md). ExposeDraw(float alpha)that early-returns whenalpha <= 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 fieldprivate FadeOverlay? _fadeOverlay;and dispose it inOnClosing. -
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 (
FadeOverlaydriven byTeleportAnimSequencer.ComputeFadeAlpha) instead of retail's 3D portal-tunnel swirl (gmSmartBoxUItunnel 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
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 finalizesresult.CellIdfor 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(neverlbX == 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 fromPhysicsEngine.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 (preservelbX/lbY) rather than a reconstructedlbX=0id — mirroringResolveCellId's fallback-preserve behavior and retail'sGotoLostCell-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 green —
dotnet build+dotnet testfor all test projects. -
Step 2: Live acceptance (user-driven launch, probe ON). Launch with
ACDREAM_PROBE_TELEPORT=1and teleport into a town and a dungeon. Confirm in the log:- the destination-LB
APPLYlands within ~1–2 frames of itsBUILD(was +10–14 s); - NO
00B4…/MOVEMENT SPEED/failed transitionlines 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.
- the destination-LB
-
Step 3: Strip (or promote) the
tp-probe. If not promoted: removeProbeTeleportEnabled+LogTeleportfromPhysicsDiagnostics.cs(andResetForTest), theAIM/PLACED/BUILD/APPLY/ENQcall sites inGameWindow.cs+LandblockStreamer.cs, and thewaited/heldbranch inBuildLandblockForStreaming. 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 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
FadeOverlayctor 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; thegrep -rnin Step 8 guards against dangling refs. Indoor dungeon login (#135) sharesIsSpawnCellReadyand is unchanged by the worldReady mapping — re-verify a dungeon login in Task 6.