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>
This commit is contained in:
parent
31602c6a24
commit
b869128df3
1 changed files with 615 additions and 0 deletions
|
|
@ -0,0 +1,615 @@
|
|||
# Retail Teleport — Priority Residency + Fade Cover — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make a teleport materialize the player onto a resident, grounded destination within ~1 s (was 10–14 s) with no movement desync, covered by a retail fade — fixing "long transition," "dropped at the wrong position," and "stops at the portal" in one coherent change.
|
||||
|
||||
**Architecture:** The dormant `TeleportAnimSequencer` (TAS) becomes the transit *driver*: it holds at `Tunnel` until `worldReady` (the destination landblock is resident), fires `Place` (materialize) and `FireLoginComplete`, and outputs a `FadeAlpha` for a fullscreen cover. `worldReady` flips fast because `StreamingController` **priority-applies** the player's destination landblock ahead of the 4/frame budget. `PortalSpace` stays set for the whole transit, so no resolve runs against the empty world; a cell-march hardening guarantees the outbound cell frame can't corrupt even if one does.
|
||||
|
||||
**Tech Stack:** C# .NET 10, Silk.NET OpenGL, xUnit. Spec: `docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## Design decisions locked in (read before starting)
|
||||
|
||||
1. **TAS is the single transit driver.** The existing `TeleportArrivalController` machine (BeginArrival/Tick/place/600-frame timeout) is **retired as the driver**; its readiness *rules* (`TeleportArrivalRules.Decide` + the `TeleportArrivalReadiness` triplet) are **kept** and reused to compute `worldReady`. The TAS's `Place` event replaces `PlaceTeleportArrival`-on-readiness, and `MaxContinue` (5 s) replaces the 600-frame timeout safety net.
|
||||
2. **`PortalSpace` is the transit suppression and is owned by the TAS lifecycle** — set when the TAS begins, cleared at `FireLoginComplete` (end of `WorldFadeIn`). This is what stops the empty-world resolve: input + outbound movement are suppressed until the world is resident and the cover lifts.
|
||||
3. **`worldReady` = the player's destination landblock terrain is registered in physics** (outdoor) **+ the EnvCell struct is cached** (indoor). Use the **canonical `EncodeLandblockId` (low 16 = 0xFFFF) key** — never the `& 0xFFFF0000` raw key (the `c880973` bug).
|
||||
4. **Priority-apply buffers the overflow.** `DrainAndApply`, when a priority id is set, drains enough of the outbox to find it, applies the priority completion **first**, applies up to `MaxCompletionsPerFrame` others, and **holds the remainder in a local buffer** for subsequent frames (no GPU spike, no lost completions).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
- **Modify** `src/AcDream.App/Streaming/StreamingController.cs` — priority-apply + overflow buffer (Task 1).
|
||||
- **Modify** `src/AcDream.Core/Physics/PhysicsEngine.cs` — add `IsLandblockTerrainResident(uint)` query (Task 2).
|
||||
- **Modify** `src/AcDream.App/Rendering/GameWindow.cs` — TAS wiring: `worldReady` computation, drive `TeleportAnimSequencer`, route `Place`/`FireLoginComplete`, set priority id, own `PortalSpace` via TAS, draw fade (Tasks 2–4).
|
||||
- **Create** `src/AcDream.App/Rendering/FadeOverlay.cs` — fullscreen alpha-quad renderer (Task 4).
|
||||
- **Modify** the swept-transition cell-march in `src/AcDream.Core/Physics/` (`Transition`/`CellTransit`) — preserve landblock id when no resident landblock (Task 5).
|
||||
- **Modify** `docs/architecture/retail-divergence-register.md` — retire/add rows (Tasks 3, 4).
|
||||
- **Tests:** `tests/AcDream.Core.Tests/Streaming/`, `tests/AcDream.Core.Tests/Physics/`, `tests/AcDream.App.Tests/World/`.
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Baseline green
|
||||
|
||||
- [ ] **Step 1: Confirm build + tests green at the current tree** (the `tp-probe` is present and harmless).
|
||||
|
||||
Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo`
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --nologo`
|
||||
Expected: all pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Priority-apply in StreamingController
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Streaming/StreamingController.cs`
|
||||
- Test: `tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs` (create)
|
||||
|
||||
The contract: when `PriorityLandblockId` is set, `DrainAndApply` applies that landblock's `Loaded`/`Promoted` completion **this frame** even if it sits past the `MaxCompletionsPerFrame` position in the outbox, applying up to `MaxCompletionsPerFrame` other completions and buffering any remainder for later frames.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Streaming;
|
||||
|
||||
public class StreamingControllerPriorityApplyTests
|
||||
{
|
||||
private static LandblockStreamResult.Loaded LoadedOf(uint canonicalId)
|
||||
=> new(canonicalId, LandblockStreamTier.Near,
|
||||
new LoadedLandblock(canonicalId, default!, System.Array.Empty<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 ~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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue