Bite-sized TDD plan for the retail teleport flow. Slice 1: pure TeleportAnimSequencer (7-state TAS) + golden-timing tests. Slice 2: the #145 readiness-gate fix (IsLandblockLoaded + Decide outdoorReady axis + apparatus probe) — ships independently of the visuals. Slice 3: TeleportFlowController (delegate-injected, unit-tested) + TeleportFadeOverlay + portal wiring (PlaceTeleportArrival split: place vs InWorld so the input lock persists the whole animation). Slice 4: one yaw-freeze + portal sounds via the EnumIDMap chain. Slice 5: de-dup login readiness onto Decide, route login/death through the controller, logout (Shift+Esc) + 0xF653 + disconnect, remove dead _teleportArrival plumbing. Slice 6 (literal 3D swirl) is a follow-up plan gated on a cdb asset trace. Drafted via two research + drafting workflows; slices 3-5 redrafted as one cohesive unit against a pinned controller API after the first parallel pass produced cross-slice inconsistencies (missing controller task, triplicated yaw-freeze, a fabricated PlayerMovementController.Update signature). All load-bearing signatures personally verified against the tree. Plan: docs/superpowers/plans/2026-06-21-retail-teleport-flow.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 KiB
Retail Teleport Flow 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: Build the retail-faithful unified teleport flow — a TeleportAnimState (TAS) animation state machine + arrival hold that covers login, logout, death, and portal with a fade-to-black cover, holds until the destination landblock is actually loaded (folding in the #145 unstreamed-arrival cascade fix), and locks input + camera for the whole transition.
Architecture: A pure Core state machine (TeleportAnimSequencer, the 7-state TAS machine) + a thin App orchestrator (TeleportFlowController) that wraps it and drives all side effects through injected delegates + a TeleportFadeOverlay UiElement for the black fade. The readiness gate (PhysicsEngine.IsLandblockLoaded) makes outdoor arrivals hold until the collision landblock is resident. Slices 1–2 land the sequencer + readiness gate (the #145 fix ships independently of the visuals); slices 3–5 route portal/login/death/logout through one controller and de-dup the duplicated readiness/recenter logic; slice 6 (the literal 3D portal swirl) is a follow-up plan gated on a cdb asset trace (see "Slice 6" at the end).
Tech Stack: C# .NET 10, xunit (dotnet test), Silk.NET (GL / OpenAL / input), DatReaderWriter (dat access), the existing AcDream.Core / AcDream.App / AcDream.UI.Abstractions layers. Decomp oracle: docs/research/named-retail/. Design doc this plan implements: docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md.
File structure
| Path | Responsibility | Slice |
|---|---|---|
src/AcDream.Core/World/TeleportAnimSequencer.cs (new) |
Pure 7-state TAS machine: enums, TeleportAnimSnapshot, the sequencer. No GL/dat/net. |
1 |
tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs (new) |
Golden-timing unit tests for the sequencer. | 1 |
src/AcDream.Core/Physics/PhysicsEngine.cs (modify) |
Add IsLandblockLoaded(uint). |
2 |
src/AcDream.App/World/TeleportArrivalController.cs (modify) |
Extend TeleportArrivalRules.Decide with the outdoorReady axis. |
2 |
src/AcDream.Core/Physics/PhysicsDiagnostics.cs (modify) |
ProbeArrivalGateEnabled flag for the apparatus probe. |
2 |
src/AcDream.App/World/TeleportFlowController.cs (new) |
The TAS orchestrator: wraps the sequencer, drives side effects via delegates. | 3 |
src/AcDream.App/UI/TeleportFadeOverlay.cs (new) |
Full-screen black alpha-quad UiElement, driven by Snapshot.FadeAlpha. |
3 |
tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs (new) |
Delegate-dispatch unit tests (spy delegates + stub readiness). | 3 |
tests/AcDream.App.Tests/UI/TeleportFadeOverlayTests.cs (new) |
Alpha→Visible + fill-color unit tests. | 3 |
src/AcDream.App/Rendering/GameWindow.cs (modify) |
Wiring only (god object — no feature bodies): construct + tick the controller, split PlaceTeleportArrival, the four entry points, the yaw-freeze, the portal sounds, the login de-dup, the logout command. |
3,4,5 |
docs/architecture/retail-divergence-register.md (modify) |
The four deviation rows (§5 of the design). | 1,3,5 |
Architecture — the pinned controller design (read before slices 3–5)
The relationship between the pieces is load-bearing; the slices assume exactly this shape:
TeleportAnimSequencer(pure, Slice 1) is the 7-state machine. It holds inTunnelwhile!worldReady(no internal timeout), emitsPlacewhen it leavesTunnel, andFireLoginCompletewhen it reachesOff.FadeAlphais 0 inside the tunnel states and smoothsteps on the fade states.TeleportFlowController(Slice 3) owns the sequencer and all timing/lock/placement decisions. EachTick(dt)it computesworldReadyfrom the injected readiness probe (Ready∨Impossible∨ frame-timeout, else hold; Logout has no destination so it is intrinsically ready), ticks the sequencer, and dispatches the sequencer's events to injected delegates:onPlace(place the player),onLoginComplete(flipPortalSpace→InWorld+ send GameAction0xA1),onLogoutDisconnect,onPlayEnterSound,onPlayExitSound.- The input lock (
PlayerState.PortalSpace) persists the WHOLE animation. It is set at the entry point and cleared only inonLoginComplete(sequencer reachedOff) — not at placement. This is whyPlaceTeleportArrivalis split in Slice 3: the placement half (Resolve + SetPosition + camera) runs on thePlaceevent; theState=InWorld+LoginCompletehalf runs atOff. Begin(kind, destPos, destCell)is idempotent on the sequencer. Portal triggers it twice — once with no coords (OnTeleportStarted) and once with coords (OnLivePositionUpdated); the second call fills the destination without restarting the animation.- Entry points: Portal/Death enter at
Tunnel(await coords →onPlace); Login enters atTunnelalready-placed (readiness returnsReadyimmediately → fast exit;onPlacere-resolves harmlessly); Logout enters atWorldFadeOutand ends inonLogoutDisconnect.
Cross-slice conventions (so the slices compose):
- The 4th
Decidearg is namedoutdoorReadyeverywhere (terrain-ready for login, landblock-ready for teleport). - The yaw-freeze lives only in Slice 4 (one early-return covering both MMB look + RMB orbit); it is not repeated in slices 3 or 5.
PlaceTeleportArrivalis split exactly once (Slice 3) intoPlaceTeleportArrivalCore+OnTeleportLoginComplete; slices 4/5 consume those.- The portal sound enum is
DatReaderWriter.Enums.Sound.EnterPortal/ExitPortal(verified — notUI_EnterPortal=0x6A); the CoreSoundIdenum is untouched. - The old
TeleportArrivalController(the bare hold) becomes unused once the portal path routes through_teleportFlow; its field/plumbing removal is deferred to the Slice 5 de-dup commit to avoid a half-removed field mid-plan.
Slice 1: TeleportAnimSequencer — pure 7-state machine + unit tests
Placement decision: src/AcDream.Core/World/TeleportAnimSequencer.cs in namespace AcDream.Core.World. Core already owns src/AcDream.Core/World/ (DerethDateTime, WorldEntity, etc.) and carries no GL/net dependency. The enums and record from the shared-type contract land here alongside the sequencer. Tests in tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs.
Task 1.1: Create the types file (enums + record)
Files:
-
Create:
src/AcDream.Core/World/TeleportAnimSequencer.cs -
Test:
tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs -
Step 1: Write the failing test — the test file references
TeleportAnimState,TeleportEntryKind,TeleportAnimEvent, andTeleportAnimSnapshotinAcDream.Core.World; the test project already referencesAcDream.Core(tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj:31).
// tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
using AcDream.Core.World;
namespace AcDream.Core.Tests.World;
public sealed class TeleportAnimSequencerTests
{
// --- Task 1.1: type presence ---
[Fact]
public void Enums_HaveExpectedValues()
{
Assert.Equal(0, (int)TeleportAnimState.Off);
Assert.Equal(1, (int)TeleportAnimState.WorldFadeOut);
Assert.Equal(2, (int)TeleportAnimState.TunnelFadeIn);
Assert.Equal(3, (int)TeleportAnimState.Tunnel);
Assert.Equal(4, (int)TeleportAnimState.TunnelContinue);
Assert.Equal(5, (int)TeleportAnimState.TunnelFadeOut);
Assert.Equal(6, (int)TeleportAnimState.WorldFadeIn);
_ = TeleportEntryKind.Portal;
_ = TeleportEntryKind.Login;
_ = TeleportEntryKind.Death;
_ = TeleportEntryKind.Logout;
_ = TeleportAnimEvent.PlayEnterSound;
_ = TeleportAnimEvent.PlayExitSound;
_ = TeleportAnimEvent.Place;
_ = TeleportAnimEvent.FireLoginComplete;
_ = TeleportAnimEvent.EnterTunnel;
}
[Fact]
public void Snapshot_DefaultsAreOff_ClearAlpha()
{
var snap = new TeleportAnimSnapshot(
TeleportAnimState.Off, FadeAlpha: 0f, ShowTunnel: false, ShowPleaseWait: false);
Assert.Equal(TeleportAnimState.Off, snap.State);
Assert.Equal(0f, snap.FadeAlpha);
Assert.False(snap.ShowTunnel);
Assert.False(snap.ShowPleaseWait);
}
}
-
Step 2: Run to verify it fails
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Enums_HaveExpectedValues"Expected: FAIL —
TeleportAnimStatedoes not exist yet. -
Step 3: Implement — create the types (no sequencer class body yet, that comes in Task 1.2):
// src/AcDream.Core/World/TeleportAnimSequencer.cs
namespace AcDream.Core.World;
// acclient.h:6871 — TeleportAnimState enum, verbatim order.
public enum TeleportAnimState
{
Off = 0,
WorldFadeOut = 1,
TunnelFadeIn = 2,
Tunnel = 3,
TunnelContinue = 4,
TunnelFadeOut = 5,
WorldFadeIn = 6,
}
/// <summary>Why the teleport was triggered — drives per-entry start state (spec §2.5).</summary>
public enum TeleportEntryKind { Portal, Login, Death, Logout }
/// <summary>
/// Edge-triggered events the sequencer emits on the tick they first occur.
/// Consumers drive audio / placement / LoginComplete from these; the sequencer
/// has no dependency on any of those systems.
/// </summary>
public enum TeleportAnimEvent
{
PlayEnterSound, // Begin(): sound_ui_enter_portal
EnterTunnel, // Off/WorldFade* -> Tunnel: world is now hidden
Place, // Tunnel -> TunnelContinue: world loaded; place the player
PlayExitSound, // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal
FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1
}
/// <summary>Immutable per-frame snapshot from the sequencer.</summary>
public readonly record struct TeleportAnimSnapshot(
TeleportAnimState State,
float FadeAlpha, // 0 = clear world, 1 = full black
bool ShowTunnel, // true during TunnelFadeIn..TunnelFadeOut
bool ShowPleaseWait); // true during TunnelContinue only
-
Step 4: Run to verify it passes
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"Expected: PASS.
-
Step 5: Commit
git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs git commit -m "feat(core/slice-1): TeleportAnimSequencer — define enums, record, event types"
Task 1.2: Sequencer skeleton + Begin(), IsActive, initial state
Files:
-
Modify:
src/AcDream.Core/World/TeleportAnimSequencer.cs -
Modify:
tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs -
Step 1: Write the failing test
// Add to TeleportAnimSequencerTests:
[Theory]
[InlineData(TeleportEntryKind.Portal, TeleportAnimState.Tunnel)]
[InlineData(TeleportEntryKind.Login, TeleportAnimState.Tunnel)]
[InlineData(TeleportEntryKind.Death, TeleportAnimState.Tunnel)]
[InlineData(TeleportEntryKind.Logout, TeleportAnimState.WorldFadeOut)]
public void Begin_SetsCorrectStartState(TeleportEntryKind kind, TeleportAnimState expectedStart)
{
var seq = new TeleportAnimSequencer();
Assert.False(seq.IsActive);
seq.Begin(kind);
Assert.True(seq.IsActive);
Assert.Equal(expectedStart, seq.State);
}
[Fact]
public void Begin_EmitsPlayEnterSoundOnFirstTick()
{
// PlayEnterSound is edge-triggered on the first Tick after Begin.
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
var (_, events) = seq.Tick(dt: 0f, worldReady: false);
Assert.Contains(TeleportAnimEvent.PlayEnterSound, events);
}
-
Step 2: Run to verify it fails
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Begin_SetsCorrectStartState"Expected: FAIL —
TeleportAnimSequencerclass missing. -
Step 3: Implement — add class body to the same file (golden constants + Begin + Tick stub):
// Append to src/AcDream.Core/World/TeleportAnimSequencer.cs
/// <summary>
/// Pure 7-state teleport animation machine (spec §2.1, acclient gmSmartBoxUI::UseTime 0x004d6e30).
/// No GL / dat / network dependency — fully unit-testable.
/// </summary>
public sealed class TeleportAnimSequencer
{
// Golden constants (spec §2.1, verified VAs):
// TELEPORT_ANIM_FADE_TIME 0x007BD278 = 1.0s
// TELEPORT_ANIM_MIN_CONTINUE_TIME 0x007BD268 = 2.0s
// TELEPORT_ANIM_MAX_CONTINUE_TIME 0x007BD270 = 5.0s
public const float FadeTime = 1.0f;
public const float MinContinue = 2.0f;
public const float MaxContinue = 5.0f;
private TeleportAnimState _state = TeleportAnimState.Off;
private float _elapsed = 0f; // time in current state
private bool _enterSoundPending = false; // edge: fire PlayEnterSound on next Tick
public bool IsActive => _state != TeleportAnimState.Off;
public TeleportAnimState State => _state;
/// <summary>
/// Start the animation. Portal/Login/Death enter at Tunnel (skipping world-fade-out);
/// Logout enters at WorldFadeOut (spec §2.5).
/// </summary>
public void Begin(TeleportEntryKind kind)
{
_state = kind == TeleportEntryKind.Logout
? TeleportAnimState.WorldFadeOut
: TeleportAnimState.Tunnel;
_elapsed = 0f;
_enterSoundPending = true;
}
/// <summary>
/// Advance the machine by <paramref name="dt"/> seconds.
/// <paramref name="worldReady"/> = destination collision landblock is resident.
/// Returns the current snapshot + edge-triggered events fired THIS tick.
/// </summary>
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
Tick(float dt, bool worldReady)
{
// Full implementation in Task 1.3 – stub returns current state with no transitions.
var evts = new List<TeleportAnimEvent>();
if (_enterSoundPending)
{
evts.Add(TeleportAnimEvent.PlayEnterSound);
_enterSoundPending = false;
}
_elapsed += dt;
var snap = BuildSnapshot();
return (snap, evts);
}
private TeleportAnimSnapshot BuildSnapshot()
{
float alpha = ComputeFadeAlpha(_state, _elapsed);
bool showTunnel = _state is TeleportAnimState.TunnelFadeIn
or TeleportAnimState.Tunnel
or TeleportAnimState.TunnelContinue
or TeleportAnimState.TunnelFadeOut;
bool pleaseWait = _state == TeleportAnimState.TunnelContinue;
return new TeleportAnimSnapshot(_state, alpha, showTunnel, pleaseWait);
}
private static float Smoothstep(float t)
{
t = Math.Clamp(t, 0f, 1f);
return t * t * (3f - 2f * t);
}
private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed)
{
float t = Math.Clamp(elapsed / FadeTime, 0f, 1f);
return state switch
{
// Fading TO black (alpha 0→1):
TeleportAnimState.WorldFadeOut => Smoothstep(t),
TeleportAnimState.TunnelFadeIn => 1f - Smoothstep(t), // tunnel fades IN: overlay goes clear
// Full black / fully clear inside tunnel states:
TeleportAnimState.Tunnel => 0f, // world hidden, overlay not needed
TeleportAnimState.TunnelContinue => 0f,
// Fading back out of tunnel:
TeleportAnimState.TunnelFadeOut => Smoothstep(t), // tunnel fades out: overlay goes black
TeleportAnimState.WorldFadeIn => 1f - Smoothstep(t), // world fades in: overlay clears
_ => 0f,
};
}
}
-
Step 4: Run to verify it passes
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"Expected: PASS (all tests so far).
-
Step 5: Commit
git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs git commit -m "feat(core/slice-1): TeleportAnimSequencer — Begin(), IsActive, enter-sound edge event"
Task 1.3: State transitions — timing-correct Tick() implementation
Files:
-
Modify:
src/AcDream.Core/World/TeleportAnimSequencer.cs -
Modify:
tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs -
Step 1: Write the failing tests — drive the machine through each transition with exact elapsed times:
// Add to TeleportAnimSequencerTests:
// Helper: drive the sequencer forward by total elapsed time using small fixed steps.
private static (TeleportAnimSnapshot snap, List<TeleportAnimEvent> allEvents)
DriveSeconds(TeleportAnimSequencer seq, float seconds, bool worldReady, float step = 0.016f)
{
var allEvts = new List<TeleportAnimEvent>();
float remaining = seconds;
TeleportAnimSnapshot last = default;
while (remaining > 0f)
{
float dt = Math.Min(step, remaining);
var (snap, evts) = seq.Tick(dt, worldReady);
last = snap;
allEvts.AddRange(evts);
remaining -= dt;
}
return (last, allEvts);
}
// --- Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel ---
[Fact]
public void Logout_AfterFadeTime_TransitionsToTunnelFadeIn()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
// Consume the enter-sound tick at dt=0
seq.Tick(0f, worldReady: false);
// Drive just past FadeTime (1.0s)
DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false);
Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State);
}
[Fact]
public void Logout_After2xFadeTime_TransitionsToTunnel()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
seq.Tick(0f, worldReady: false); // consume enter-sound
DriveSeconds(seq, 2f * TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false);
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
}
// --- Portal/Login/Death path: enters at Tunnel ---
[Fact]
public void Portal_StartsInTunnel_HoldsWhileNotReady()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false); // consume enter-sound
// Drive 10s — should not advance past Tunnel while !worldReady
DriveSeconds(seq, 10f, worldReady: false);
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
}
[Fact]
public void Portal_WhenWorldReady_TransitionsToTunnelContinue_EmitsEnterTunnelAndPlace()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false); // consume enter-sound
// The very first tick with worldReady=true should advance Tunnel→TunnelContinue and emit Place.
var (_, evts) = seq.Tick(0.016f, worldReady: true);
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
Assert.Contains(TeleportAnimEvent.Place, evts);
}
// --- TunnelContinue: MIN_CONTINUE hold then TunnelFadeOut ---
[Fact]
public void TunnelContinue_DoesNotAdvance_BeforeMinContinue()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
// Enter TunnelContinue
seq.Tick(0.016f, worldReady: true);
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
// Drive MinContinue - ε — should still be TunnelContinue
DriveSeconds(seq, TeleportAnimSequencer.MinContinue - 0.1f, worldReady: true);
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
}
[Fact]
public void TunnelContinue_AdvancesToTunnelFadeOut_AfterMinContinue()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
}
// --- MAX_CONTINUE forces progress even when !worldReady ---
[Fact]
public void TunnelContinue_ForcesAdvance_AtMaxContinue_EvenIfNotReady()
{
// Simulate: world never becomes fully ready but MAX_CONTINUE forces fade-out
// This path exercises the safety-net from spec §3.4.
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
// Manually push the state to TunnelContinue by passing worldReady=true for one tick,
// then simulate worldReady toggling back to false.
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
// Drive MaxContinue + ε with worldReady=false (simulating "world never loaded")
DriveSeconds(seq, TeleportAnimSequencer.MaxContinue + 0.05f, worldReady: false);
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
}
// --- TunnelFadeOut -> WorldFadeIn: PlayExitSound edge event ---
[Fact]
public void TunnelFadeOut_EmitsPlayExitSound_OnTransitionToWorldFadeIn()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
// Drive through MinContinue -> TunnelFadeOut
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
// Drive through FadeTime -> WorldFadeIn; PlayExitSound should fire on that edge
var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true);
Assert.Equal(TeleportAnimState.WorldFadeIn, seq.State);
Assert.Contains(TeleportAnimEvent.PlayExitSound, evts);
}
// --- WorldFadeIn -> Off: FireLoginComplete edge event ---
[Fact]
public void WorldFadeIn_EmitsFireLoginComplete_OnTransitionToOff()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); // -> WorldFadeIn
var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true);
Assert.Equal(TeleportAnimState.Off, seq.State);
Assert.False(seq.IsActive);
Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts);
}
-
Step 2: Run to verify it fails
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_WhenWorldReady_TransitionsToTunnelContinue"Expected: FAIL — Tick() stub never advances state.
-
Step 3: Implement — replace the
Tick()stub inTeleportAnimSequencerwith the full transition machine. Also tracks elapsed-in-state and_continueElapsedfor the MIN/MAX gates:
// Replace the existing Tick() and add _continueElapsed field in TeleportAnimSequencer:
private float _continueElapsed = 0f; // tracks time inside TunnelContinue
private bool _exitSoundPending = false;
private bool _loginCompletePending = false;
private bool _enterTunnelPending = false;
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
Tick(float dt, bool worldReady)
{
var evts = new List<TeleportAnimEvent>();
// Edge events queued by Begin or a prior transition:
if (_enterSoundPending) { evts.Add(TeleportAnimEvent.PlayEnterSound); _enterSoundPending = false; }
if (_enterTunnelPending) { evts.Add(TeleportAnimEvent.EnterTunnel); _enterTunnelPending = false; }
if (_exitSoundPending) { evts.Add(TeleportAnimEvent.PlayExitSound); _exitSoundPending = false; }
if (_loginCompletePending){ evts.Add(TeleportAnimEvent.FireLoginComplete); _loginCompletePending = false; }
_elapsed += dt;
// State-machine transitions:
switch (_state)
{
case TeleportAnimState.WorldFadeOut:
if (_elapsed >= FadeTime)
Advance(TeleportAnimState.TunnelFadeIn, evts, enterTunnel: false);
break;
case TeleportAnimState.TunnelFadeIn:
if (_elapsed >= FadeTime)
Advance(TeleportAnimState.Tunnel, evts, enterTunnel: true);
break;
case TeleportAnimState.Tunnel:
// Hold here until worldReady (EndTeleportAnimation analogue).
if (worldReady)
{
evts.Add(TeleportAnimEvent.Place);
Advance(TeleportAnimState.TunnelContinue, evts, enterTunnel: false);
_continueElapsed = 0f;
}
break;
case TeleportAnimState.TunnelContinue:
_continueElapsed += dt;
bool minMet = _continueElapsed >= MinContinue;
bool maxForce = _continueElapsed >= MaxContinue;
if (minMet || maxForce)
Advance(TeleportAnimState.TunnelFadeOut, evts, enterTunnel: false);
break;
case TeleportAnimState.TunnelFadeOut:
if (_elapsed >= FadeTime)
{
_exitSoundPending = true;
Advance(TeleportAnimState.WorldFadeIn, evts, enterTunnel: false);
// Flush the exit sound in the same tick:
evts.Add(TeleportAnimEvent.PlayExitSound);
_exitSoundPending = false;
}
break;
case TeleportAnimState.WorldFadeIn:
if (_elapsed >= FadeTime)
{
_loginCompletePending = true;
Advance(TeleportAnimState.Off, evts, enterTunnel: false);
evts.Add(TeleportAnimEvent.FireLoginComplete);
_loginCompletePending = false;
}
break;
case TeleportAnimState.Off:
default:
break;
}
return (BuildSnapshot(), evts);
}
private void Advance(TeleportAnimState next, List<TeleportAnimEvent> evts, bool enterTunnel)
{
_state = next;
_elapsed = 0f;
if (enterTunnel) _enterTunnelPending = true;
}
-
Step 4: Run to verify it passes
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"Expected: PASS (all transition tests).
-
Step 5: Commit
git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs git commit -m "feat(core/slice-1): TeleportAnimSequencer — full 7-state Tick() with timed transitions and edge events"
Task 1.4: FadeAlpha endpoints and monotonicity
Files:
-
Modify:
tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs -
Step 1: Write the failing tests
// Add to TeleportAnimSequencerTests:
[Fact]
public void FadeAlpha_IsZeroAtStart_OfWorldFadeOut()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
var (snap, _) = seq.Tick(dt: 0f, worldReady: false);
// At elapsed=0 in WorldFadeOut: smoothstep(0)=0 => alpha=0 (world fully visible).
Assert.Equal(0f, snap.FadeAlpha, precision: 4);
}
[Fact]
public void FadeAlpha_IsOneAtEnd_OfWorldFadeOut()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
seq.Tick(0f, worldReady: false); // consume enter-sound tick, elapsed≈0
// Drive to just BEFORE the transition (so we're still in WorldFadeOut)
DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.02f, worldReady: false);
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
var (snap, _) = seq.Tick(0f, worldReady: false);
// smoothstep(clamp((1.0-0.02)/1.0,0,1)) should be close to 1
Assert.True(snap.FadeAlpha > 0.95f, $"Expected FadeAlpha near 1, got {snap.FadeAlpha}");
}
[Fact]
public void FadeAlpha_IsMonotonicallyIncreasing_DuringWorldFadeOut()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
seq.Tick(0f, worldReady: false);
float prev = -1f;
// Sample 50 steps within FadeTime
float step = TeleportAnimSequencer.FadeTime / 50f;
for (int i = 0; i < 48; i++) // stop before transition
{
var (snap, _) = seq.Tick(step, worldReady: false);
if (seq.State != TeleportAnimState.WorldFadeOut) break;
Assert.True(snap.FadeAlpha >= prev - 0.001f,
$"Alpha decreased: {prev} -> {snap.FadeAlpha} at step {i}");
prev = snap.FadeAlpha;
}
}
[Fact]
public void FadeAlpha_IsZero_DuringTunnelStates()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
// In Tunnel state, overlay alpha should be 0 (the tunnel viewport is shown; no alpha quad needed)
var (snap, _) = seq.Tick(0.016f, worldReady: false);
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
Assert.Equal(0f, snap.FadeAlpha);
}
[Fact]
public void ShowTunnel_TrueInTunnelFamilyStates_FalseOtherwise()
{
// Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel -> TunnelContinue -> TunnelFadeOut -> WorldFadeIn -> Off
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
seq.Tick(0f, worldReady: false);
// WorldFadeOut: ShowTunnel = false
var (snap, _) = seq.Tick(0.016f, worldReady: false);
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
Assert.False(snap.ShowTunnel);
// Advance to TunnelFadeIn
DriveSeconds(seq, TeleportAnimSequencer.FadeTime, worldReady: false);
Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State);
var (snap2, _) = seq.Tick(0f, worldReady: false);
Assert.True(snap2.ShowTunnel);
// Advance to Tunnel
DriveSeconds(seq, TeleportAnimSequencer.FadeTime, worldReady: false);
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
var (snap3, _) = seq.Tick(0f, worldReady: false);
Assert.True(snap3.ShowTunnel);
}
[Fact]
public void ShowPleaseWait_TrueOnlyInTunnelContinue()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
// Tunnel: ShowPleaseWait false
var (snap1, _) = seq.Tick(0.016f, worldReady: false);
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
Assert.False(snap1.ShowPleaseWait);
// Advance to TunnelContinue
var (snap2, _) = seq.Tick(0.016f, worldReady: true);
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
Assert.True(snap2.ShowPleaseWait);
}
-
Step 2: Run to verify it fails
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.FadeAlpha_IsZeroAtStart_OfWorldFadeOut"Expected: FAIL —
ComputeFadeAlpharesult not yet connected throughTick(the stub returned 0 unconditionally, Task 1.2 hadn't wiredBuildSnapshotinto the real state inTick). After Task 1.3 landed, the alpha logic is correct — this test validates it runs through the right smoothstep path. (If Task 1.3 is already green, these may pass immediately; the step 2 confirms they exercise the code path.) -
Step 3: Implement —
ComputeFadeAlphais already implemented in Task 1.2; no new implementation needed. The only refinement: ensure_elapsedis sampled correctly relative to the current state's entry time (already reset to 0f inAdvance). No code changes expected if Task 1.3 is green. -
Step 4: Run to verify it passes
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"Expected: PASS.
-
Step 5: Commit
git add tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs git commit -m "test(core/slice-1): FadeAlpha endpoint + monotonicity + ShowTunnel/ShowPleaseWait coverage"
Task 1.5: Full portal event sequence and logout event sequence
Files:
-
Modify:
tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs -
Step 1: Write the failing tests — exercise the complete ordered event stream for each major path:
// Add to TeleportAnimSequencerTests:
// Helper: run the sequencer to completion collecting ordered events.
private static List<TeleportAnimEvent> RunToOff(
TeleportEntryKind kind, bool worldReadyAfterFirstTunnel = true, float step = 0.05f)
{
var seq = new TeleportAnimSequencer();
var allEvts = new List<TeleportAnimEvent>();
seq.Begin(kind);
int safetyNet = 5000;
while (seq.IsActive && --safetyNet > 0)
{
// Simulate world becoming ready on first Tick inside Tunnel
bool ready = worldReadyAfterFirstTunnel && seq.State == TeleportAnimState.Tunnel;
var (_, evts) = seq.Tick(step, worldReady: ready);
allEvts.AddRange(evts);
}
return allEvts;
}
[Fact]
public void Portal_FullSequence_EventsInOrder()
{
var evts = RunToOff(TeleportEntryKind.Portal);
// Required events in order: PlayEnterSound, Place, PlayExitSound, FireLoginComplete.
// EnterTunnel is optional but must come before Place if present.
int enterIdx = evts.IndexOf(TeleportAnimEvent.PlayEnterSound);
int placeIdx = evts.IndexOf(TeleportAnimEvent.Place);
int exitIdx = evts.IndexOf(TeleportAnimEvent.PlayExitSound);
int loginIdx = evts.IndexOf(TeleportAnimEvent.FireLoginComplete);
Assert.True(enterIdx >= 0, "PlayEnterSound must fire");
Assert.True(placeIdx >= 0, "Place must fire");
Assert.True(exitIdx >= 0, "PlayExitSound must fire");
Assert.True(loginIdx >= 0, "FireLoginComplete must fire");
Assert.True(enterIdx < placeIdx, "PlayEnterSound must precede Place");
Assert.True(placeIdx < exitIdx, "Place must precede PlayExitSound");
Assert.True(exitIdx < loginIdx, "PlayExitSound must precede FireLoginComplete");
}
[Fact]
public void Logout_FullSequence_EventsInOrder_WithEnterTunnelAfterFades()
{
var evts = RunToOff(TeleportEntryKind.Logout);
int enterIdx = evts.IndexOf(TeleportAnimEvent.PlayEnterSound);
int tunnelIdx = evts.IndexOf(TeleportAnimEvent.EnterTunnel);
int placeIdx = evts.IndexOf(TeleportAnimEvent.Place);
int exitIdx = evts.IndexOf(TeleportAnimEvent.PlayExitSound);
int loginIdx = evts.IndexOf(TeleportAnimEvent.FireLoginComplete);
Assert.True(enterIdx >= 0, "PlayEnterSound must fire");
Assert.True(tunnelIdx >= 0, "EnterTunnel must fire (Logout path goes through WorldFadeOut->TunnelFadeIn->Tunnel)");
Assert.True(placeIdx >= 0, "Place must fire");
Assert.True(exitIdx >= 0, "PlayExitSound must fire");
Assert.True(loginIdx >= 0, "FireLoginComplete must fire");
Assert.True(enterIdx < tunnelIdx, "PlayEnterSound must precede EnterTunnel");
Assert.True(tunnelIdx < placeIdx, "EnterTunnel must precede Place");
Assert.True(placeIdx < exitIdx, "Place must precede PlayExitSound");
Assert.True(exitIdx < loginIdx, "PlayExitSound must precede FireLoginComplete");
}
[Fact]
public void Portal_NoEventsFiredTwice()
{
var evts = RunToOff(TeleportEntryKind.Portal);
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayEnterSound));
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.Place));
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayExitSound));
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.FireLoginComplete));
}
[Fact]
public void Death_BehavesIdenticallyToPortal_FullSequence()
{
var portalEvts = RunToOff(TeleportEntryKind.Portal);
var deathEvts = RunToOff(TeleportEntryKind.Death);
// Same event sequence (both enter at Tunnel)
Assert.Equal(portalEvts, deathEvts);
}
[Fact]
public void Login_BehavesIdenticallyToPortal_FullSequence()
{
var portalEvts = RunToOff(TeleportEntryKind.Portal);
var loginEvts = RunToOff(TeleportEntryKind.Login);
Assert.Equal(portalEvts, loginEvts);
}
[Fact]
public void AfterOff_IsActiveIsFalse_AndStateIsOff()
{
RunToOff(TeleportEntryKind.Portal);
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
// Drain to Off
for (int i = 0; i < 5000 && seq.IsActive; i++)
{
bool ready = seq.State == TeleportAnimState.Tunnel;
seq.Tick(0.05f, worldReady: ready);
}
Assert.False(seq.IsActive);
Assert.Equal(TeleportAnimState.Off, seq.State);
}
-
Step 2: Run to verify it fails
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_FullSequence_EventsInOrder"Expected: FAIL —
EnterTunnelevent not fired correctly through Logout's TunnelFadeIn→Tunnel transition in current stub (Task 1.3's_enterTunnelPendingwiring needs verification). -
Step 3: Implement — verify
_enterTunnelPendingis flushed at the START of the NEXT Tick, not deferred further. The currentTick()already has:if (_enterTunnelPending) { evts.Add(TeleportAnimEvent.EnterTunnel); _enterTunnelPending = false; }which runs before the state-machine switch. The
Advance(TunnelFadeIn→Tunnel, enterTunnel: true)sets_enterTunnelPending = true, which flushes on the next Tick. This is already correct. No code change needed — theRunToOffhelper exercises the Logout path which was not hit before. If EnterTunnel fires on the same tick as the transition, it is counted inallEvtscorrectly becauseDriveSecondsaccumulates all per-tick events. -
Step 4: Run to verify it passes
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"Expected: PASS for all sequencer tests.
-
Step 5: Commit
git add tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs git commit -m "test(core/slice-1): full portal+logout event-sequence ordering + no-duplicate-fire coverage"
Task 1.6: EnterTunnel fired for Portal path via Logout-to-Tunnel transition correctness + Build green
Files:
-
Modify:
src/AcDream.Core/World/TeleportAnimSequencer.cs(if any gap surfaces) -
Run:
dotnet buildand full test suite -
Step 1: Write the failing test — assert EnterTunnel fires on the Portal path (Portal enters Tunnel directly; the Begin sets state to Tunnel but EnterTunnel should still fire since the tunnel is newly entered):
// Add to TeleportAnimSequencerTests:
[Fact]
public void Portal_EmitsEnterTunnel_OnFirstTick()
{
// Portal begins directly in Tunnel; EnterTunnel signals "world is now hidden".
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
// First tick: should emit PlayEnterSound AND EnterTunnel (both on entry)
var (_, evts) = seq.Tick(0f, worldReady: false);
Assert.Contains(TeleportAnimEvent.PlayEnterSound, evts);
Assert.Contains(TeleportAnimEvent.EnterTunnel, evts);
}
-
Step 2: Run to verify it fails
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_EmitsEnterTunnel_OnFirstTick"Expected: FAIL —
Beginwith Portal kind doesn't set_enterTunnelPending. -
Step 3: Implement — update
Begin()to also set_enterTunnelPendingwhen entering at Tunnel:
public void Begin(TeleportEntryKind kind)
{
_state = kind == TeleportEntryKind.Logout
? TeleportAnimState.WorldFadeOut
: TeleportAnimState.Tunnel;
_elapsed = 0f;
_continueElapsed = 0f;
_enterSoundPending = true;
_enterTunnelPending = _state == TeleportAnimState.Tunnel; // true for Portal/Login/Death
_exitSoundPending = false;
_loginCompletePending = false;
}
-
Step 4: Run all sequencer tests + build
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests" dotnet build src/AcDream.Core/AcDream.Core.csprojExpected: all PASS, build green.
-
Step 5: Commit
git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs git commit -m "feat(core/slice-1): Begin() sets EnterTunnel pending for portal/login/death entry; all sequencer tests pass"
Task 1.7: Full build + full test suite green; register divergence row
Files:
-
Modify:
docs/architecture/retail-divergence-register.md(add smoothstep row per spec §5 / §3.2) -
Step 1: No test to write — this is the completion gate.
-
Step 2: Run full suite:
dotnet build dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csprojExpected: build green; all tests pass (including pre-existing suite).
-
Step 3: Implement — add the divergence-register row. Read the existing register to find the insertion point: Open
docs/architecture/retail-divergence-register.md, locate the table body, and add one row:| TAS-smoothstep | TeleportAnimSequencer.ComputeFadeAlpha | GetAnimLevel 1024-entry table approximated as smoothstep | approximation | src/AcDream.Core/World/TeleportAnimSequencer.cs | retire if GetAnimLevel table resolved via cdb read (spec §8) |(Exact column order matches the existing register rows — read the file header before inserting.)
-
Step 4: Build + test green (same commands as Step 2).
-
Step 5: Commit
git add docs/architecture/retail-divergence-register.md git commit -m "docs(slice-1): retail-divergence-register — TAS smoothstep approximation row (spec §5 row 2)"
Slice 2 Task List — Readiness Gate (#145 Residual / Work-Item-A Fix)
Task 2.1: Add PhysicsEngine.IsLandblockLoaded with unit test
Files:
-
Modify:
src/AcDream.Core/Physics/PhysicsEngine.cs(afterLandblockCountproperty, line 32) -
Test:
tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs(new file) -
Step 1: Write the failing test
// tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class PhysicsEngineIsLandblockLoadedTests
{
private static TerrainSurface MakeFlatTerrain()
{
var heights = new byte[81];
Array.Fill(heights, (byte)50);
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
return new TerrainSurface(heights, heightTable);
}
[Fact]
public void IsLandblockLoaded_AbsentId_ReturnsFalse()
{
var engine = new PhysicsEngine();
Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu));
}
[Fact]
public void IsLandblockLoaded_AddedId_ReturnsTrue()
{
var engine = new PhysicsEngine();
engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
Assert.True(engine.IsLandblockLoaded(0xA9B4FFFFu));
}
[Fact]
public void IsLandblockLoaded_AnotherIdAbsent_ReturnsFalse()
{
var engine = new PhysicsEngine();
engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
Assert.False(engine.IsLandblockLoaded(0xDEADBEEFu));
}
[Fact]
public void IsLandblockLoaded_AfterRemove_ReturnsFalse()
{
var engine = new PhysicsEngine();
engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
engine.RemoveLandblock(0xA9B4FFFFu);
Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu));
}
}
-
Step 2: Run to verify it fails
Run:
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsEngineIsLandblockLoadedTests"Expected: FAIL —
CS1061: 'PhysicsEngine' does not contain a definition for 'IsLandblockLoaded'. -
Step 3: Implement
In
src/AcDream.Core/Physics/PhysicsEngine.cs, afterLandblockCount(line 32):/// <summary> /// Returns true iff a landblock with this id has been registered via /// <see cref="AddLandblock"/>. Because <see cref="AddLandblock"/> registers /// terrain + collision cells + portals atomically, ContainsKey implies the /// collision mesh is present and resolvable. /// </summary> public bool IsLandblockLoaded(uint landblockId) => _landblocks.ContainsKey(landblockId); -
Step 4: Run to verify it passes
Run:
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsEngineIsLandblockLoadedTests"Expected: PASS (4 tests, all green).
-
Step 5: Commit
git add src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs && git commit -m "feat(slice2): add PhysicsEngine.IsLandblockLoaded — readiness gate §3.4"
Task 2.2: Extend TeleportArrivalRules.Decide with outdoorReady and update truth-table tests
Files:
-
Modify:
src/AcDream.App/World/TeleportArrivalController.cs(TeleportArrivalRules.Decide, line 119) -
Modify:
tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs(extend existing tests + add new ones) -
Step 1: Write the failing test
Replace the content of
tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs:using AcDream.App.World; using Xunit; namespace AcDream.App.Tests.World; /// <summary> /// Full truth-table for TeleportArrivalRules.Decide after the §3.4 readiness-gate change. /// </summary> public class TeleportArrivalRulesTests { [Fact] public void Unhydratable_IsImpossible_Outdoor() { Assert.Equal(ArrivalReadiness.Impossible, TeleportArrivalRules.Decide( claimUnhydratable: true, indoor: false, indoorCellReady: false, outdoorReady: false)); } [Fact] public void Unhydratable_IsImpossible_Indoor() { Assert.Equal(ArrivalReadiness.Impossible, TeleportArrivalRules.Decide( claimUnhydratable: true, indoor: true, indoorCellReady: true, outdoorReady: false)); } [Fact] public void Indoor_CellReady_IsReady() { Assert.Equal(ArrivalReadiness.Ready, TeleportArrivalRules.Decide( claimUnhydratable: false, indoor: true, indoorCellReady: true, outdoorReady: false)); } [Fact] public void Indoor_CellNotReady_IsNotReady() { Assert.Equal(ArrivalReadiness.NotReady, TeleportArrivalRules.Decide( claimUnhydratable: false, indoor: true, indoorCellReady: false, outdoorReady: false)); } [Fact] public void Outdoor_LandblockLoaded_IsReady() { Assert.Equal(ArrivalReadiness.Ready, TeleportArrivalRules.Decide( claimUnhydratable: false, indoor: false, indoorCellReady: false, outdoorReady: true)); } [Fact] public void Outdoor_LandblockNotLoaded_IsNotReady() { // §3.4: the #145 residual — outdoor arrival on a not-yet-streamed landblock // must hold. Previously returned Ready immediately. Assert.Equal(ArrivalReadiness.NotReady, TeleportArrivalRules.Decide( claimUnhydratable: false, indoor: false, indoorCellReady: false, outdoorReady: false)); } } -
Step 2: Run to verify it fails
Run:
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests"Expected: FAIL —
Outdoor_LandblockNotLoaded_IsNotReadyfails (current code returnsReadyfor all outdoor), andOutdoor_LandblockLoaded_IsReadyfails with a compile error because the new parameter doesn't exist yet. -
Step 3: Implement
In
src/AcDream.App/World/TeleportArrivalController.cs, replaceTeleportArrivalRules.Decide(lines 119–143):/// <summary> /// Decide whether a held teleport arrival can place now. /// </summary> /// <param name="claimUnhydratable">The destination cell can never hydrate.</param> /// <param name="indoor">Destination is an indoor cell (cell index ≥ 0x0100).</param> /// <param name="indoorCellReady">For an indoor destination, the EnvCell floor has hydrated.</param> /// <param name="outdoorReady">For an outdoor destination, the physics landblock is loaded /// (<see cref="PhysicsEngine.IsLandblockLoaded"/> returned true for the destination landblock id). /// Ignored for indoor destinations.</param> public static ArrivalReadiness Decide( bool claimUnhydratable, bool indoor, bool indoorCellReady, bool outdoorReady) { if (claimUnhydratable) return ArrivalReadiness.Impossible; // Indoor (sealed dungeon / building interior): gate on the EnvCell floor hydrating, // exactly as #135. Unaffected by the outdoor-landblock gate. if (indoor) return indoorCellReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady; // Outdoor (#145 §3.4): hold until the physics landblock is registered. // AddLandblock is atomic (terrain + cells + portals in one write — PhysicsEngine.cs:70), // so ContainsKey implies the collision mesh is present and resolvable. The hold is // self-resolving: streaming progresses unconditionally during the hold because the // streaming controller runs before TeleportArrivalController.Tick (GameWindow.cs:7420-7551). // The 10 s timeout safety-net (_maxHoldFrames) remains active. return outdoorReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady; } -
Step 4: Run to verify it passes
Run:
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests"Expected: PASS (6 tests, all green).
-
Step 5: Commit
git add src/AcDream.App/World/TeleportArrivalController.cs tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs && git commit -m "feat(slice2): TeleportArrivalRules.Decide — outdoor holds until landblock loaded (#145 §3.4)"
Task 2.3: Fix the compile break — update the one Decide call-site in GameWindow.cs
The signature change in Task 2.2 breaks the call at GameWindow.cs:5528. This task wires outdoorReady and verifies the build is green. There is no unit test for GameWindow wiring (god object); verification is build-green + a described manual run.
Files:
-
Modify:
src/AcDream.App/Rendering/GameWindow.cs(methodTeleportArrivalReadiness, around line 5513) -
Step 1: Locate the anchor —
GameWindow.cs:5528:// existing line 5528: return AcDream.App.World.TeleportArrivalRules.Decide( claimUnhydratable, indoor, indoorCellReady); -
Step 2: Apply the edit — replace the three-argument call with a four-argument call:
// After the existing: // bool indoor = (destCell & 0xFFFFu) >= 0x0100u; // bool indoorCellReady = indoor && !claimUnhydratable // && _physicsEngine.IsSpawnCellReady(destCell); // Add: bool outdoorReady = !indoor && _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u); return AcDream.App.World.TeleportArrivalRules.Decide( claimUnhydratable, indoor, indoorCellReady, outdoorReady);The full method body after the edit:
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness( System.Numerics.Vector3 destPos, uint destCell) { bool claimUnhydratable = IsSpawnClaimUnhydratable(destCell); bool indoor = (destCell & 0xFFFFu) >= 0x0100u; bool indoorCellReady = indoor && !claimUnhydratable && _physicsEngine.IsSpawnCellReady(destCell); bool outdoorReady = !indoor && _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u); return AcDream.App.World.TeleportArrivalRules.Decide( claimUnhydratable, indoor, indoorCellReady, outdoorReady); } -
Step 3: Build green
Run:
dotnet build src/AcDream.App/AcDream.App.csproj -c DebugExpected: zero errors.
-
Step 4: Commit
git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(slice2): wire outdoorReady into TeleportArrivalReadiness (GameWindow)"
Task 2.4: Add ProbeArrivalGate flag to PhysicsDiagnostics and log the NotReady → Ready transition
The existing PhysicsDiagnostics pattern (read PhysicsDiagnostics.cs:31–32 for the template): a static bool property seeded from an env var, listed in ResetForTest(). The probe log lives in TeleportArrivalReadiness in GameWindow.cs.
Files:
- Modify:
src/AcDream.Core/Physics/PhysicsDiagnostics.cs(add property +ResetForTestentry) - Modify:
src/AcDream.App/Rendering/GameWindow.cs(add_lastArrivalVerdictfield + probe emission inTeleportArrivalReadiness)
There is no unit test for a diagnostic emission; verification is the described manual run in Task 2.5.
-
Step 1: Add the probe flag to
PhysicsDiagnosticsIn
src/AcDream.Core/Physics/PhysicsDiagnostics.cs, afterProbeResolveEnabled(after line 32):/// <summary> /// Retail teleport readiness gate (§3.4, 2026-06-21). When true, every transition of /// the <c>TeleportArrivalReadiness</c> verdict (NotReady → Ready / Impossible) emits one /// <c>[arrival-gate]</c> line: the destination cell id, landblock id, verdict, /// <c>PhysicsEngine.LandblockCount</c> at the moment it flips, and whether the /// flip was forced (Impossible) or natural (Ready). /// /// <para>Initial state from <c>ACDREAM_PROBE_RESOLVE=1</c> — shares the existing probe /// flag because this is a readiness-path extension; no new env var needed.</para> /// </summary> public static bool ProbeArrivalGateEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1";In
PhysicsDiagnostics.ResetForTest()(line 536 area), add afterProbeResolveEnabled = false;:ProbeArrivalGateEnabled = false; -
Step 2: Add the transition-tracking field and probe emission to GameWindow
In
src/AcDream.App/Rendering/GameWindow.cs, near theTeleportArrivalControllerfield (grep for_teleportArrivalto find the declaration block), add:private AcDream.App.World.ArrivalReadiness _lastArrivalVerdict = AcDream.App.World.ArrivalReadiness.NotReady;Then in
TeleportArrivalReadiness(after computing the verdict and before returning), add the probe emission:private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness( System.Numerics.Vector3 destPos, uint destCell) { bool claimUnhydratable = IsSpawnClaimUnhydratable(destCell); bool indoor = (destCell & 0xFFFFu) >= 0x0100u; bool indoorCellReady = indoor && !claimUnhydratable && _physicsEngine.IsSpawnCellReady(destCell); bool outdoorReady = !indoor && _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u); var verdict = AcDream.App.World.TeleportArrivalRules.Decide( claimUnhydratable, indoor, indoorCellReady, outdoorReady); if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeArrivalGateEnabled && verdict != _lastArrivalVerdict && verdict != AcDream.App.World.ArrivalReadiness.NotReady) { Console.WriteLine(System.FormattableString.Invariant( $"[arrival-gate] destCell=0x{destCell:X8} " + $"landblock=0x{(destCell & 0xFFFF0000u):X8} " + $"verdict={verdict} indoor={indoor} " + $"landblockCount={_physicsEngine.LandblockCount}")); } _lastArrivalVerdict = verdict; return verdict; }Reset
_lastArrivalVerdicttoNotReadyinBeginArrival(i.e. wherever_teleportArrival.BeginArrivalis called) so each new hold starts fresh. Find theBeginArrivalcall site by searching for_teleportArrival.BeginArrivalinGameWindow.cs; immediately before or after that call add:_lastArrivalVerdict = AcDream.App.World.ArrivalReadiness.NotReady; -
Step 3: Build green
Run:
dotnet build src/AcDream.App/AcDream.App.csproj -c DebugExpected: zero errors, zero warnings on the touched files.
-
Step 4: Commit
git add src/AcDream.Core/Physics/PhysicsDiagnostics.cs src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(slice2): arrival-gate probe — log NotReady→Ready flip with landblock count"
Task 2.5: Apparatus re-test — verify the gate on the 0xC98C edge-arrival scenario
This task has no automated xunit test — it is a described manual run against a live session using the probe.
What the test proves: The readiness gate correctly holds an outdoor arrival until AddLandblock fires for the destination, then flips Ready in the same streaming frame — no cell-march, no Z free-fall.
-
Step 1: Launch with the probe enabled
In PowerShell:
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" $env:ACDREAM_LIVE = "1" $env:ACDREAM_TEST_HOST = "127.0.0.1" $env:ACDREAM_TEST_PORT = "9000" $env:ACDREAM_TEST_USER = "testaccount" $env:ACDREAM_TEST_PASS = "testpassword" $env:ACDREAM_PROBE_RESOLVE = "1" # gates both ProbeResolveEnabled and ProbeArrivalGateEnabled dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-gate-retest.log" -
Step 2: Reproduce the
0xC98CscenarioUse the
/teleportGM command to portal to a landblock near a streaming edge — ideally the same destination (0xC98C…) documented inlaunch5.log. Alternatively, use/tp 0xC98C0200or portal via a gate that forces a cross-landblock hop. -
Step 3: Inspect the log
Look for
[arrival-gate]lines inlaunch-gate-retest.log. The expected pattern:[arrival-gate] destCell=0xC98C0200 landblock=0xC98C0000 verdict=Ready indoor=False landblockCount=N live: teleport complete — snapped to (...) cell=0xC98C...The
[arrival-gate]line MUST appear before thelive: teleport completeline.landblockCountshould be ≥ 1 at the moment the gate flips.The probe MUST NOT be followed by any sequence of
[resolve]lines that showbranch=NO-LANDBLOCKafter the placement — that is the Z-free-fall symptom. If NO-LANDBLOCK lines appear afterlive: teleport complete, the fix has a gap and must be investigated before closing. -
Step 4: Confirm no regression on indoor arrival
Enter a dungeon (walk into a portal in Holtburg). The log should show
[arrival-gate] indoor=Truelines whileNotReady, then a flip toReadyonce the EnvCell floor hydrates — same behavior as before. The change must not regress the indoor path. -
Step 5: Commit result note
If the run passes, record a one-line note in the commit that closes this apparatus task:
git commit --allow-empty -m "test(slice2): apparatus re-test passed — arrival gate flips Ready before placement on 0xC98C edge arrival; no NO-LANDBLOCK march observed"If the run reveals a gap, file it as a new issue before continuing.
Task 2.6: Full test suite green + run-all confirmation
-
Step 1: Run the full suite
dotnet test AcDream.slnx -c DebugExpected: all tests pass. No new test failures introduced by the
Decidesignature change. (The only existing caller wasTeleportArrivalRulesTests.cs, which was updated in Task 2.2.TeleportArrivalControllerTests.csexercises the controller via the delegate — notDecidedirectly — so it is unaffected.) -
Step 2: Confirm
TeleportArrivalControllerTestsstill passesRun:
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"Expected: PASS (the controller's injected
_readinessdelegate passesArrivalReadinessvalues directly; it does not callDecideinternally, so the signature change is invisible to it). -
Step 3: Commit (if not already committed per-task above)
No additional commit if each task was committed individually. Otherwise:
git add -u && git commit -m "feat(slice2): readiness gate complete — IsLandblockLoaded + Decide(outdoorReady) + probe (#145 §3.4)"
Slice 3: TeleportFlowController + TeleportFadeOverlay (portal through the sequencer + fade)
Task 3.1 — TeleportFlowController (delegate-injected, unit-tested)
Files:
-
Create
src/AcDream.App/World/TeleportFlowController.cs(namespaceAcDream.App.World). -
Test:
tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs(match style oftests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs:1-147—record-based call spies, staticMakehelper,[Fact]). -
Consumes (do NOT redefine):
AcDream.Core.World.TeleportAnimSequencer/TeleportAnimState/TeleportEntryKind/TeleportAnimEvent/TeleportAnimSnapshot(slice 1);AcDream.App.World.ArrivalReadiness(src/AcDream.App/World/TeleportArrivalController.cs:7-19). -
Step 1: Write the failing test. Real xunit, mirroring
TeleportArrivalControllerTestsspy style:
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.World;
using AcDream.Core.World;
using Xunit;
namespace AcDream.App.Tests.World;
public class TeleportFlowControllerTests
{
private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced);
private sealed class Spies
{
public readonly List<PlaceCall> Placed = new();
public int LoginComplete;
public int LogoutDisconnect;
public int EnterSound;
public int ExitSound;
}
// readinessQueue: one verdict per Tick (last value repeats once exhausted).
private static TeleportFlowController Make(
Spies s, Queue<ArrivalReadiness> readinessQueue,
int maxHoldFrames = TeleportFlowController.DefaultMaxHoldFrames)
{
ArrivalReadiness last = ArrivalReadiness.NotReady;
return new TeleportFlowController(
readiness: (_, _) => { if (readinessQueue.Count > 0) last = readinessQueue.Dequeue(); return last; },
onPlace: (pos, cell, forced) => s.Placed.Add(new PlaceCall(pos, cell, forced)),
onLoginComplete: () => s.LoginComplete++,
onLogoutDisconnect: () => s.LogoutDisconnect++,
onPlayEnterSound: () => s.EnterSound++,
onPlayExitSound: () => s.ExitSound++,
maxHoldFrames: maxHoldFrames);
}
// Drive enough fixed-dt ticks (1/60s) to walk a portal run all the way to OFF.
private static void RunToOff(TeleportFlowController c, int maxTicks = 2000)
{
for (int i = 0; i < maxTicks && c.IsActive; i++) c.Tick(1f / 60f);
}
[Fact]
public void Tick_BeforeBegin_IsNoOp()
{
var s = new Spies();
var c = Make(s, new Queue<ArrivalReadiness>());
c.Tick(1f / 60f);
Assert.False(c.IsActive);
Assert.Empty(s.Placed);
}
[Fact]
public void Portal_FullRun_FiresEnterPlaceExitLoginComplete_InOrder()
{
var s = new Spies();
// readiness Ready from the first probe so the tunnel exits as soon as it can hold.
var q = new Queue<ArrivalReadiness>();
q.Enqueue(ArrivalReadiness.Ready);
var c = Make(s, q);
c.Begin(TeleportEntryKind.Portal, new Vector3(10, 20, 6), 0x01250126u);
RunToOff(c);
Assert.False(c.IsActive);
Assert.Equal(1, s.EnterSound);
var place = Assert.Single(s.Placed);
Assert.False(place.Forced);
Assert.Equal(0x01250126u, place.Cell);
Assert.Equal(new Vector3(10, 20, 6), place.Pos);
Assert.Equal(1, s.ExitSound);
Assert.Equal(1, s.LoginComplete);
Assert.Equal(0, s.LogoutDisconnect);
}
[Fact]
public void Begin_Idempotent_SecondBeginWithCoords_DoesNotRestartSequencer()
{
var s = new Spies();
var q = new Queue<ArrivalReadiness>();
q.Enqueue(ArrivalReadiness.NotReady); // hold in TUNNEL after first probe
var c = Make(s, q);
c.Begin(TeleportEntryKind.Portal); // no coords (OnTeleportStarted)
var stateAfterFirst = c.Snapshot.State; // sequencer began (Tunnel)
c.Tick(1f / 60f); // holding; no coords -> worldReady false for Portal
c.Begin(TeleportEntryKind.Portal, new Vector3(1, 2, 3), 0x01250126u); // coords arrive
// Still active, still no premature place: the second Begin must NOT re-call _seq.Begin.
Assert.True(c.IsActive);
Assert.Empty(s.Placed);
Assert.Equal(TeleportAnimState.Tunnel, c.Snapshot.State);
}
[Fact]
public void Logout_FullRun_FiresLogoutDisconnect_NotLoginComplete()
{
var s = new Spies();
var c = Make(s, new Queue<ArrivalReadiness>()); // no dest -> Logout worldReady=true intrinsically
c.Begin(TeleportEntryKind.Logout);
RunToOff(c);
Assert.False(c.IsActive);
Assert.Equal(1, s.LogoutDisconnect);
Assert.Equal(0, s.LoginComplete);
Assert.Empty(s.Placed); // _haveDest false -> Place event is a no-op
Assert.Equal(1, s.ExitSound);
}
[Fact]
public void Impossible_ForcesPlace_WithForcedTrue()
{
var s = new Spies();
var q = new Queue<ArrivalReadiness>();
q.Enqueue(ArrivalReadiness.Impossible);
var c = Make(s, q);
c.Begin(TeleportEntryKind.Portal, new Vector3(4, 5, 6), 0x0125FF00u);
RunToOff(c);
var place = Assert.Single(s.Placed);
Assert.True(place.Forced);
}
}
-
Step 2: Run to verify it fails —
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportFlowControllerTests". Expected: FAIL —TeleportFlowControllerdoes not exist (compile error). -
Step 3: Implement the pinned class verbatim:
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.World;
/// <summary>
/// Phase B (retail teleport flow): the visual/timing/lock state machine that wraps
/// the pure <see cref="TeleportAnimSequencer"/> (the 7-state TAS machine) around the
/// existing arrival-hold readiness probe. Single owner of "a teleport is happening".
///
/// <para>All side effects are injected delegates (same pure-seam style as
/// <see cref="TeleportArrivalController"/>): readiness probe, placement, login-complete,
/// logout teardown, and the two portal sounds. Carries no GL / dat / net dependency
/// directly, so it is fully unit-testable.</para>
///
/// <para>Drives: the sequencer per frame with a <c>worldReady</c> gate computed from the
/// readiness probe; on the sequencer's <see cref="TeleportAnimEvent.Place"/> it runs the
/// placement (Resolve + SetPosition + camera); at <see cref="TeleportAnimEvent.FireLoginComplete"/>
/// (sequencer reached <see cref="TeleportAnimState.Off"/>) it either disconnects (Logout)
/// or sends LoginComplete + flips PortalSpace->InWorld (all other kinds). The input lock
/// therefore persists the WHOLE animation, not just until placement.</para>
/// </summary>
public sealed class TeleportFlowController
{
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
public const int DefaultMaxHoldFrames = 600;
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
private readonly Action<Vector3, uint, bool> _onPlace; // (destPos, destCell, forced)
private readonly Action _onLoginComplete;
private readonly Action _onLogoutDisconnect;
private readonly Action _onPlayEnterSound;
private readonly Action _onPlayExitSound;
private readonly int _maxHoldFrames;
private readonly TeleportAnimSequencer _seq = new();
private TeleportEntryKind _kind;
private Vector3 _destPos;
private uint _destCell;
private bool _haveDest;
private int _heldFrames;
private bool _forced;
public TeleportFlowController(
Func<Vector3, uint, ArrivalReadiness> readiness,
Action<Vector3, uint, bool> onPlace,
Action onLoginComplete,
Action onLogoutDisconnect,
Action onPlayEnterSound,
Action onPlayExitSound,
int maxHoldFrames = DefaultMaxHoldFrames)
{
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
_onPlace = onPlace ?? throw new ArgumentNullException(nameof(onPlace));
_onLoginComplete = onLoginComplete ?? throw new ArgumentNullException(nameof(onLoginComplete));
_onLogoutDisconnect = onLogoutDisconnect ?? throw new ArgumentNullException(nameof(onLogoutDisconnect));
_onPlayEnterSound = onPlayEnterSound ?? throw new ArgumentNullException(nameof(onPlayEnterSound));
_onPlayExitSound = onPlayExitSound ?? throw new ArgumentNullException(nameof(onPlayExitSound));
_maxHoldFrames = maxHoldFrames;
}
/// <summary>Last snapshot from the sequencer — drives the fade overlay alpha + tunnel/please-wait flags.</summary>
public TeleportAnimSnapshot Snapshot { get; private set; }
public bool IsActive => _seq.IsActive;
/// <summary>Start (or supply coords to) a teleport flow. Idempotent on the sequencer:
/// the first call (OnTeleportStarted, no coords) begins it; a later call with coords
/// (OnLivePositionUpdated) fills the destination WITHOUT restarting the animation.</summary>
public void Begin(TeleportEntryKind kind, Vector3 destPos = default, uint destCell = 0)
{
_kind = kind;
if (destCell != 0)
{
_destPos = destPos;
_destCell = destCell;
_haveDest = true;
_heldFrames = 0;
}
if (!_seq.IsActive)
_seq.Begin(kind);
}
public void Tick(float dt)
{
if (!_seq.IsActive) return;
bool worldReady;
if (_haveDest)
{
ArrivalReadiness v = _readiness(_destPos, _destCell);
_heldFrames++;
bool forced = v == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames;
worldReady = v == ArrivalReadiness.Ready || forced;
_forced = forced && v != ArrivalReadiness.Ready;
}
else
{
// Logout has no destination world to load; Portal/Death/Login wait for coords.
worldReady = _kind == TeleportEntryKind.Logout;
}
var (snap, events) = _seq.Tick(dt, worldReady);
Snapshot = snap;
foreach (var e in events)
{
switch (e)
{
case TeleportAnimEvent.PlayEnterSound:
_onPlayEnterSound();
break;
case TeleportAnimEvent.PlayExitSound:
_onPlayExitSound();
break;
case TeleportAnimEvent.Place:
if (_haveDest) _onPlace(_destPos, _destCell, _forced);
break;
case TeleportAnimEvent.FireLoginComplete:
if (_kind == TeleportEntryKind.Logout) _onLogoutDisconnect();
else _onLoginComplete();
break;
case TeleportAnimEvent.EnterTunnel:
// no-op — the sequencer drives the tunnel visual state itself.
break;
}
}
}
}
- Step 4: Run to verify it passes — same
--filter. Expected: PASS. - Step 5: Commit —
git add src/AcDream.App/World/TeleportFlowController.cs tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs && git commit -m "feat(teleport): Slice 3 — TeleportFlowController wrapping the TAS sequencer with delegate side-effects"
Task 3.2 — TeleportFadeOverlay (full-screen alpha-black UiElement)
Files:
- Create
src/AcDream.App/UI/TeleportFadeOverlay.cs(namespaceAcDream.App.UI). - Test:
tests/AcDream.App.Tests/UI/TeleportFadeOverlayTests.cs(match a UiElement test, e.g.tests/AcDream.App.Tests/UI/UiMeterTests.csstyle — these tests assert geometry/state without a live GL context). - Depends on:
UiElement(src/AcDream.App/UI/UiElement.cs:32, virtualOnDraw(UiRenderContext)at :168,Visibleat :70,Width/Heightat :50-51);UiRenderContext.DrawRect(float,float,float,float,Vector4)(src/AcDream.App/UI/UiRenderContext.cs:78);UiRenderContext.ScreenSize(:19);UiElement.ClickThrough(:78);UiElement.ZOrder(:94).
The overlay holds a settable Alpha (0..1). When Alpha <= 0 it sets Visible = false (so it never participates in hit-test or draw). Because the overlay has no public render-surface seam that's GL-free, the testable logic is "Alpha→Visible gating + the draw color it would emit". Extract that into a pure method FillColor() so the test asserts it without a UiRenderContext.
- Step 1: Write the failing test:
using System.Numerics;
using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
public class TeleportFadeOverlayTests
{
[Fact]
public void Alpha_Zero_IsInvisible()
{
var o = new TeleportFadeOverlay { Alpha = 0f };
Assert.False(o.Visible);
}
[Fact]
public void Alpha_Positive_IsVisible_AndBlackWithThatAlpha()
{
var o = new TeleportFadeOverlay { Alpha = 0.5f };
Assert.True(o.Visible);
Assert.Equal(new Vector4(0f, 0f, 0f, 0.5f), o.FillColor());
}
[Fact]
public void Alpha_Clamps_To_Unit_Range()
{
var o = new TeleportFadeOverlay { Alpha = 5f };
Assert.Equal(1f, o.FillColor().W);
o.Alpha = -3f;
Assert.False(o.Visible);
Assert.Equal(0f, o.FillColor().W);
}
}
-
Step 2: Run to verify it fails —
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportFadeOverlayTests". Expected: FAIL —TeleportFadeOverlaydoes not exist. -
Step 3: Implement:
using System;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Phase B (retail teleport flow): a top-most <see cref="UiElement"/> that fills the
/// whole screen with <c>(0,0,0,Alpha)</c>. The <see cref="TeleportFlowController"/> drives
/// <see cref="Alpha"/> from <c>Snapshot.FadeAlpha</c> each frame (0 = clear world, 1 = full
/// black). Added once to the <see cref="UiRoot"/> (highest ZOrder so it sits over panels).
///
/// <para>Divergence-register row: black fade via an alpha overlay quad, NOT retail's
/// camera near-plane collapse (Render::set_vdst to 0.001). Visually identical, no
/// projection surgery — see the design doc §3.3.</para>
/// </summary>
public sealed class TeleportFadeOverlay : UiElement
{
private float _alpha;
/// <summary>0..1 black-fade alpha. Setting it <= 0 hides the overlay entirely so it
/// never draws and never participates in hit-testing.</summary>
public float Alpha
{
get => _alpha;
set
{
_alpha = Math.Clamp(value, 0f, 1f);
Visible = _alpha > 0f;
}
}
public TeleportFadeOverlay()
{
// Decoration: never claim the pointer (the world / panels stay interactive when clear,
// and during a full-black hold the input lock is enforced separately by PortalSpace).
ClickThrough = true;
Visible = false;
ZOrder = int.MaxValue; // over every retail-UI panel
}
/// <summary>The fill color this overlay emits — pure black at the current alpha.
/// Pure (no GL) so it can be asserted in a unit test.</summary>
public Vector4 FillColor() => new(0f, 0f, 0f, _alpha);
protected override void OnDraw(UiRenderContext ctx)
{
if (_alpha <= 0f) return;
// Cover the entire screen regardless of this element's Left/Top (Root sits at origin,
// so local == screen). DrawRect composites after sprites — the fade must cover them.
var size = ctx.ScreenSize;
ctx.DrawRect(0f, 0f, size.X, size.Y, FillColor());
}
}
- Step 4: Run to verify it passes — same
--filter. Expected: PASS. - Step: Add divergence-register row TF-FADE — the alpha-overlay deviation is introduced here, so its row lands in this commit (CLAUDE.md register rule). In
docs/architecture/retail-divergence-register.md, matching the existing column order, add (design §5 row 1):
| TF-FADE | Adaptation | TeleportFadeOverlay fills the screen with a black alpha quad instead of retail's camera near-plane collapse (Render::set_vdst -> 0.001, TAS §2.2). Visually identical. | src/AcDream.App/UI/TeleportFadeOverlay.cs | Edge-pixel depth-precision differences only; tolerable. |
- Step 5: Commit —
git add src/AcDream.App/UI/TeleportFadeOverlay.cs tests/AcDream.App.Tests/UI/TeleportFadeOverlayTests.cs docs/architecture/retail-divergence-register.md && git commit -m "feat(teleport): Slice 3 — TeleportFadeOverlay full-screen alpha-black fade quad + TF-FADE register row"
Task 3.3 — GameWindow: wire the flow controller + overlay for the PORTAL path (integration, build-green + manual)
Files: src/AcDream.App/Rendering/GameWindow.cs (god object — no unit test; per the structure rules new LOGIC lives in 3.1/3.2, this task is wiring only).
Anchors (all line numbers from the current tree):
-
OnTeleportStartedat:5579. -
OnLivePositionUpdatedportal-space branch /BeginArrivalcall at:5472-5474. -
PlaceTeleportArrivalbody at:5534-5570(the SPLIT target). -
EnsureTeleportArrivalControllerat:5501. -
Per-frame
_teleportArrival?.Tick();at:7555. -
_uiHost.Draw(...)at:8994. -
_audioEngine/_soundCache/_datsfields at:250-251/:28. -
GameActionLoginComplete.Build()(src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs). -
Step 1 (edit): Add fields + a lazy ensure near
_teleportArrival(:5482):
// Phase B (retail teleport flow): the TAS state machine + its full-screen fade overlay.
// Replaces the bare _teleportArrival hold for the player. Lazily constructed on the
// first teleport (all runtime deps wired by then), same as _teleportArrival.
private AcDream.App.World.TeleportFlowController? _teleportFlow;
private AcDream.App.UI.TeleportFadeOverlay? _teleportFade;
private void EnsureTeleportFlowController()
{
if (_teleportFlow is not null) return;
_teleportFlow = new AcDream.App.World.TeleportFlowController(
readiness: TeleportArrivalReadiness,
onPlace: PlaceTeleportArrivalCore,
onLoginComplete: OnTeleportLoginComplete,
onLogoutDisconnect: () => { _liveSessionController?.Dispose(); _liveSessionController = null; },
onPlayEnterSound: () => PlayPortalSound(PortalSoundKind.Enter), // Slice 4 supplies PlayPortalSound
onPlayExitSound: () => PlayPortalSound(PortalSoundKind.Exit));
_teleportFade = new AcDream.App.UI.TeleportFadeOverlay();
_uiHost?.Root.AddChild(_teleportFade);
}
(Slice 4 adds PlayPortalSound + PortalSoundKind. For Slice 3 to build green on its own, temporarily pass onPlayEnterSound: () => { } / onPlayExitSound: () => { } and replace them in Task 4.2 — note this in the commit message.)
- Step 2 (edit): SPLIT
PlaceTeleportArrival(:5534-5570). Rename the existing body toPlaceTeleportArrivalCoreand REMOVE its last two statements (_playerController.State = InWorld;at:5563and the_liveSession?.SendGameAction(...LoginComplete...)at:5568-5569). Add the new login-complete delegate that owns exactly those two lines:
// The placement half (Resolve + SetPosition + camera). Runs on the sequencer's Place
// event. Does NOT flip PortalSpace->InWorld and does NOT send LoginComplete — those move
// to OnTeleportLoginComplete (fired at sequencer OFF) so the input lock persists the
// WHOLE animation, not just until placement.
private void PlaceTeleportArrivalCore(
System.Numerics.Vector3 destPos, uint destCell, bool forced)
{
// ... existing body verbatim through the camera Update calls (:5537-5561) ...
if (forced)
Console.WriteLine($"live: teleport HOLD gave up (impossible/timeout) — force-snapping ...");
// (entity SetPosition + ParentCellId, _playerController.SetPosition, chase-camera Update)
Console.WriteLine($"live: teleport placed — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
// NOTE: no State=InWorld, no LoginComplete here anymore.
}
// Fired at sequencer OFF (TeleportAnimEvent.FireLoginComplete) for every non-logout kind.
// Flips the input lock back on AND tells the server the client finished loading.
private void OnTeleportLoginComplete()
{
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
_liveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
}
- Step 3 (edit): Portal trigger. In
OnTeleportStarted(:5583), afterEnsureTeleportArrivalController();add:
EnsureTeleportFlowController();
_teleportFlow!.Begin(AcDream.Core.World.TeleportEntryKind.Portal); // no coords yet
In OnLivePositionUpdated portal-space branch, REPLACE the _teleportArrival!.BeginArrival(newWorldPos, p.LandblockId); at :5474 with (keeping the recenter / pre-collapse above it untouched):
EnsureTeleportFlowController();
_pendingTeleportRot = rot;
_teleportFlow!.Begin(AcDream.Core.World.TeleportEntryKind.Portal, newWorldPos, p.LandblockId);
- Step 4 (edit): per-frame tick + overlay drive. REPLACE
_teleportArrival?.Tick();at:7555with:
// Phase B: advance the teleport flow (TAS animation + arrival hold). Runs AFTER streaming
// + the live-session drain, same ordering invariant the old _teleportArrival.Tick had.
_teleportFlow?.Tick(dt);
if (_teleportFade is not null && _teleportFlow is not null)
_teleportFade.Alpha = _teleportFlow.Snapshot.FadeAlpha;
(dt is in scope at this call site — confirm by reading the enclosing method; if the local is named differently, use that name. Add a "read GameWindow.cs:7551 enclosing-method dt local to confirm name" check.) The fade overlay draws inside the existing _uiHost.Draw(...) pass at :8994 because it was added as a UiRoot child in EnsureTeleportFlowController — no new draw site.
- Step: dotnet build green —
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug. - Step: MANUAL/VISUAL verification. Launch against ACE (CLAUDE.md launch block), walk the player into a portal. Expect: screen fades to black, holds while the destination streams, fades back in; the camera no longer "floats" to the destination through an unstreamed world; input stays frozen until the fade-in completes (try pressing W during the black hold — no movement until the world is back). The tunnel states currently render as a black hold (the swirl is Slice 6).
- Step 5: Commit —
git add -A && git commit -m "feat(teleport): Slice 3 — route portal teleports through TeleportFlowController + fade overlay; split placement from login-complete"
Note:
_teleportArrivalbecomes dead once the portal path routes through_teleportFlow. Leave it in place for Slice 3 (login/death/logout still need the unified path in Slice 5); Slice 5's de-dup commit removes the now-unused_teleportArrivalfield +EnsureTeleportArrivalController+BeginArrivalplumbing.
Slice 4: Yaw-freeze in PortalSpace + portal sounds
Task 4.1 — Yaw freeze during PortalSpace (build-green + manual; NO fabricated-signature unit test)
Files: src/AcDream.App/Rendering/GameWindow.cs — the chase-camera mouse-move block.
Anchor: the single if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null) block at :1095, which contains BOTH the MMB mouse-look sub-branch (:1098) and the RMB orbit sub-branch (:1120). One early-return at the TOP of that block covers both (per the prompt — do NOT add it in slice 3 or 5).
- Step 1 (edit): Insert as the first statement inside the
:1095block, beforefloat sens = _sensChase;(:1097):
// Phase B (retail teleport flow): freeze look/orbit yaw while the teleport animation
// holds the player in PortalSpace. The keyboard-turn freeze already happens (the
// PlayerMovementController.Update early-return precedes the yaw block), but the Silk
// mouse-move handler bypasses Update — so MMB mouse-look (below) and RMB orbit both
// leak yaw without this gate. Covers BOTH sub-branches since they share this block.
if (_playerController is not null
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace)
{
_lastMouseX = pos.X;
_lastMouseY = pos.Y;
return;
}
- Step: dotnet build green —
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug. - Step: MANUAL/VISUAL verification. During a portal (while the fade holds), hold MMB and move the mouse, then hold RMB and move the mouse. Expect: neither rotates the character/camera while in PortalSpace; both resume normally after the fade-in completes (State back to InWorld). (Mouse input can't be unit-tested through Silk — this is build-green + manual per the prompt; do NOT fabricate a
PlayerMovementController.Updatesignature test.) - Step 5: Commit —
git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "fix(teleport): Slice 4 — freeze MMB look + RMB orbit yaw while held in PortalSpace"
Task 4.2 — Portal Enter/Exit sounds (clean EnumIDMap chain, no stopgap)
Files: src/AcDream.App/Rendering/GameWindow.cs (PlayPortalSound + PortalSoundKind).
VERIFIED CORRECTION — enum member names. The design doc / handoff says
DRWSound.UI_EnterPortal=0x6A/UI_ExitPortal=0x6B. That is wrong for our dat library. The real members areDRWSound.EnterPortalandDRWSound.ExitPortal(noUI_prefix). Therefore:
- Do NOT add
UI_EnterPortal=0x6A/UI_ExitPortal=0x6Bto the CoreSoundIdenum (src/AcDream.Core/Audio/AudioModel.cs:16-42). The portal-sound chain usesDatReaderWriter.Enums.Sound(aliasedDRWSound), NOT the CoreSoundId— exactly asSoundCookbook.Roll(SoundTable, DRWSound, Random)is typed (src/AcDream.Core/Audio/SoundCookbook.cs:74-82, alias at :4). The CoreSoundIdenum needs no change.- Add a confirm step below to pin the exact member spelling before use.
Pinned chain elements (all read from source):
_dats.Portal.Header.MasterMapId+_dats.Portal.TryGet<EnumIDMap>(...)— the EnumIDMap pattern (src/AcDream.App/UI/IconComposer.cs:86-95for the master→sub two-level lookup)._soundCache.GetSoundTable(uint)(src/AcDream.Core/Audio/DatSoundCache.cs:59-66) →SoundTable?.SoundCookbook.Roll(SoundTable, DRWSound, Random)→SoundEntry?(:74); winningentry.Idis the Wave id (used exactly so atsrc/AcDream.App/Audio/AudioHookSink.cs:113)._soundCache.GetWave(uint)→WaveData?(DatSoundCache.cs:39)._audioEngine.PlayUiWave(uint waveId, WaveData wave, float volume=1, float pitch=1)(src/AcDream.App/Audio/OpenAlAudioEngine.cs:278).- Fields
_dats(:28),_audioEngine(:250),_soundCache(:251).
The UI SoundTable id resolves via MasterMapId → ClientEnumToID[0x10000003] → ClientEnumToID[7] per the design's §8 chain. This chain is medium-confidence (not yet runtime-confirmed), so the implementation must fail gracefully (no sound + one warning log) if any link misses — that is the design's required fallback, not a workaround.
-
Step 0 (confirm the enum spelling — required before writing code): read the member names by greping the dat lib so the case
DRWSound.EnterPortal/DRWSound.ExitPortalcompiles. The build error pins exact casing in YOUR build; if casing differs, use the build error to correct it — do not guess a third spelling. -
Step 1 (no separate unit test): This is GameWindow integration (god object) and the resolution depends on live dats — it's build-green + manual, like 4.1. (The reusable picker logic —
SoundCookbook.Roll— is already unit-covered.) Skip Steps 1-2 of the TDD template; go straight to implement + build + manual-verify. -
Step 3: Implement in GameWindow:
private enum PortalSoundKind { Enter, Exit }
// Phase B (retail teleport flow): play the EnterPortal / ExitPortal UI sound.
// Resolves the UI SoundTable via the master EnumIDMap chain (design §8):
// Portal.Header.MasterMapId -> EnumIDMap.ClientEnumToID[0x10000003] -> [7] -> SoundTable id
// then rolls the EnterPortal/ExitPortal entry and plays its Wave on the UI pool.
// Medium-confidence chain: if any link misses, log once + play nothing (the design's
// required graceful fallback, not a workaround — the visual flow proceeds regardless).
private uint _uiSoundTableId; // 0 = unresolved/unknown; resolved lazily on first portal sound
private bool _uiSoundTableTried;
private void PlayPortalSound(PortalSoundKind kind)
{
if (_audioEngine is null || !_audioEngine.IsAvailable || _soundCache is null || _dats is null)
return;
uint tableId = ResolveUiSoundTableId();
if (tableId == 0) return;
var table = _soundCache.GetSoundTable(tableId);
if (table is null) return;
var sound = kind == PortalSoundKind.Enter
? DatReaderWriter.Enums.Sound.EnterPortal
: DatReaderWriter.Enums.Sound.ExitPortal;
var entry = AcDream.Core.Audio.SoundCookbook.Roll(table, sound, Random.Shared);
if (entry is null) return;
var wave = _soundCache.GetWave((uint)entry.Id);
if (wave is null) return;
_audioEngine.PlayUiWave((uint)entry.Id, wave,
volume: System.Math.Clamp(entry.Volume > 0 ? entry.Volume : 1f, 0f, 1f));
}
private uint ResolveUiSoundTableId()
{
if (_uiSoundTableTried) return _uiSoundTableId;
_uiSoundTableTried = true;
if (_dats is null) return 0;
uint masterDid = (uint)_dats.Portal.Header.MasterMapId;
if (masterDid == 0
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)
|| !master.ClientEnumToID.TryGetValue(0x10000003u, out var subDid)
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub)
|| !sub.ClientEnumToID.TryGetValue(7u, out var tableId))
{
Console.WriteLine("[teleport] UI SoundTable EnumIDMap chain did not resolve — portal sounds disabled (no-op).");
return 0;
}
_uiSoundTableId = tableId;
return tableId;
}
Then in EnsureTeleportFlowController (Task 3.3), replace the temporary empty sound delegates with () => PlayPortalSound(PortalSoundKind.Enter) / () => PlayPortalSound(PortalSoundKind.Exit).
- Step: dotnet build green —
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug. (IfDatReaderWriter.Enums.Sound.EnterPortal/.ExitPortalfails to resolve, the build error pins the real casing — fix it from the error, do not guess.) - Step: MANUAL/VISUAL (audio) verification. Portal in: expect the retail EnterPortal whoosh at the start of the fade; expect the ExitPortal sound at the tunnel→world fade-in edge. If the log prints "UI SoundTable EnumIDMap chain did not resolve", the §8 index guess (
0x10000003→[7]) is wrong — capture the real chain via a cdb trace / dat dump and correct the two constants (do NOT add a hardcoded table id workaround). - Step 5: Commit —
git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(teleport): Slice 4 — EnterPortal/ExitPortal UI sounds via master EnumIDMap chain, graceful no-sound fallback"
Divergence-register: no new row for sounds (faithful port). If the §8 chain stays unresolved and you ship with sounds disabled, add a stopgap row noting "portal sounds pending EnumIDMap-chain runtime confirmation" and delete it when resolved.
Slice 5: Unify login + death + logout (de-dup readiness; logout command)
Task 5.1 — De-dup the login readiness predicate onto TeleportArrivalRules.Decide
Files:
src/AcDream.App/Rendering/GameWindow.cs— theisSpawnGroundReadylogin predicate (:1036-1065).- Test: extend
tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs(the slice-2Decide(claimUnhydratable, indoor, indoorCellReady, outdoorReady)4-arg form).
The login predicate (:1036-1064) and TeleportArrivalReadiness (:5513-5530) were verbatim duplicates of the readiness logic. Slice 2 already rewrote TeleportArrivalReadiness to call TeleportArrivalRules.Decide(..., outdoorReady) with outdoorReady from IsLandblockLoaded. This task collapses the LOGIN predicate to call the SAME Decide, computing its outdoorReady arg from SampleTerrainZ(...) != null (login's existing outdoor gate). Do NOT create a separate ComputeArrivalReadiness method (the prompt forbids it) — both sites call Decide directly.
- Step 1: Write the failing test — add the outdoor-axis case proving
DecidehonorsoutdoorReady(this guards the shared method the login predicate will now depend on):
[Fact]
public void Outdoor_NotLoaded_HoldsNotReady()
{
// Slice 2 axis: outdoor now HOLDS until the landblock is loaded (folds in #145 residual).
Assert.Equal(ArrivalReadiness.NotReady,
TeleportArrivalRules.Decide(
claimUnhydratable: false, indoor: false, indoorCellReady: false, outdoorReady: false));
}
[Fact]
public void Outdoor_Loaded_IsReady()
{
Assert.Equal(ArrivalReadiness.Ready,
TeleportArrivalRules.Decide(
claimUnhydratable: false, indoor: false, indoorCellReady: false, outdoorReady: true));
}
(If slice 2 already added these exact cases, instead add a login-shaped case asserting the indoor branch ignores outdoorReady: Decide(false, true, true, false) == Ready. Read the current TeleportArrivalRulesTests.cs first and only add what's missing — do not duplicate.)
-
Step 2: Run to verify it fails —
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests". Expected: FAIL if the 4-argDecidecases aren't present yet (compile/assert), or PASS-already if slice 2 covered them (then this task is the GameWindow rewrite only — note it). -
Step 3: Implement — rewrite the login predicate body (
:1036-1064) to delegate toDecide:
isSpawnGroundReady: () =>
{
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false;
// #135/#145 (de-dup §3.5): the login spawn-readiness verdict is the SAME decision as a
// teleport arrival — route both through TeleportArrivalRules.Decide. Indoor gates on the
// EnvCell floor; outdoor gates on the terrain under the spawn having streamed in
// (SampleTerrainZ != null — login's existing outdoor gate, the async equivalent of
// retail's synchronous cell load).
uint claim = (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
&& sp.Position is { } spawnClaim && spawnClaim.LandblockId != 0)
? spawnClaim.LandblockId : 0u;
bool claimUnhydratable = claim != 0 && IsSpawnClaimUnhydratable(claim);
bool indoor = claim != 0 && (claim & 0xFFFFu) >= 0x0100u;
bool indoorCellReady = indoor && !claimUnhydratable && _physicsEngine.IsSpawnCellReady(claim);
bool outdoorReady = _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null;
return AcDream.App.World.TeleportArrivalRules.Decide(
claimUnhydratable, indoor, indoorCellReady, outdoorReady) == AcDream.App.World.ArrivalReadiness.Ready;
},
(Read :1036-1064 first to preserve the exact field names — _lastSpawnByGuid, sp.Position, IsSpawnClaimUnhydratable, IsSpawnCellReady, SampleTerrainZ are all confirmed in the current body. The original returned bool; Decide(...) == Ready preserves that — login holds on NotReady/Impossible, places on Ready. Preserve the old un-hydratable-indoor truth table: if the old code demoted an un-hydratable indoor claim to the outdoor terrain gate (placed on SampleTerrainZ != null) rather than holding, treat claimUnhydratable && indoor as "use outdoorReady" — read the old branch and reproduce its exact behavior; do not silently change login placement timing.)
- Step 4: Run to verify it passes — Rules
--filterPASS; thendotnet build src/AcDream.App/AcDream.App.csproj -c Debuggreen. - Step: MANUAL/VISUAL verification. Log in fresh (outdoor spawn at Holtburg) and into a dungeon: both must reach in-world FPS-steady with no free-fall (the #106/#135 invariant the predicate guards). Regression-gate: login stays FPS-steady (the design's explicit login regression gate).
- Step 5: Commit —
git add src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs && git commit -m "refactor(teleport): Slice 5 — de-dup login spawn-readiness onto TeleportArrivalRules.Decide (§3.5)"
Task 5.2 — Route LOGIN + DEATH through the flow controller
Files: src/AcDream.App/Rendering/GameWindow.cs — EnterPlayerModeFromAutoEntry success branch (:2610-2624); OnTeleportStarted (:5579, death confirmation comment).
Anchors:
-
EnterPlayerModeFromAutoEntryat:2610; its success branch logs "auto-entered player mode" at:2622. -
EnterPlayerModeNowreturns true after setting up_playerControllerwithPosition(PlayerMovementController.Position=>_body.Position,src/AcDream.App/Input/PlayerMovementController.cs:131) andCellId(:133). -
Death: server
0xF751teleport-to-lifestone already routes throughOnTeleportStarted→Begin(Portal)(Task 3.3). NO new death code. -
Step 1 (LOGIN edit): In
EnterPlayerModeFromAutoEntrysuccess branch (:2620-2623), after theConsole.WriteLine($"live: auto-entered player mode ..."):
// Phase B (retail teleport flow) §4: route LOGIN through the same TAS flow. The world is
// already loaded (auto-entry only fires once isSpawnGroundReady passed), so the readiness
// probe returns Ready immediately -> the tunnel exits fast -> continue + fade-in plays.
// onPlace re-resolves the player position harmlessly (idempotent). This gives login the
// retail fade-in instead of a hard pop-in.
EnsureTeleportFlowController();
_teleportFlow!.Begin(
AcDream.Core.World.TeleportEntryKind.Login,
_playerController!.Position,
_playerController.CellId);
- Step 2 (DEATH comment): Above
OnTeleportStarted(:5579), add a confirming comment (no code):
// Phase B §4: DEATH needs NO dedicated code. ACE teleports the corpse owner to the
// lifestone after the death anim (Player_Death.cs:247 / Player_Location.cs:686); the client
// observes it as an ordinary 0xF751 teleport, which already enters here -> Begin(Portal) ->
// TUNNEL. The large-position-jump branch of OnLivePositionUpdated is a second safety net.
- Step: dotnet build green —
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug. - Step: MANUAL/VISUAL verification. (1) Login: expect a short black hold → fade-in (NOT an instant pop) and FPS-steady. (2) Death (let the character die against a creature, or use a GM kill): expect death anim → fade → respawn at the lifestone with the same fade-in. Confirm LoginComplete is sent after login fade-in (the server accepts subsequent movement). If death shows NO fade (the teleport arrived without a preceding
0xF751, soOnTeleportStartednever fired): apply the open-question #1 contingency — add a large-position-jump →PortalSpace+Begin(Death)trigger inOnLivePositionUpdated. File it as a follow-up task; do not block the slice. - Step 5: Commit —
git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(teleport): Slice 5 — route login + death through TeleportFlowController (Login enters at TUNNEL, exits fast)"
Task 5.3 — LOGOUT command (animation + 0xF653 + clean disconnect)
Files:
src/AcDream.App/Rendering/GameWindow.cs—OnInputActionswitch (:11442method; add acasenear:11646).- Test: extend
tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs(theLogout_FullRun_FiresLogoutDisconnect_NotLoginCompletecase in Task 3.1 already covers the controller side — this task is the input wiring + the disconnect delegate, which is integration).
Confirmed:
-
InputAction.LOGOUTexists (src/AcDream.UI.Abstractions/Input/InputAction.cs:100) with a retail default keybind Shift+Escape (src/AcDream.UI.Abstractions/Input/KeyBindings.cs:212) — so NO new action and NO new keybind needed. -
OnInputActionfilters to Press/DoubleClick before the switch (:11501), so acasefires on Press. -
Logout teardown =
_liveSessionController?.Dispose()→LiveSessionController.Dispose()(src/AcDream.App/Net/LiveSessionController.cs:87) →WorldSession.Dispose()(src/AcDream.Core.Net/WorldSession.cs:1319) which sends0xF653CharacterLogOff (:1328) then DISCONNECT. The flow controller'sonLogoutDisconnectdelegate (wired in Task 3.3) already calls exactly this. -
Step 1 (no new unit test): the controller's Logout behavior is unit-covered by
Logout_FullRun_FiresLogoutDisconnect_NotLoginComplete(Task 3.1). The input-case wiring is GameWindow integration (build-green + manual). Confirm the existing 3.1 test passes:dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportFlowControllerTests.Logout". Expected: PASS (already implemented in Slice 3). -
Step 2 (edit): Add a
caseto theOnInputActionswitch, before the closing brace at:11647(after theEscapeKeycase at:11632-11646):
case AcDream.UI.Abstractions.Input.InputAction.LOGOUT:
// Phase B §4: retail logout = the TAS animation starting at WORLD_FADE_OUT, then 0xF653
// CharacterLogOff + clean disconnect (via WorldSession.Dispose, fired by the controller's
// onLogoutDisconnect delegate at sequencer OFF). Char-select-return UI is deferred (§9 /
// register row 4). Idempotent: ignore if a flow is already running or we're offline.
if (_liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld
&& (_teleportFlow is null || !_teleportFlow.IsActive))
{
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace; // lock input for the whole fade
EnsureTeleportFlowController();
_teleportFlow!.Begin(AcDream.Core.World.TeleportEntryKind.Logout);
}
break;
- Step: dotnet build green —
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug. - Step: MANUAL/VISUAL verification. In-world, press Shift+Esc. Expect: world fades to black (WORLD_FADE_OUT → TUNNEL_FADE_IN front segments — Logout's ~6s budget) → at sequencer OFF the client sends
0xF653and disconnects cleanly (the window closes / session ends; verify via WireMCP capture on loopback that a0xF653CharacterLogOff frame goes out before the DISCONNECT, and that ACE clears the session promptly — graceful close, not a hard kill). No char-select screen (deferred, §9). - Step 5: Commit —
git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(teleport): Slice 5 — LOGOUT (Shift+Esc) plays the TAS fade then sends 0xF653 + disconnects"
Task 5.4 — Remove the now-dead TeleportArrivalController plumbing + add divergence-register row 4
Files:
-
src/AcDream.App/Rendering/GameWindow.cs— remove the unused_teleportArrivalfield,EnsureTeleportArrivalController, and theBeginArrivalcall path (all superseded by_teleportFlowafter slices 3/5). -
docs/architecture/retail-divergence-register.md— add row 4 (logout → disconnect, not char-select). -
Step 1: Confirm
_teleportArrivalis unreferenced except its own decls —rg -n "_teleportArrival|EnsureTeleportArrivalController" src/AcDream.App/Rendering/GameWindow.cs. Expected: only the field declaration, the (now-shimmed) ensure, and no live.Tick()/.BeginArrival()callers (slices 3/5 replaced them with_teleportFlow). If a live caller remains, route it through_teleportFlowfirst. -
Step 2: Remove the dead plumbing — delete the
_teleportArrivalfield decl, theEnsureTeleportArrivalControllermethod, and anyEnsureTeleportArrivalController();calls left inOnTeleportStarted(theEnsureTeleportFlowController()call from Task 3.3 supersedes it). KeepTeleportArrivalController.cs(the class +TeleportArrivalRules) —TeleportArrivalRules.Decideis still used byTeleportArrivalReadiness; only the unused instance plumbing in GameWindow goes. -
Step 3: dotnet build green —
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug. Then full suite:dotnet test AcDream.slnx -c Debug. -
Step 4: Add divergence-register row 4 — in
docs/architecture/retail-divergence-register.md, matching the existing column order:
| Logout returns to disconnect/window-close, not a char-select screen | Stopgap | Retail shows char-select after logout; acdream closes the session. Retire when the char-select / re-login UI is built (design §9). | src/AcDream.App/World/TeleportFlowController.cs (onLogoutDisconnect) | No char-select after logout; user must relaunch to reconnect. |
- Step 5: Commit —
git add src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md && git commit -m "refactor(teleport): Slice 5 — remove dead _teleportArrival plumbing; divergence row 4 (logout→disconnect)"
Cross-slice consistency notes (the parallel-draft fixes):
- One sound-enum decision, stated once:
DRWSound.EnterPortal/ExitPortal(NOTUI_*=0x6A/0x6B); CoreSoundIdenum untouched. Owned by Task 4.2; referenced nowhere else. - One yaw-freeze, in Slice 4 only (Task 4.1) — a single early-return at
GameWindow.cs:1095covering both MMB + RMB sub-branches; not duplicated in 3 or 5. - PlaceTeleportArrival split happens once (Task 3.3):
PlaceTeleportArrivalCore(placement only) +OnTeleportLoginComplete(State=InWorld + LoginComplete). Slices 4/5 consume these, never re-split. _teleportArrivalremoval is the explicit Task 5.4 once login no longer uses the oldisSpawnGroundReady→BeginArrivalpath — avoids a half-removed field across slices.Beginidempotency (sequencer not restarted by the second coords-carryingBegin) is the contract that makes Portal's two-call trigger (OnTeleportStarted no-coords + OnLivePositionUpdated coords) safe — unit-pinned in Task 3.1.
Slice 6 (FOLLOW-UP PLAN — not in this plan): the literal 3D portal swirl
The retail tunnel is a 3D sub-viewport (UIElement_Viewport Type 0xD) rendering a portal CPhysicsObj (dat GetDIDByEnum(0x10000001,7) setup + (0x10000002,7) animation, 40 fps, randomized camera spin) shown during the Tunnel* states. acdream has no offscreen-3D / sub-viewport infrastructure, and the asset ids only resolve via a runtime trace. This slice is therefore its own spec→plan, executed in the same pass after its prerequisite:
- Prerequisite (cdb trace): attach cdb to a live retail client (see CLAUDE.md "Retail debugger toolchain"),
bp acclient!CPhysicsObj::makeObjectduring a portal, and capture the resolvedGetDIDByEnum(0x10000001,7)/(0x10000002,7)dat ids. Also captureUIGlobals::GetAnimLevel's 1024-entry table if the fade ease needs to be exact (else smoothstep stands — register row TF-EASE). - Then: build a
PortalTunnelView(offscreen 3D render of the swirlCPhysicsObjat 40 fps with the randomized camera rotation) and blit it duringSnapshot.ShowTunnel; the sequencer/overlay seams already exist (ShowTunnel/ShowPleaseWaitflags are wired in slices 1/3). Until then the tunnel states render as the black fade-hold — a coherent, retail-shaped flow.
This staging is the design's §7 slice 6 + §8 prerequisites. It does not block slices 1–5, which ship the complete behavioral feature (float gone, input locked, hold-until-loaded, sounds, the four entry points, the #145 fix).
Open questions / risks (resolve during execution; none block plan start)
- Death trigger (verify in Slice 5 Task 5.2). Death is assumed to arrive as a server
0xF751teleport →OnTeleportStarted→Begin(Portal). The research could not confirm from acdream code alone that ACE always sends0xF751before the lifestoneUpdatePosition. Gate: the Task 5.2 manual death test. Contingency if it fails (no fade on death): add a large-position-jump detector inOnLivePositionUpdatedthat setsPortalSpace+ calls_teleportFlow.Begin(TeleportEntryKind.Death, …)when the player jumps landblocks with no preceding0xF751. File it as a follow-up task only if the manual test shows death bypassing the flow. - Portal sound enum casing + the EnumIDMap chain (Slice 4 Task 4.2). The member is
DatReaderWriter.Enums.Sound.EnterPortal/ExitPortal(verified against the dat lib; the design doc'sUI_EnterPortal=0x6Ais wrong). If the build still can't resolve it, the compile error pins the exact casing — fix from the error, don't guess. The UI-SoundTable EnumIDMap index chain (0x10000003→[7]) is medium-confidence; the implementation logs once and plays no sound if it misses (graceful, per design) — capture the real chain via cdb/dat-dump and correct the two constants rather than hardcoding a table id. - Login
Impossibletruth-table (Slice 5 Task 5.1). Preserve the old login predicate's exact behavior for an un-hydratable indoor claim (it demoted to the outdoor terrain gate). ReadGameWindow.cs:1036-1064before rewriting and confirmDecide(...) == Readyreproduces it; if the old code placed (not held) on an un-hydratable indoor claim, keep that. Regression gate: login stays FPS-steady (the #106/#135 invariant). - Apparatus gate for the #145 fix (Slice 2 Task 2.5). Before relying on the readiness-gate change, run the
ACDREAM_PROBE_RESOLVEapparatus on a0xC98C-style edge arrival and confirm the gate flipsReadybefore placement with noNO-LANDBLOCKmarch. This is the "no guess-patch" gate (the original #145 burned 5 attempts). Tracked as task #4 in the session. DatReaderWritersource-of-truth. Confirm whether acdream references the vendoredreferences/DatReaderWriteror thechorizite.datreaderwriterNuGet DLL — the two disagreed on the Sound enum member names during research. The build resolves this at Slice 4.
Phase completion checklist (per CLAUDE.md)
- Every AC-specific algorithm cites its decomp reference (the sequencer constants cite
acclient.h:6871+ the VAs from the design §2.1). - Every deviation this work introduces has a
retail-divergence-register.mdrow (TF-FADE alpha overlay, TF-EASE smoothstep, logout→disconnect; tasks 1.7 / 3.x / 5.3 add them). - Conformance tests exist for the critical paths (sequencer golden timings;
Decidetruth table; controller event dispatch). dotnet buildgreen,dotnet testgreen.- Visual verification by the user (the mandatory stop): portal feel, login fade-in, death→lifestone, logout fade.
- Roadmap / milestones updated when the feature lands; ISSUES #145 residual closed if the apparatus re-test passes.
Execution
Plan complete. Two execution options:
- Subagent-Driven (recommended) — dispatch a fresh subagent per task, review between tasks, fast iteration (REQUIRED SUB-SKILL:
superpowers:subagent-driven-development). Best for a plan this size (it isolates the god-object wiring tasks from the pure-logic tasks). - Inline Execution — execute tasks in this session with batched checkpoints (REQUIRED SUB-SKILL:
superpowers:executing-plans).
Recommended order: Slice 1 → Slice 2 (lands the #145 fix; run the apparatus gate) → Slice 3 → Slice 4 → Slice 5. Each slice ends build+test green; slices 3/5 end with a visual stop.