diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index f35c88e9..475fbaf9 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -599,7 +599,7 @@ This is the asynchronous adapter for retail's direct full-`Position` placement
`TeleportAnimSequencer` is the pure Core owner of retail's seven-state
teleport lifecycle, exact 1/2/5-second thresholds, frame-aligned exit window,
-and 100-sample `UIGlobals::GetAnimLevel` fade curve. App-layer
+and 100-sample `UIGlobals::GetAnimLevel` view-plane curve. App-layer
`PortalTunnelPresentation` owns the synthetic DAT Setup (`0x02000306`), its
40-frame/s animation (`0x030005AC`), `CSequence`, SmartBox-FOV camera, distant light, mesh
references, and animation-hook delivery. It renders through the existing
@@ -613,12 +613,32 @@ enters `LiveEntityRuntime`, collision, picking, radar, or server GUID state.
`SmartBox::SetOverrideFovDistance` / `Render::set_vdst`. It captures the active
game projection at teleport begin, then applies the same table-eased
view-plane distance and near plane to the portal and world viewports through
-the four fade states. Portal camera direction is a roll around its local AC
+the four retail `FADE` states. Those states do not drive a black alpha layer:
+the portal and world 3-D viewports switch directly at the transition view
+distance, while retained UI remains independently composed. Portal camera
+direction is a roll around its local AC
`+Y` forward axis, so the animated scene remains a passage rather than yawing
away from it. Destination placement also calls the retail chase camera's
`set_viewer(player, reset_sought=1)` equivalent; the normal damped/swept camera
path then re-extends from the arriving player.
+One reset seam ends a logical transit on successful completion, replacement
+by a newer teleport sequence, or session teardown. It clears the destination,
+streaming priority, sequencer edges, projection override, and portal scene
+together, preventing transition state from crossing destination or session
+lifetimes. `TeleportTransitCoordinator` correlates each fresh F751 sequence
+with exactly one accepted `UpdatePosition`. Because the Position can arrive on
+either side of F751, a pre-notification packet is buffered only when it advances
+retail's `TELEPORT_TS`; after notification, the first accepted matching packet
+is consumed even if that timestamp already advanced. Duplicate/older F751
+notifications are rejected with wrap-safe ordering. Canonical physics remains
+independent, and streaming recentering compares destination identity with the
+actual current streaming center rather than the player's still-unplaced source
+cell. A live teleport received while acdream's developer fly/orbit camera is
+active re-enters the existing player-mode lifecycle first; retail has no
+detached camera mode, and destination placement therefore always retains the
+canonical local physics controller and chase-camera handoff.
+
The destination residency gate supplies retail's `EndTeleportAnimation` edge
once asynchronous streaming is ready. Player placement remains authoritative
and separate from presentation; arrival resets camera viewer state but does
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 74c0f3c8..60658508 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -66,7 +66,7 @@ accepted-divergence entries (#96, #49, #50).
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
-| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted). **#145 refinement (2026-06-22):** an OUTDOOR teleport additionally requires the destination's OWN landblock terrain to be resident (`PhysicsEngine.IsLandblockTerrainResident(destCell)`, which `StreamingController` priority-applies so it flips in ~hundreds of ms) before placing — and `SampleTerrainZ` is short-circuited behind it. On a teleport OUT of a dungeon the collapsed SOURCE dungeon stays resident at the same streaming-local coords and answers `SampleTerrainZ` (terrain 0), so without the load check the resolve ran against the dungeon's cells and rooted the player at a dungeon-frame cell id (ACE then rejected all movement as "failed transition"). Decision in `TeleportWorldReady`; the hold→materialize→regain-control transit is driven by `TeleportAnimSequencer` (the retail fade cover, ticked in `OnUpdate`), which replaced the retired `TeleportArrivalController` | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportWorldReady` ~5512 + the TAS transit tick in `OnUpdate`) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) |
+| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted). **#145 refinement (2026-06-22):** an OUTDOOR teleport additionally requires the destination's OWN landblock terrain to be resident (`PhysicsEngine.IsLandblockTerrainResident(destCell)`, which `StreamingController` priority-applies so it flips in ~hundreds of ms) before placing — and `SampleTerrainZ` is short-circuited behind it. On a teleport OUT of a dungeon the collapsed SOURCE dungeon stays resident at the same streaming-local coords and answers `SampleTerrainZ` (terrain 0), so without the load check the resolve ran against the dungeon's cells and rooted the player at a dungeon-frame cell id (ACE then rejected all movement as "failed transition"). Decision in `TeleportWorldReady`; the hold→materialize→regain-control transit is driven by `TeleportAnimSequencer` (the retail portal/view-plane lifecycle, ticked in `OnUpdate`), which replaced the retired `TeleportArrivalController` | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportWorldReady` ~5512 + the TAS transit tick in `OnUpdate`) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) |
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
@@ -212,7 +212,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |
| ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
-| AP-115 | The DAT-authored portal-space viewport and its animation `SoundTweakedHook` are live, but the separate `ClientUISystem` enter/exit sound enums and centered repeating `"In Portal Space - Please Wait..."` display string are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; teleport event switch in `GameWindow.cs` | acdream has no retained cross-stack centered display-string owner or ClientUISystem sound-table-enum resolver yet; routing the line into chat or inventing direct wave IDs would be less faithful | Portal travel has the correct animated wormhole, timing, fades, and animation-authored sound, but lacks retail's short UI cue sounds and center notice | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` |
+| AP-115 | The DAT-authored portal-space viewport and its animation `SoundTweakedHook` are live, but the separate `ClientUISystem` enter/exit sound enums and centered repeating `"In Portal Space - Please Wait..."` display string are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; teleport event switch in `GameWindow.cs` | acdream has no retained cross-stack centered display-string owner or ClientUISystem sound-table-enum resolver yet; routing the line into chat or inventing direct wave IDs would be less faithful | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, and animation-authored sound, but lacks retail's short UI cue sounds and center notice | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` |
## 4. Temporary stopgap (TS) — 34 active rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 4654d416..e5d538da 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -593,7 +593,7 @@ Research: R9 + R12 + R13.
- **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys.
- **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload.
- **Indoor portal-based cell tracking (follow-up to Indoor walking Phase 1 / issue #87).** Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's `CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses a cell portal boundary, `CellId` propagates through the `CEnvCell` portal connectivity graph. Prerequisite for wall collision from outside (#85) and the remaining #84 threshold symptom. PDB symbols and `acclient.h` `CCellStructure` refs are in place (see #87). **Unblocks G.3.**
-- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with post-transition `LoginComplete`, and the retail DAT-authored portal-space CreatureMode viewport (Setup `0x02000306`, animation `0x030005AC`, exact camera/light/timing/fades, forward-axis roll, projection warp, and destination viewer reset; corrective visual gate pending 2026-07-15). Dungeons render, stream, teleport-in, collide, light, and their doors work — see the shipped-table rows (G.3, G.3a, #137 collision, A7.L1 lighting) below and the M1.5 section of `docs/plans/2026-05-12-milestones.md` for current gate status (one recorded end-to-end round-trip user gate outstanding; live residual = far-town teleport-OUT arrival cascade, #145-residual REOPENED). See `r09-dungeon-portal-space.md` and `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
+- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with post-transition `LoginComplete`, and the retail DAT-authored portal-space CreatureMode viewport (Setup `0x02000306`, animation `0x030005AC`, exact camera/light/timing/view-plane transitions, forward-axis roll, direct portal→world viewport switch, projection warp, and destination viewer reset; corrective visual gate pending 2026-07-15). Dungeons render, stream, teleport-in, collide, light, and their doors work — see the shipped-table rows (G.3, G.3a, #137 collision, A7.L1 lighting) below and the M1.5 section of `docs/plans/2026-05-12-milestones.md` for current gate status (one recorded end-to-end round-trip user gate outstanding; live residual = far-town teleport-OUT arrival cascade, #145-residual REOPENED). See `r09-dungeon-portal-space.md` and `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index dbedf4f2..6f74d445 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -306,8 +306,9 @@ temporary black tunnel cover is retired. F751 transit now renders retail's
client-enum-resolved DAT Setup `0x02000306` with animation `0x030005AC` at
40 frames/s in a dedicated CreatureMode-equivalent viewport, using retail's
camera, distant light, random eased forward-axis roll, frame-aligned exit
-window, exact 100-sample fade curve, view-plane/FOV warp, and destination
-viewer reset. The viewport and fades compose below retained UI, so
+window, exact 100-sample view-plane curve, projection warp, and destination
+viewer reset. Retail switches the portal and world viewports directly without
+a black alpha fade; both compose below retained UI, so
chat, panels, toolbar, cursor, and input remain live throughout travel. See
`docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
diff --git a/docs/research/2026-07-15-retail-portal-space-pseudocode.md b/docs/research/2026-07-15-retail-portal-space-pseudocode.md
index 1ada51f2..47fd5ef7 100644
--- a/docs/research/2026-07-15-retail-portal-space-pseudocode.md
+++ b/docs/research/2026-07-15-retail-portal-space-pseudocode.md
@@ -91,9 +91,10 @@ show portal viewport
hide world SmartBox viewport
```
-At the end of `TunnelFadeOut`, retail restores the world view distance, hides
-the portal viewport, shows the world viewport, clears the teleport object's
-sequence animations, plays `Sound_UI_ExitPortal`, and enters `WorldFadeIn`.
+At the end of `TunnelFadeOut`, retail commits the current transition view
+distance, hides the portal viewport, shows the world viewport, clears the
+teleport object's sequence animations, plays `Sound_UI_ExitPortal`, and enters
+`WorldFadeIn`. That following state restores the ordinary game view distance.
## Camera motion
@@ -185,31 +186,35 @@ WorldFadeIn:
-> Off
```
-The narrow remaining-time window makes the one-second tunnel fade finish at
+The narrow remaining-time window makes the one-second tunnel projection transition finish at
the end of the 120-frame animation. The five-second ceiling is retail's escape
for a missed window.
-World/tunnel fades use `GetAnimLevel(elapsed / FadeTime)`. The fade belongs
-between the 3-D viewport and the retained UI. It must never cover or disable
-the retained UI.
+The four states whose retail enum names contain `FADE` use
+`GetAnimLevel(elapsed / FadeTime)`. Instruction-level x86 inspection of
+`gmSmartBoxUI::UseTime` (`0x004D7133..0x004D725F`) shows that this value feeds
+only `SmartBox::SetOverrideFovDistance`. The function performs no alpha or
+black-quad draw; `FADE` names the view-plane transition, not a transparency
+compositor. The retained UI remains independent because only the two 3-D
+viewports are switched.
## View-plane warp
-Retail couples every fade state to a projection transition. On begin it
+Retail couples every `FADE` state to a projection transition. On begin it
captures the current SmartBox view-plane distance; for a perspective
projection this is `cot(verticalFov / 2)`. The transition endpoint is the
named constant `TRANSITION_VIEW_PLANE_DISTANCE = 0.001` at `0x007BD260`.
```text
-fadeLevel = GetAnimLevel(elapsed / FadeTime) / 1024
+viewPlaneLevel = GetAnimLevel(elapsed / FadeTime) / 1024
WorldFadeOut or TunnelFadeOut:
currentDistance = gameDistance
- + (0.001 - gameDistance) * fadeLevel
+ + (0.001 - gameDistance) * viewPlaneLevel
TunnelFadeIn or WorldFadeIn:
currentDistance = 0.001
- + (gameDistance - 0.001) * fadeLevel
+ + (gameDistance - 0.001) * viewPlaneLevel
SmartBox.SetOverrideFovDistance(true, currentDistance)
```
@@ -221,10 +226,22 @@ verticalFov = 2 * atan(1 / currentDistance)
znear = max(0.1, currentDistance * 0.25)
```
-At `0.001`, the view is nearly 180 degrees wide. Black covers the singular
-endpoint; as `WorldFadeIn` clears, the FOV eases back to the captured game
-projection. This is retail's characteristic destination-world warp, separate
-from both the alpha fade and chase-camera movement.
+At `0.001`, the view is nearly 180 degrees wide. Retail does not cover that
+endpoint with black: it switches directly from the tunnel viewport to the
+destination world at this projection, then `WorldFadeIn` expands the world
+from the center as the captured game projection is restored. This is retail's
+characteristic destination-world warp, independent of chase-camera movement.
+
+Instruction-level checks that disambiguate the Binary Ninja x87 output:
+
+- `SmartBox::GetOverrideFovDistance` `0x00451BE0`: when no override is active,
+ returns `cot(activeVerticalFov / 2)` (`0x00798088 = 0.5`,
+ `0x007928C0 = 1.0`).
+- `gmSmartBoxUI::UseTime` `0x004D7199` and `0x004D722E`: converts the signed
+ table result with `0x007BD6A0 = 1/1024` and performs the two lerps above.
+- `Render::set_vdst` `0x0054B240`: x87 `fpatan` computes
+ `atan(1 / distance)`, doubles it, and sets
+ `znear = max(0.1, distance * 0.25)`.
## Player placement boundary
@@ -261,7 +278,7 @@ collision. Reusing the source-world viewer is not retail behavior.
The retail teleport hook does not clear the character's animation sequence.
The arrival presentation is instead hidden by the portal scene and its final
-world fade while the normal motion/update stream settles. Therefore acdream
+world view-plane transition while the normal motion/update stream settles. Therefore acdream
must not add an arrival-only animation reset.
## acdream integration translation
@@ -272,11 +289,18 @@ must not add an arrival-only animation reset.
consumes the portal object's current frame for retail exit timing and uses
the exact table-driven easing curve.
- During tunnel states the portal scene replaces world pixels before retained
- UI draws. The black fade also draws before retained UI. Input dispatch and
+ UI draws. There is no black compositor. Input dispatch and
`RetailUiRuntime.Tick` continue unchanged.
- The projection owner captures the active camera's M22 view-plane distance at
teleport begin and applies `Render::set_vdst` semantics to both the tunnel
- and world viewport during the four fade states.
+ and world viewport during the four `FADE` states.
+- F751 remains a notification gate and does not itself advance `TELEPORT_TS`.
+ Presentation correlates its sequence with exactly one accepted destination
+ Position. A Position that arrives before F751 is buffered only when it
+ advances that timestamp; after F751, the first accepted matching Position is
+ consumed even if the channel already advanced. Thus reordered delivery and
+ retransmitted notifications cannot materialize the wrong destination or
+ strand the transit, while canonical physics retains its own retail gates.
- Destination placement resets the retail chase camera's published and sought
positions to the player before the normal update path resumes.
- Destination residency remains acdream's asynchronous adaptation. It supplies
diff --git a/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md b/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md
index 23784ea3..be15ae0b 100644
--- a/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md
+++ b/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md
@@ -1,5 +1,10 @@
# Retail Teleport Flow Implementation Plan
+> **Historical presentation plan.** Superseded 2026-07-15 by the DAT-authored
+> portal viewport and retail view-plane port. `FadeAlpha` and the black overlay
+> below are not current behavior. See
+> `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
+
> **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**.
diff --git a/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md b/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md
index b06406d7..24e357a6 100644
--- a/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md
+++ b/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md
@@ -1,5 +1,10 @@
# Retail Teleport — Priority Residency + Fade Cover — Implementation Plan
+> **Historical presentation plan.** Superseded 2026-07-15 by the DAT-authored
+> portal viewport and retail view-plane port. `FadeAlpha`/`FadeOverlay` below
+> describe the retired temporary adaptation, not current behavior. See
+> `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make a teleport materialize the player onto a resident, grounded destination within ~1 s (was 10–14 s) with no movement desync, covered by a retail fade — fixing "long transition," "dropped at the wrong position," and "stops at the portal" in one coherent change.
diff --git a/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md b/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md
index d646e873..c8299b4f 100644
--- a/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md
+++ b/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md
@@ -1,5 +1,11 @@
# Design — Retail teleport flow (the unified `TeleportAnimState` controller)
+> **Presentation semantics corrected 2026-07-15.** The unified lifecycle and
+> residency gate remain, but the proposed alpha-black cover was an adaptation.
+> Retail's `FADE` states ease only SmartBox view-plane distance and switch the
+> portal/world viewports directly. Current oracle:
+> `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
+
**Date:** 2026-06-21
**Feature:** Work item B from `docs/research/2026-06-21-teleport-issues-handoff.md` — the keystone of the
teleport-issues cluster (#138, #145 residual, the floating-camera / input-not-locked / "takes too long"
diff --git a/docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md b/docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md
index 031953d0..552d0136 100644
--- a/docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md
+++ b/docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md
@@ -1,5 +1,12 @@
# Retail-faithful teleport — priority residency + fade cover — design (2026-06-22)
+> **Presentation superseded 2026-07-15.** The residency and placement design
+> remains historical context, but the black-cover interpretation does not.
+> The installed DAT-authored portal viewport is now live, and instruction-level
+> retail x86 proves the TAS `FADE` states drive only view-plane distance; retail
+> draws no alpha-black overlay. Current oracle:
+> `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
+
**Status:** approved design, pre-implementation.
**Branch:** `claude/thirsty-goldberg-51bb9b`. **Baseline:** `dd2eb8b` (+ the uncommitted `tp-probe` diagnostic).
**Supersedes the framing of:** `docs/research/2026-06-21-teleport-foundation-handoff.md` (the `_datLock`-starvation
diff --git a/src/AcDream.App/Rendering/FadeOverlay.cs b/src/AcDream.App/Rendering/FadeOverlay.cs
deleted file mode 100644
index 085080e4..00000000
--- a/src/AcDream.App/Rendering/FadeOverlay.cs
+++ /dev/null
@@ -1,119 +0,0 @@
-using System;
-using Silk.NET.OpenGL;
-
-namespace AcDream.App.Rendering;
-
-///
-/// Fullscreen black quad at a given alpha for retail's world/portal viewport
-/// transitions. It is drawn after the active 3-D viewport but before retained
-/// UI, matching gmSmartBoxUI::UseTime: the world or portal scene fades
-/// while chat, toolbar, windows, and cursor remain fully visible.
-///
-/// Draws in NDC, so it needs no view/projection. Self-contained GL state
-/// (feedback_render_self_contained_gl_state): sets blend + disables depth, restores the
-/// frame-global convention (depth test/write on) on exit.
-///
-public sealed class FadeOverlay : IDisposable
-{
- private const string VertSrc = @"#version 430 core
-layout(location = 0) in vec2 aPos;
-void main() { gl_Position = vec4(aPos, 0.0, 1.0); }";
-
- private const string FragSrc = @"#version 430 core
-uniform float uAlpha;
-out vec4 FragColor;
-void main() { FragColor = vec4(0.0, 0.0, 0.0, uAlpha); }";
-
- private readonly GL _gl;
- private readonly uint _program;
- private readonly uint _vao;
- private readonly uint _vbo;
- private readonly int _locAlpha;
-
- // Two triangles covering the whole NDC viewport.
- private static readonly float[] QuadVerts =
- {
- -1f, -1f, 1f, -1f, 1f, 1f,
- -1f, -1f, 1f, 1f, -1f, 1f,
- };
-
- public FadeOverlay(GL gl)
- {
- _gl = gl ?? throw new ArgumentNullException(nameof(gl));
-
- uint vs = Compile(ShaderType.VertexShader, VertSrc);
- uint fs = Compile(ShaderType.FragmentShader, FragSrc);
- _program = _gl.CreateProgram();
- _gl.AttachShader(_program, vs);
- _gl.AttachShader(_program, fs);
- _gl.LinkProgram(_program);
- _gl.GetProgram(_program, ProgramPropertyARB.LinkStatus, out int linked);
- if (linked == 0)
- throw new InvalidOperationException($"FadeOverlay link failed: {_gl.GetProgramInfoLog(_program)}");
- _gl.DeleteShader(vs);
- _gl.DeleteShader(fs);
-
- _locAlpha = _gl.GetUniformLocation(_program, "uAlpha");
-
- _vao = _gl.GenVertexArray();
- _vbo = _gl.GenBuffer();
- _gl.BindVertexArray(_vao);
- _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
- unsafe
- {
- fixed (float* v = QuadVerts)
- _gl.BufferData(BufferTargetARB.ArrayBuffer,
- (nuint)(QuadVerts.Length * sizeof(float)), v, BufferUsageARB.StaticDraw);
- _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), (void*)0);
- }
- _gl.EnableVertexAttribArray(0);
- _gl.BindVertexArray(0);
- _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
- }
-
- private uint Compile(ShaderType type, string src)
- {
- uint s = _gl.CreateShader(type);
- _gl.ShaderSource(s, src);
- _gl.CompileShader(s);
- _gl.GetShader(s, ShaderParameterName.CompileStatus, out int ok);
- if (ok == 0)
- throw new InvalidOperationException($"FadeOverlay {type} compile failed: {_gl.GetShaderInfoLog(s)}");
- return s;
- }
-
- ///
- /// Draw the fullscreen black cover at (0 = clear → no-op,
- /// 1 = opaque). Call after the active 3-D viewport and before retained UI.
- ///
- public void Draw(float alpha)
- {
- if (alpha <= 0f) return;
- alpha = Math.Clamp(alpha, 0f, 1f);
-
- // ---- set state (everything this draw depends on) ----
- _gl.Disable(EnableCap.DepthTest);
- _gl.DepthMask(false);
- _gl.Disable(EnableCap.CullFace);
- _gl.Enable(EnableCap.Blend);
- _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
-
- _gl.UseProgram(_program);
- _gl.Uniform1(_locAlpha, alpha);
- _gl.BindVertexArray(_vao);
- _gl.DrawArrays(PrimitiveType.Triangles, 0, 6);
- _gl.BindVertexArray(0);
- _gl.UseProgram(0);
-
- // ---- restore the frame-global convention ----
- _gl.DepthMask(true);
- _gl.Enable(EnableCap.DepthTest);
- }
-
- public void Dispose()
- {
- _gl.DeleteProgram(_program);
- _gl.DeleteVertexArray(_vao);
- _gl.DeleteBuffer(_vbo);
- }
-}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index d334bf52..38c227a0 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -222,7 +222,6 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
- private AcDream.App.Rendering.FadeOverlay? _fadeOverlay;
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
@@ -2588,8 +2587,7 @@ public sealed class GameWindow : IDisposable
// T1: invisible portal depth writes (seal/punch) — retail
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
- _fadeOverlay = new AcDream.App.Rendering.FadeOverlay(_gl);
- _portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.TryCreate(
+ _portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired(
_gl,
_dats!,
_animLoader!,
@@ -2783,6 +2781,9 @@ public sealed class GameWindow : IDisposable
private void ClearInboundEntityState()
{
EndMouseLookAndRestoreCursor();
+ ResetTeleportTransitState(clearSession: true);
+ if (_playerController is not null)
+ _playerController.State = AcDream.App.Input.PlayerState.InWorld;
// Session-scoped origin readiness. A reconnect's first logical player
// CreateObject must initialize the render/streaming center anew;
@@ -6817,118 +6818,34 @@ public sealed class GameWindow : IDisposable
entity.Rotation = rmState.Body.Orientation;
}
- // Phase B.3 / G.3a (#133): portal-space arrival detection.
- // Only runs for our own player character while in PortalSpace.
- if (_playerController is not null
- && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
- && update.Guid == _playerServerGuid)
+ // F751 is only a notification gate; the accepted Position may arrive
+ // before or after it. Canonical physics above always consumes the
+ // packet first. The presentation coordinator exposes exactly one
+ // sequence-correlated destination without reordering that state.
+ if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Apply
+ && update.Guid == _playerServerGuid
+ && _teleportTransit.OfferDestination(
+ update.TeleportSequence,
+ timestamps.TeleportAdvanced,
+ update,
+ out var teleportDestination))
{
- var oldPos = _playerController.Position;
- uint streamingOriginLandblockId =
- AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
- _liveCenterX, _liveCenterY);
-
- // Retail preserves the full Position (objcell_id + Frame) through
- // SmartBox::TeleportPlayer @ 0x00453910. Dungeon EnvCells can have
- // negative local frame origins, so XYZ cannot identify the source
- // landblock (#215). Compare the controller's authoritative current
- // cell with the received destination cell instead.
- var landblockTransition =
- AcDream.App.Streaming.TeleportLandblockTransition.Classify(
- _playerController.CellId,
- p.LandblockId,
- streamingOriginLandblockId);
- int oldLbX = (int)((landblockTransition.SourceLandblockId >> 24) & 0xFFu);
- int oldLbY = (int)((landblockTransition.SourceLandblockId >> 16) & 0xFFu);
- bool differentLandblock = landblockTransition.CrossesLandblock;
-
- Console.WriteLine(
- $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
- $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
-
- System.Numerics.Vector3 newWorldPos;
- if (differentLandblock)
- {
- // #145: drop the stale SOURCE center landblock from the physics engine
- // BEFORE recentering. The destination loads at world-offset (0,0) (the new
- // center), but the prior center was ALSO loaded at offset (0,0) and its
- // offset is never re-based — so the two overlap, and the Z-agnostic outdoor
- // cell-snap (AdjustPosition / Resolve, iterating _landblocks) resolves the
- // player into the STALE source landblock (a dungeon's frame). That happens
- // for the arrival snap AND every per-frame resolve until the source finally
- // unloads, so outbound movement is sent in the dungeon's frame and the server
- // rejects every move as a failed transition (#145 / #138). Removing the stale
- // center here makes both resolves fall through to the server-authoritative
- // position (Resolve's NO-LANDBLOCK verbatim branch, :605) until the
- // destination streams in. Only the offset-(0,0) center can collide with the
- // destination-local position, so removing it alone is sufficient.
- _physicsEngine.RemoveLandblock(streamingOriginLandblockId);
-
- // Recenter the streaming controller on the new landblock NOW (kick
- // off the dungeon load). After recentering, the destination is
- // (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin.
- _liveCenterX = lbX;
- _liveCenterY = lbY;
- newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
-
- // #135: pre-collapse on teleport into a sealed dungeon too — same
- // race as login. The destination isn't placed until it hydrates, so
- // without this NormalTick loads the full neighbor window during the
- // arrival hold. The PortalSpace observer branch (OnUpdate) keeps the
- // observer pinned to _liveCenterX/Y while held, so the stale frozen
- // player position can't drift the observer off the dungeon and re-expand.
- if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
- _streamingController.PreCollapseToDungeon(lbX, lbY);
- else
- // Outdoor teleport: the render origin (_liveCenter) just moved. Drop ALL
- // resident terrain so overlapping blocks on a NEARBY jump re-bake at the
- // new origin instead of rendering shifted — the confirmed "terrain in the
- // sky" arcs (stale slots offset by exactly deltaLB×192). 2026-06-22.
- _streamingController?.ForceReloadWindow();
- }
- else
- {
- newWorldPos = worldPos;
- }
-
- // Retail "pink-bubble" transit: do NOT snap here. Record the destination and
- // PRIORITIZE its landblock in streaming so it applies ahead of the per-frame
- // budget (residency in ~hundreds of ms, not 10-14s). The TAS — ticked per frame
- // in OnUpdate — holds the player in PortalSpace behind the fade until the
- // destination terrain is resident (TeleportWorldReady), then fires Place. While
- // held, no movement resolve runs, so the outbound cell frame can't corrupt.
- _pendingTeleportRot = rot;
- _pendingTeleportPos = newWorldPos;
- _pendingTeleportCell = p.LandblockId;
- _teleportHoldSeconds = 0f;
- _teleportForced = false;
- if (_streamingController is not null)
- {
- _streamingController.PriorityLandblockId =
- AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY);
- // Eager-apply the destination's IMMEDIATE SURROUNDINGS (the near ring), not
- // just the one landblock the player stands on — so they arrive in a loaded,
- // collidable world instead of a single landblock in the void.
- _streamingController.PriorityRadius = TeleportNearRingRadius;
- }
- AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
- "AIM", p.LandblockId,
- $"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}");
+ AimTeleportDestination(teleportDestination);
}
}
- // Retail teleport transit: the dormant 7-state TAS drives the fade cover, holds the
+ // Retail teleport transit: the 7-state TAS drives portal/view-plane presentation, holds the
// player in PortalSpace until the destination is resident (TeleportWorldReady), then
// fires Place (materialize) and FireLoginComplete (regain control + ack the server).
// Replaces the old TeleportArrivalController hold/place machine.
private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new();
private readonly TeleportViewPlaneController _teleportViewPlane = new();
- private bool _teleportInProgress;
+ private readonly AcDream.App.Streaming.TeleportTransitCoordinator<
+ AcDream.Core.Net.WorldSession.EntityPositionUpdate> _teleportTransit = new();
private System.Numerics.Vector3 _pendingTeleportPos;
private uint _pendingTeleportCell;
private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout)
private bool _teleportForced; // true when the safety-net timeout force-places
- private float _teleportFadeAlpha; // 0 = clear, 1 = full black; consumed by the fade overlay
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
// Loud safety net for a destination that never streams (worker crash / corrupt dat /
@@ -6939,13 +6856,133 @@ public sealed class GameWindow : IDisposable
private const float TeleportMaxHoldSeconds = 10f;
// 2026-06-22: how many landblocks of the player's IMMEDIATE SURROUNDINGS to eager-apply
- // (and to hold the fade for) on a teleport. radius 1 = the 3×3 around the destination —
+ // (and to hold portal space for) on a teleport. radius 1 = the 3×3 around the destination —
// the player's own landblock + its 8 neighbours, ~576 m across — enough that they arrive
// standing on loaded ground with wall collision and a non-empty immediate view. The far
- // ring (out to the streaming window) still drains at the per-frame budget after the fade
- // lifts. Bigger = a more complete arrival but a longer (still hidden) loading fade.
+ // ring (out to the streaming window) still drains at the per-frame budget after portal
+ // space exits. Bigger = a more complete arrival but a longer portal-space hold.
private const int TeleportNearRingRadius = 1;
+ ///
+ /// Bind a sequence-correlated teleport Position to presentation and
+ /// asynchronous streaming. Canonical physics has already accepted this
+ /// packet; this method only establishes the destination viewport lifetime.
+ ///
+ private void AimTeleportDestination(
+ AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
+ {
+ var playerController = _playerController
+ ?? throw new InvalidOperationException(
+ "A teleport destination was accepted before the local player controller existed.");
+ var p = update.Position;
+ int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
+ int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
+ uint streamingOriginLandblockId =
+ AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
+ _liveCenterX, _liveCenterY);
+ var origin = new System.Numerics.Vector3(
+ (lbX - _liveCenterX) * 192f,
+ (lbY - _liveCenterY) * 192f,
+ 0f);
+ var worldPos = new System.Numerics.Vector3(
+ p.PositionX, p.PositionY, p.PositionZ) + origin;
+
+ // Retail preserves the full Position (objcell_id + Frame) through
+ // SmartBox::TeleportPlayer @ 0x00453910. Dungeon EnvCells can have
+ // negative local frame origins, so XYZ cannot identify the source
+ // landblock (#215). Player crossing and streaming recentering are
+ // separate while a prior destination is aimed but not yet placed.
+ var landblockTransition =
+ AcDream.App.Streaming.TeleportLandblockTransition.Classify(
+ playerController.CellId,
+ p.LandblockId,
+ streamingOriginLandblockId);
+ int oldLbX = (int)((landblockTransition.SourceLandblockId >> 24) & 0xFFu);
+ int oldLbY = (int)((landblockTransition.SourceLandblockId >> 16) & 0xFFu);
+ bool playerCrossesLandblock = landblockTransition.CrossesLandblock;
+ bool streamingCenterChanges = landblockTransition.ChangesStreamingCenter;
+
+ Console.WriteLine(
+ $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
+ $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, playerController.Position):F1}");
+
+ System.Numerics.Vector3 newWorldPos;
+ if (streamingCenterChanges)
+ {
+ // #145: drop the stale STREAMING center landblock from the physics engine
+ // BEFORE recentering. The destination loads at world-offset (0,0) (the new
+ // center), but the prior center was ALSO loaded at offset (0,0) and its
+ // offset is never re-based — so the two overlap, and the Z-agnostic outdoor
+ // cell-snap resolves the player into the stale center. On a replacement this
+ // can be the abandoned first destination, not the unplaced player's source.
+ _physicsEngine.RemoveLandblock(streamingOriginLandblockId);
+
+ _liveCenterX = lbX;
+ _liveCenterY = lbY;
+ newWorldPos = new System.Numerics.Vector3(
+ p.PositionX, p.PositionY, p.PositionZ);
+
+ if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
+ _streamingController.PreCollapseToDungeon(lbX, lbY);
+ else
+ _streamingController?.ForceReloadWindow();
+ }
+ else
+ {
+ newWorldPos = worldPos;
+ }
+
+ // Do not snap here. The TAS holds portal space until the destination
+ // near ring is resident, then emits Place exactly once.
+ _pendingTeleportRot = new System.Numerics.Quaternion(
+ p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
+ _pendingTeleportPos = newWorldPos;
+ _pendingTeleportCell = p.LandblockId;
+ _teleportHoldSeconds = 0f;
+ _teleportForced = false;
+ if (_streamingController is not null)
+ {
+ _streamingController.PriorityLandblockId =
+ AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY);
+ _streamingController.PriorityRadius = TeleportNearRingRadius;
+ }
+
+ AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
+ "AIM", p.LandblockId,
+ $"seq={update.TeleportSequence} lb={lbX},{lbY} " +
+ $"indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} " +
+ $"playerCross={playerCrossesLandblock} centerChange={streamingCenterChanges}");
+ }
+
+ ///
+ /// End one logical teleport lifetime. This is the single symmetry seam
+ /// used by successful completion, replacement by a newer sequence, and
+ /// session teardown, so presentation state and destination state cannot
+ /// leak independently.
+ ///
+ private void ResetTeleportTransitState(bool clearSession = false)
+ {
+ if (clearSession)
+ _teleportTransit.ClearSession();
+ else
+ _teleportTransit.EndActive();
+ _pendingTeleportPos = default;
+ _pendingTeleportCell = 0u;
+ _pendingTeleportRot = System.Numerics.Quaternion.Identity;
+ _teleportHoldSeconds = 0f;
+ _teleportForced = false;
+
+ _teleportAnim.Reset();
+ _teleportViewPlane.Reset();
+ _portalTunnel?.Exit();
+
+ if (_streamingController is not null)
+ {
+ _streamingController.PriorityLandblockId = 0u;
+ _streamingController.PriorityRadius = 0;
+ }
+ }
+
// #145: the LANDBLOCK-relative (cell-local) position used to SEED the player
// body's cell-relative CellPosition. This is the ONE place the streaming center
// (_liveCenter) is allowed to touch the physics frame — at the placement seam,
@@ -6967,7 +7004,7 @@ public sealed class GameWindow : IDisposable
/// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell
/// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around
/// the player, ) being registered — not just the
- /// single destination landblock — so the fade only lifts onto a loaded, collidable world
+ /// single destination landblock — so portal space exits onto a loaded, collidable world
/// (the player's cell-walk can root into neighbour cells; no walk-through-walls and no
/// "only one landblock loaded"). The streaming controller priority-applies that same ring,
/// so it flips fast. An impossible claim (indoor cell id outside the dat's NumCells)
@@ -7032,35 +7069,79 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"PLACED", resolved.CellId, $"forced={forced}");
- // Do NOT flip to InWorld or send LoginComplete here — the player materializes BEHIND
- // the fade and stays input-frozen (PortalSpace) until the TAS fades the world back in
- // and fires FireLoginComplete (which regains control + acks the server). This is the
+ // Do NOT flip to InWorld or send LoginComplete here — the player materializes while
+ // the portal viewport is active and stays input-frozen (PortalSpace) until the TAS
+ // restores the destination projection and fires FireLoginComplete. This is the
// retail "pop out the other side" sequence.
Console.WriteLine($"live: teleport materialized — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
}
///
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). Freeze movement
- /// input (PortalSpace) and begin the retail fade transit. The per-frame TAS tick holds
- /// the player behind the fade until the destination is resident, then materializes them
- /// (Place) and, after the world fades back in, regains control + acks the server
+ /// input (PortalSpace) and begin retail portal transit. The per-frame TAS tick holds
+ /// the player behind the portal viewport until the destination is resident, then materializes
+ /// them (Place) and, after the destination view-plane restores, regains control + acks the server
/// (FireLoginComplete).
///
private void OnTeleportStarted(uint sequence)
{
- if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence))
+ ushort teleportSequence = (ushort)sequence;
+ if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, teleportSequence)
+ || !_teleportTransit.CanBegin(teleportSequence))
return;
EndMouseLookAndRestoreCursor();
- if (_playerController is not null)
- _playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
- _teleportInProgress = true;
+ // A fresh sequence is a new logical transit. Discard the prior
+ // destination and presentation before this sequence can observe them;
+ // its destination PositionUpdate may arrive on a later network tick.
+ ResetTeleportTransitState();
+ _teleportTransit.QueueStart(teleportSequence);
+ TryActivatePendingTeleportPresentation();
+ Console.WriteLine($"live: teleport queued (seq={sequence})");
+ }
+
+ ///
+ /// Converge a queued F751 notification with the ordinary live-player
+ /// projection lifecycle. Usually this activates synchronously. If a
+ /// session transition has not materialized the player projection yet, the
+ /// notification and any correlated Position remain queued until it does.
+ ///
+ private void TryActivatePendingTeleportPresentation()
+ {
+ if (!_teleportTransit.HasPendingStart)
+ return;
+
+ // Retail has no detached fly/orbit gameplay mode. If acdream's live
+ // developer camera currently owns the view, restore the existing
+ // player-mode lifecycle before transit so placement still has the
+ // canonical local physics controller and chase-camera handoff.
+ var playerController = _playerController;
+ if (playerController is null)
+ {
+ if (!_entitiesByServerGuid.ContainsKey(_playerServerGuid))
+ return;
+
+ _playerMode = true;
+ if (!EnterPlayerModeNow(loggingTag: "teleport"))
+ {
+ _playerMode = false;
+ return;
+ }
+ playerController = _playerController;
+ }
+ if (playerController is null)
+ return;
+ playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
_teleportHoldSeconds = 0f;
_teleportForced = false;
_teleportViewPlane.Begin(
_cameraController?.Active.Projection ?? System.Numerics.Matrix4x4.Identity);
_teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal);
- Console.WriteLine($"live: teleport started (seq={sequence})");
+ bool hasBufferedDestination = _teleportTransit.Activate(
+ out var bufferedDestination);
+ if (hasBufferedDestination)
+ AimTeleportDestination(bufferedDestination);
+ Console.WriteLine($"live: teleport presentation started (seq={_teleportTransit.Sequence})");
}
///
@@ -8881,14 +8962,19 @@ public sealed class GameWindow : IDisposable
// Step 2: routed through the controller; functionally identical.
_liveSessionController?.Tick();
+ // Usually F751 activates immediately. This no-op convergence check
+ // covers the session edge where the canonical player exists before
+ // its normal live projection/controller has materialized.
+ TryActivatePendingTeleportPresentation();
+
// Retail teleport transit. Runs AFTER streaming (which priority-applies the
// destination landblock this frame) and the live-session drain, so a destination
// that became resident this frame materializes the same frame. The TAS holds at
// Tunnel until worldReady, fires Place (materialize, hidden), then after the min-
- // continue + fades fires FireLoginComplete (regain control + ack). PortalTunnelPresentation
- // replaces the world viewport through the tunnel-family states; FadeOverlay transitions
- // between the two viewports below the retained UI.
- if (_teleportInProgress)
+ // continue + view-plane transitions fires FireLoginComplete (regain control + ack).
+ // PortalTunnelPresentation replaces the world viewport through the tunnel-family states;
+ // retail switches the two 3-D viewports directly, below the retained UI.
+ if (_teleportTransit.IsActive)
{
bool haveDest = _pendingTeleportCell != 0u;
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
@@ -8905,12 +8991,6 @@ public sealed class GameWindow : IDisposable
int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0;
var (snap, evts) = _teleportAnim.Tick((float)dt, ready, tunnelFrame);
_teleportViewPlane.Update(snap);
- // The fade is a viewport transition below retained UI. If the
- // installed DATs are corrupt and the retail portal scene could
- // not be built, retain an opaque world cover during transit.
- _teleportFadeAlpha = _portalTunnel is null && snap.ShowTunnel
- ? 1f
- : snap.FadeAlpha;
foreach (var e in evts)
{
@@ -8937,10 +9017,7 @@ public sealed class GameWindow : IDisposable
// each portal transition.
_liveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
- _teleportInProgress = false;
- _pendingTeleportCell = 0u;
- _teleportViewPlane.Reset();
- _teleportFadeAlpha = 0f;
+ ResetTeleportTransitState();
break;
default:
// The retained audio engine does not yet own the
@@ -9575,8 +9652,9 @@ public sealed class GameWindow : IDisposable
if (_cameraController is not null)
{
- var camera = _cameraController.Active;
- var worldProjection = _teleportViewPlane.Apply(camera.Projection);
+ var activeCamera = _cameraController.Active;
+ var camera = _teleportViewPlane.ApplyTo(activeCamera);
+ var worldProjection = camera.Projection;
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection);
// Extract camera world position from the inverse of the view
@@ -10489,7 +10567,7 @@ public sealed class GameWindow : IDisposable
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a
// CreatureMode portal-space viewport. This replacement scene and
- // its black transition draw BEFORE retained UI, so chat, windows,
+ // its projection transition draws BEFORE retained UI, so chat, windows,
// cursor, and toolbar remain visible and interactive in transit.
var portalProjection = _teleportViewPlane.Apply(
_cameraController!.Active.Projection);
@@ -10497,7 +10575,6 @@ public sealed class GameWindow : IDisposable
_window!.Size.X,
_window.Size.Y,
portalProjection);
- _fadeOverlay?.Draw(_teleportFadeAlpha);
// Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the
// viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window
@@ -14693,7 +14770,6 @@ public sealed class GameWindow : IDisposable
_wbDrawDispatcher?.Dispose();
_envCellRenderer?.Dispose(); // Phase A8
_portalDepthMask?.Dispose(); // T1
- _fadeOverlay?.Dispose();
_clipFrame?.Dispose(); // Phase U.3
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_particleRenderer?.Dispose(); // releases particle mesh refs before WB
diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs
index 73aaaa29..4c5bbd88 100644
--- a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs
+++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs
@@ -103,10 +103,11 @@ public sealed class PortalTunnelPresentation : IDisposable
///
/// Resolve retail's enum-mapped portal Setup and animation through the
- /// installed DATs. Missing assets fail closed with an actionable message;
- /// no substitute tunnel is fabricated.
+ /// installed DATs. Retail dereferences this scene unconditionally; missing
+ /// required assets therefore fail startup with an actionable diagnostic.
+ /// No substitute tunnel is fabricated.
///
- public static PortalTunnelPresentation? TryCreate(
+ public static PortalTunnelPresentation CreateRequired(
GL gl,
DatCollection dats,
IAnimationLoader animationLoader,
@@ -132,21 +133,18 @@ public sealed class PortalTunnelPresentation : IDisposable
? null
: animationLoader.LoadAnimation(animationDid);
- if (setup is null || animation is null)
- {
- Console.Error.WriteLine(
- "[portal-space] retail DAT assets unavailable: "
- + $"setup=0x{setupDid:X8} ({(setup is null ? "missing" : "ok")}), "
- + $"animation=0x{animationDid:X8} ({(animation is null ? "missing" : "ok")})");
- return null;
- }
+ EnsureRequiredAssets(
+ setupDid,
+ setup is not null,
+ animationDid,
+ animation is not null);
return new PortalTunnelPresentation(
gl,
dispatcher,
lightUbo,
meshAdapter,
- setup,
+ setup!,
setupDid,
animationDid,
animationLoader,
@@ -155,6 +153,21 @@ public sealed class PortalTunnelPresentation : IDisposable
displayNotice);
}
+ internal static void EnsureRequiredAssets(
+ uint setupDid,
+ bool setupLoaded,
+ uint animationDid,
+ bool animationLoaded)
+ {
+ if (setupLoaded && animationLoaded)
+ return;
+
+ throw new InvalidOperationException(
+ "[portal-space] required retail DAT assets unavailable: "
+ + $"setup=0x{setupDid:X8} ({(setupLoaded ? "ok" : "missing")}), "
+ + $"animation=0x{animationDid:X8} ({(animationLoaded ? "ok" : "missing")})");
+ }
+
public bool IsVisible => _visible;
public int CurrentAnimationFrame => _sequence.GetCurrFrameNumber();
public uint SetupDid => _setupDid;
@@ -210,7 +223,7 @@ public sealed class PortalTunnelPresentation : IDisposable
///
/// Replace the already-rendered world viewport with retail portal space.
- /// The caller then draws the fade and retained UI above this pass.
+ /// The caller then draws retained UI above this pass.
///
public void Draw(int width, int height, Matrix4x4 smartBoxProjection)
{
diff --git a/src/AcDream.App/Rendering/TeleportViewPlaneController.cs b/src/AcDream.App/Rendering/TeleportViewPlaneController.cs
index 2accef56..01c50711 100644
--- a/src/AcDream.App/Rendering/TeleportViewPlaneController.cs
+++ b/src/AcDream.App/Rendering/TeleportViewPlaneController.cs
@@ -8,14 +8,16 @@ namespace AcDream.App.Rendering;
/// (0x004D6E30) eases SmartBox's view-plane distance between the
/// active game's value and TRANSITION_VIEW_PLANE_DISTANCE = 0.001.
/// Render::set_vdst (0x0054B240) converts that distance back to
-/// FOV and adjusts the near plane. This is the wide projection warp visible
-/// while the destination world emerges from black.
+/// FOV and adjusts the near plane. At the exit edge retail swaps directly
+/// from portal space to the destination world at the transition projection;
+/// there is no black-alpha compositor between them.
///
public sealed class TeleportViewPlaneController
{
public const float TransitionViewPlaneDistance = 0.001f;
private float _gameViewPlaneDistance = 1f;
+ private readonly ProjectionOverrideCamera _projectionCamera = new();
public bool Enabled { get; private set; }
public float CurrentViewPlaneDistance { get; private set; } = 1f;
@@ -38,20 +40,42 @@ public sealed class TeleportViewPlaneController
}
///
- /// Apply the four retail fade-state branches. Their projection blend is
- /// numerically the same table-driven level as the black fade: normal to
- /// 0.001 on fade-out, and 0.001 back to normal on fade-in.
+ /// Apply retail's view-plane state machine. Here "fade" is retail's state
+ /// name for a projection transition: normal to 0.001 on fade-out, and
+ /// 0.001 back to normal on fade-in. A logout transition retains the
+ /// captured override during stable Tunnel; EndTeleportAnimation
+ /// (0x004D65D5) releases it when entering TunnelContinue.
///
public void Update(TeleportAnimSnapshot snapshot)
{
- Enabled = snapshot.State is TeleportAnimState.WorldFadeOut
- or TeleportAnimState.TunnelFadeIn
- or TeleportAnimState.TunnelFadeOut
- or TeleportAnimState.WorldFadeIn;
+ switch (snapshot.State)
+ {
+ case TeleportAnimState.WorldFadeOut:
+ case TeleportAnimState.TunnelFadeIn:
+ case TeleportAnimState.TunnelFadeOut:
+ case TeleportAnimState.WorldFadeIn:
+ Enabled = true;
+ CurrentViewPlaneDistance = Lerp(
+ _gameViewPlaneDistance,
+ TransitionViewPlaneDistance,
+ snapshot.ViewPlaneBlend);
+ break;
- CurrentViewPlaneDistance = Enabled
- ? Lerp(_gameViewPlaneDistance, TransitionViewPlaneDistance, snapshot.FadeAlpha)
- : _gameViewPlaneDistance;
+ case TeleportAnimState.Tunnel:
+ // Retail preserves the captured FOV override through the
+ // logout path's stable tunnel. A normal portal begins in
+ // Tunnel with no override, so its disabled state is retained.
+ if (Enabled)
+ CurrentViewPlaneDistance = _gameViewPlaneDistance;
+ break;
+
+ case TeleportAnimState.TunnelContinue:
+ case TeleportAnimState.Off:
+ default:
+ Enabled = false;
+ CurrentViewPlaneDistance = _gameViewPlaneDistance;
+ break;
+ }
}
public void Reset()
@@ -93,6 +117,39 @@ public sealed class TeleportViewPlaneController
return Matrix4x4.CreatePerspectiveFieldOfView(fov, aspect, near, far);
}
+ ///
+ /// Decorate the active camera with the same projection returned by
+ /// . Retail's Render::set_vdst is
+ /// global to every 3-D draw; callers must pass this returned camera to
+ /// terrain, meshes, particles, sky, weather, and portal-cell rendering so
+ /// rasterization and frustum culling cannot use different projections.
+ ///
+ public ICamera ApplyTo(ICamera baseCamera)
+ {
+ ArgumentNullException.ThrowIfNull(baseCamera);
+ _projectionCamera.Update(baseCamera, Apply(baseCamera.Projection));
+ return _projectionCamera;
+ }
+
private static float Lerp(float from, float to, float amount) =>
from + (to - from) * Math.Clamp(amount, 0f, 1f);
+
+ private sealed class ProjectionOverrideCamera : ICamera
+ {
+ private ICamera _source = null!;
+
+ public Matrix4x4 View => _source.View;
+ public Matrix4x4 Projection { get; private set; } = Matrix4x4.Identity;
+ public float Aspect
+ {
+ get => _source.Aspect;
+ set => _source.Aspect = value;
+ }
+
+ public void Update(ICamera source, Matrix4x4 projection)
+ {
+ _source = source;
+ Projection = projection;
+ }
+ }
}
diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs
index d39b3d9f..969c9a41 100644
--- a/src/AcDream.App/Streaming/StreamingController.cs
+++ b/src/AcDream.App/Streaming/StreamingController.cs
@@ -118,7 +118,7 @@ public sealed class StreamingController
/// so the player arrives to a near-empty world (the "Fort Tethana only one landblock
/// loaded" symptom) and their cell-walk can't root into neighbour cells yet (the
/// transient walk-through-walls). The far ring still drains at the budget. The eager
- /// apply runs during the teleport hold (behind the fade), so the GPU spike is hidden.
+ /// apply runs while the portal viewport replaces the world, so the GPU spike is hidden.
///
public int PriorityRadius { get; set; }
@@ -377,7 +377,7 @@ public sealed class StreamingController
/// SYNCHRONOUSLY drop every resident landblock (render slot + physics + state) so none
/// survives into the next frame stale, and no async unload can race a re-bake, then null
/// the region so the next re-bootstraps the WHOLE window fresh at
- /// the new origin (the near ring is priority-applied behind the fade; the rest streams in).
+ /// the new origin (the near ring is priority-applied during portal travel; the rest streams in).
/// A sealed-dungeon destination uses instead.
///
public void ForceReloadWindow()
@@ -405,7 +405,7 @@ public sealed class StreamingController
/// (rendering at its old world position as "floating terrain at the horizon"), and rapid
/// hops accumulated them faster than they cleared — a runaway resident count (951 observed
/// vs a 625 window) that also dragged FPS. Loads inside the teleport near ring
- /// (, applied behind the fade) likewise bypass the budget so the
+ /// (, applied during portal travel) likewise bypass the budget so the
/// player materialises in a loaded world.
///
private void DrainAndApply()
diff --git a/src/AcDream.App/Streaming/TeleportLandblockTransition.cs b/src/AcDream.App/Streaming/TeleportLandblockTransition.cs
index c982d24f..f8acdb80 100644
--- a/src/AcDream.App/Streaming/TeleportLandblockTransition.cs
+++ b/src/AcDream.App/Streaming/TeleportLandblockTransition.cs
@@ -1,9 +1,11 @@
namespace AcDream.App.Streaming;
///
-/// Classifies whether a local-player teleport crosses an AC landblock boundary.
-/// Cell identity comes from the complete retail Position, never from its
-/// frame coordinates: dungeon EnvCells may have valid negative local origins.
+/// Classifies both whether the player crosses an AC landblock boundary and
+/// whether acdream's asynchronous streaming origin must recenter. These are
+/// distinct while a teleport destination is aimed but not yet placed. Cell
+/// identity comes from the complete retail Position, never from its frame
+/// coordinates: dungeon EnvCells may have valid negative local origins.
///
///
/// Retail carries Position::objcell_id through
@@ -14,9 +16,12 @@ namespace AcDream.App.Streaming;
///
internal readonly record struct TeleportLandblockTransition(
uint SourceLandblockId,
- uint DestinationLandblockId)
+ uint DestinationLandblockId,
+ uint StreamingCenterLandblockId)
{
public bool CrossesLandblock => SourceLandblockId != DestinationLandblockId;
+ public bool ChangesStreamingCenter =>
+ StreamingCenterLandblockId != DestinationLandblockId;
///
/// Build the transition from the player's current cell and the received
@@ -34,7 +39,8 @@ internal readonly record struct TeleportLandblockTransition(
return new TeleportLandblockTransition(
sourceLandblockId,
- NormalizeLandblockId(destinationCellId));
+ NormalizeLandblockId(destinationCellId),
+ NormalizeLandblockId(currentStreamingCenterLandblockId));
}
private static uint NormalizeLandblockId(uint cellOrLandblockId)
diff --git a/src/AcDream.App/Streaming/TeleportTransitCoordinator.cs b/src/AcDream.App/Streaming/TeleportTransitCoordinator.cs
new file mode 100644
index 00000000..7987ebe8
--- /dev/null
+++ b/src/AcDream.App/Streaming/TeleportTransitCoordinator.cs
@@ -0,0 +1,150 @@
+namespace AcDream.App.Streaming;
+
+///
+/// Correlates F751 notifications with accepted Position packets without
+/// imposing presentation order on canonical physics. F751 deliberately does
+/// not consume retail's TELEPORT_TS, and the matching Position may be observed
+/// on either side of the notification.
+///
+internal sealed class TeleportTransitCoordinator
+ where TDestination : struct
+{
+ private bool _hasLastStart;
+ private ushort _lastStartSequence;
+ private readonly Dictionary _bufferedDestinations = new();
+ private bool _hasPendingStart;
+ private ushort _pendingStartSequence;
+ private bool _destinationAccepted;
+
+ public bool IsActive { get; private set; }
+ public ushort Sequence { get; private set; }
+ public bool HasPendingStart => _hasPendingStart;
+
+ /// Reject retransmitted or older F751 notifications.
+ public bool CanBegin(ushort sequence) =>
+ !_hasLastStart || IsNewer(_lastStartSequence, sequence);
+
+ ///
+ /// Record a fresh notification lifetime. Presentation activation is a
+ /// separate edge because acdream's optional developer camera can
+ /// temporarily withdraw the normal player projection.
+ ///
+ public bool QueueStart(ushort sequence)
+ {
+ if (!CanBegin(sequence))
+ return false;
+
+ PruneDestinationsOlderThan(sequence);
+ IsActive = false;
+ Sequence = 0;
+ _hasPendingStart = true;
+ _pendingStartSequence = sequence;
+ _hasLastStart = true;
+ _lastStartSequence = sequence;
+ _destinationAccepted = false;
+ return true;
+ }
+
+ ///
+ /// Activate the queued presentation and consume a destination that was
+ /// accepted before the player projection became available.
+ ///
+ public bool Activate(out TDestination bufferedDestination)
+ {
+ bufferedDestination = default;
+ if (!_hasPendingStart)
+ return false;
+
+ IsActive = true;
+ Sequence = _pendingStartSequence;
+ _hasPendingStart = false;
+ _pendingStartSequence = 0;
+ _destinationAccepted = false;
+
+ if (!_bufferedDestinations.Remove(Sequence, out bufferedDestination))
+ return false;
+
+ _destinationAccepted = true;
+ return true;
+ }
+
+ ///
+ /// Offer an accepted Position packet. A matching active transit consumes
+ /// its first packet even when TELEPORT_TS was already advanced. Without a
+ /// matching notification, only a timestamp-advancing packet is buffered;
+ /// ordinary movement can never become a future portal destination.
+ ///
+ public bool OfferDestination(
+ ushort sequence,
+ bool teleportTimestampAdvanced,
+ TDestination destination,
+ out TDestination acceptedDestination)
+ {
+ acceptedDestination = default;
+
+ // An advancing TELEPORT_TS makes every older destination impossible
+ // to match a future F751. Retire those generations now so a skipped
+ // notification cannot retain a stale destination through u16 wrap.
+ if (teleportTimestampAdvanced)
+ PruneDestinationsOlderThan(sequence);
+
+ if (IsActive && sequence == Sequence)
+ {
+ if (_destinationAccepted)
+ return false;
+
+ _destinationAccepted = true;
+ acceptedDestination = destination;
+ return true;
+ }
+
+ if (_hasPendingStart && sequence == _pendingStartSequence)
+ {
+ _bufferedDestinations.TryAdd(sequence, destination);
+ return false;
+ }
+
+ ushort correlationSequence = _hasPendingStart
+ ? _pendingStartSequence
+ : Sequence;
+ bool mayBelongToFutureStart = (!IsActive && !_hasPendingStart)
+ || IsNewer(correlationSequence, sequence);
+ if (!teleportTimestampAdvanced || !mayBelongToFutureStart)
+ return false;
+
+ _bufferedDestinations.TryAdd(sequence, destination);
+
+ return false;
+ }
+
+ /// End presentation while retaining duplicate and reorder history.
+ public void EndActive()
+ {
+ IsActive = false;
+ Sequence = 0;
+ _hasPendingStart = false;
+ _pendingStartSequence = 0;
+ _destinationAccepted = false;
+ }
+
+ /// Clear every correlation value at a network-session boundary.
+ public void ClearSession()
+ {
+ EndActive();
+ _hasLastStart = false;
+ _lastStartSequence = 0;
+ _bufferedDestinations.Clear();
+ }
+
+ private static bool IsNewer(ushort current, ushort incoming)
+ => AcDream.Core.Physics.PhysicsTimestampGate.IsNewer(current, incoming);
+
+ private void PruneDestinationsOlderThan(ushort sequence)
+ {
+ foreach (ushort bufferedSequence in _bufferedDestinations.Keys.ToArray())
+ {
+ if (IsNewer(bufferedSequence, sequence))
+ _bufferedDestinations.Remove(bufferedSequence);
+ }
+ }
+}
diff --git a/src/AcDream.Core/World/TeleportAnimSequencer.cs b/src/AcDream.Core/World/TeleportAnimSequencer.cs
index 3042a07d..510c27d5 100644
--- a/src/AcDream.Core/World/TeleportAnimSequencer.cs
+++ b/src/AcDream.Core/World/TeleportAnimSequencer.cs
@@ -23,7 +23,7 @@ public enum TeleportEntryKind { Portal, Login, Death, Logout }
public enum TeleportAnimEvent
{
PlayEnterSound, // Begin(): sound_ui_enter_portal
- EnterTunnel, // Off/WorldFade* -> Tunnel: world is now hidden
+ EnterTunnel, // First tunnel-family frame: portal viewport replaces world
Place, // Tunnel -> TunnelContinue: world loaded; place the player
PlayExitSound, // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal
FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1
@@ -32,7 +32,7 @@ public enum TeleportAnimEvent
/// Immutable per-frame snapshot from the sequencer.
public readonly record struct TeleportAnimSnapshot(
TeleportAnimState State,
- float FadeAlpha, // 0 = clear world, 1 = full black
+ float ViewPlaneBlend, // 0 = game view distance, 1 = transition distance
bool ShowTunnel, // true during TunnelFadeIn..TunnelFadeOut
bool ShowPleaseWait); // true during TunnelContinue only
@@ -82,6 +82,21 @@ public sealed class TeleportAnimSequencer
_enterTunnelPending = _state == TeleportAnimState.Tunnel; // true for Portal/Login/Death
}
+ ///
+ /// Cancel the current transition and discard every pending edge event.
+ /// Session teardown and a replacement teleport both require a clean
+ /// lifetime; no event from the abandoned transition may reach the next
+ /// session or destination.
+ ///
+ public void Reset()
+ {
+ _state = TeleportAnimState.Off;
+ _elapsed = 0f;
+ _continueElapsed = 0f;
+ _enterSoundPending = false;
+ _enterTunnelPending = false;
+ }
+
///
/// Advance the machine by seconds.
/// = destination collision landblock is resident.
@@ -104,12 +119,14 @@ public sealed class TeleportAnimSequencer
{
case TeleportAnimState.WorldFadeOut:
if (_elapsed >= FadeTime)
- Advance(TeleportAnimState.TunnelFadeIn, enterTunnel: false);
+ // UseTime's viewport-visibility block precedes this state
+ // transition, so retail shows portal space on the next tick.
+ Advance(TeleportAnimState.TunnelFadeIn, enterTunnel: true);
break;
case TeleportAnimState.TunnelFadeIn:
if (_elapsed >= FadeTime)
- Advance(TeleportAnimState.Tunnel, enterTunnel: true);
+ Advance(TeleportAnimState.Tunnel, enterTunnel: false);
break;
case TeleportAnimState.Tunnel:
@@ -171,13 +188,13 @@ public sealed class TeleportAnimSequencer
private TeleportAnimSnapshot BuildSnapshot()
{
- float alpha = ComputeFadeAlpha(_state, _elapsed);
+ float viewBlend = ComputeViewPlaneBlend(_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);
+ return new TeleportAnimSnapshot(_state, viewBlend, showTunnel, pleaseWait);
}
///
@@ -193,21 +210,26 @@ public sealed class TeleportAnimSequencer
return RetailAnimationLevels[-negativeIndex];
}
- private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed)
+ ///
+ /// Retail's four states named *Fade* do not drive an alpha cover.
+ /// gmSmartBoxUI::UseTime (0x004D7133-0x004D725F) uses this eased
+ /// level only to interpolate SmartBox's view-plane distance.
+ ///
+ private static float ComputeViewPlaneBlend(TeleportAnimState state, float elapsed)
{
float t = Math.Clamp(elapsed / FadeTime, 0f, 1f);
float level = GetRetailAnimationLevel(t) / 1024f;
return state switch
{
- // Fading TO black (alpha 0→1):
+ // Normal game view distance -> transition distance.
TeleportAnimState.WorldFadeOut => level,
- TeleportAnimState.TunnelFadeIn => 1f - level, // tunnel fades IN: overlay goes clear
- // Full black / fully clear inside tunnel states:
- TeleportAnimState.Tunnel => 0f, // world hidden, overlay not needed
+ TeleportAnimState.TunnelFadeOut => level,
+ // Transition distance -> normal game view distance.
+ TeleportAnimState.TunnelFadeIn => 1f - level,
+ TeleportAnimState.WorldFadeIn => 1f - level,
+ // Stable viewport states use the ordinary game projection.
+ TeleportAnimState.Tunnel => 0f,
TeleportAnimState.TunnelContinue => 0f,
- // Fading back out of tunnel:
- TeleportAnimState.TunnelFadeOut => level, // tunnel fades out: overlay goes black
- TeleportAnimState.WorldFadeIn => 1f - level, // world fades in: overlay clears
_ => 0f,
};
}
diff --git a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
index f58702ab..e567e81e 100644
--- a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
@@ -53,6 +53,21 @@ public sealed class PortalTunnelAssetTests
Assert.Equal(DatReaderWriter.Enums.AnimationHookType.SoundTweaked, hook.HookType);
}
+ [Fact]
+ public void RequiredAssets_MissingSetupOrAnimationFailsWithActionableIds()
+ {
+ var error = Assert.Throws(() =>
+ PortalTunnelPresentation.EnsureRequiredAssets(
+ setupDid: 0u,
+ setupLoaded: false,
+ animationDid: 0x030005ACu,
+ animationLoaded: true));
+
+ Assert.Contains("required retail DAT assets unavailable", error.Message);
+ Assert.Contains("setup=0x00000000 (missing)", error.Message);
+ Assert.Contains("animation=0x030005AC (ok)", error.Message);
+ }
+
[Fact]
public void PortalTunnelCamera_RollsAroundForwardAxisWithoutLeavingPassage()
{
@@ -96,7 +111,7 @@ public sealed class PortalTunnelAssetTests
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.TunnelFadeOut,
- FadeAlpha: 0.5f,
+ ViewPlaneBlend: 0.5f,
ShowTunnel: true,
ShowPleaseWait: false));
System.Numerics.Matrix4x4 projection = controller.Apply(baseProjection);
@@ -126,7 +141,7 @@ public sealed class PortalTunnelAssetTests
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.WorldFadeIn,
- FadeAlpha: 1f,
+ ViewPlaneBlend: 1f,
ShowTunnel: false,
ShowPleaseWait: false));
System.Numerics.Matrix4x4 wideProjection = controller.Apply(baseProjection);
@@ -139,7 +154,7 @@ public sealed class PortalTunnelAssetTests
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.Off,
- FadeAlpha: 0f,
+ ViewPlaneBlend: 0f,
ShowTunnel: false,
ShowPleaseWait: false));
@@ -147,6 +162,101 @@ public sealed class PortalTunnelAssetTests
Assert.Equal(baseProjection, controller.Apply(baseProjection));
}
+ [Fact]
+ public void TeleportViewPlane_ApplyToSuppliesOverrideToEveryCameraConsumer()
+ {
+ var source = new PortalTunnelCamera
+ {
+ Aspect = 16f / 9f,
+ FovRadians = MathF.PI / 3f,
+ Near = 0.1f,
+ Far = 5000f,
+ };
+ var controller = new TeleportViewPlaneController();
+ controller.Begin(source.Projection);
+ controller.Update(new TeleportAnimSnapshot(
+ TeleportAnimState.WorldFadeIn,
+ ViewPlaneBlend: 1f,
+ ShowTunnel: false,
+ ShowPleaseWait: false));
+
+ ICamera renderCamera = controller.ApplyTo(source);
+
+ Assert.Equal(source.View, renderCamera.View);
+ Assert.Equal(
+ TeleportViewPlaneController.TransitionViewPlaneDistance,
+ renderCamera.Projection.M22,
+ precision: 5);
+ renderCamera.Aspect = 4f / 3f;
+ Assert.Equal(4f / 3f, source.Aspect, precision: 5);
+ }
+
+ [Fact]
+ public void TeleportViewPlane_TunnelPreservesLogoutOverrideUntilTunnelContinue()
+ {
+ var capturedProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
+ MathF.PI / 3f,
+ 16f / 9f,
+ 0.1f,
+ 5000f);
+ var changedProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
+ MathF.PI / 2f,
+ 16f / 9f,
+ 0.1f,
+ 5000f);
+ var controller = new TeleportViewPlaneController();
+ controller.Begin(capturedProjection);
+
+ controller.Update(new TeleportAnimSnapshot(
+ TeleportAnimState.WorldFadeOut,
+ ViewPlaneBlend: 1f,
+ ShowTunnel: false,
+ ShowPleaseWait: false));
+ controller.Update(new TeleportAnimSnapshot(
+ TeleportAnimState.TunnelFadeIn,
+ ViewPlaneBlend: 0f,
+ ShowTunnel: true,
+ ShowPleaseWait: false));
+ controller.Update(new TeleportAnimSnapshot(
+ TeleportAnimState.Tunnel,
+ ViewPlaneBlend: 0f,
+ ShowTunnel: true,
+ ShowPleaseWait: false));
+
+ Assert.True(controller.Enabled);
+ Assert.Equal(capturedProjection.M22, controller.Apply(changedProjection).M22, precision: 5);
+
+ controller.Update(new TeleportAnimSnapshot(
+ TeleportAnimState.TunnelContinue,
+ ViewPlaneBlend: 0f,
+ ShowTunnel: true,
+ ShowPleaseWait: false));
+
+ Assert.False(controller.Enabled);
+ Assert.Equal(changedProjection, controller.Apply(changedProjection));
+ }
+
+ [Fact]
+ public void TeleportViewPlane_NormalPortalTunnelDoesNotEnableOverride()
+ {
+ var baseProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
+ MathF.PI / 3f,
+ 16f / 9f,
+ 0.1f,
+ 5000f);
+ var controller = new TeleportViewPlaneController();
+ controller.Begin(baseProjection);
+
+ controller.Update(new TeleportAnimSnapshot(
+ TeleportAnimState.Tunnel,
+ ViewPlaneBlend: 0f,
+ ShowTunnel: true,
+ ShowPleaseWait: false));
+
+ Assert.False(controller.Enabled);
+ Assert.Equal(baseProjection, controller.Apply(baseProjection));
+ }
+
[Theory]
[InlineData(45f)]
[InlineData(60f)]
diff --git a/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs b/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs
index 027d4665..1cadf774 100644
--- a/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs
@@ -60,4 +60,18 @@ public sealed class TeleportLandblockTransitionTests
Assert.False(transition.CrossesLandblock);
Assert.Equal(0x8C04FFFFu, transition.SourceLandblockId);
}
+
+ [Fact]
+ public void ReplacementBackToPlayerLandblock_StillRecentersAbandonedStreamingOrigin()
+ {
+ var transition = TeleportLandblockTransition.Classify(
+ sourceCellId: 0x8C0401ADu,
+ destinationCellId: 0x8C040145u,
+ currentStreamingCenterLandblockId: 0xA9B4FFFFu);
+
+ Assert.False(transition.CrossesLandblock);
+ Assert.True(transition.ChangesStreamingCenter);
+ Assert.Equal(0x8C04FFFFu, transition.DestinationLandblockId);
+ Assert.Equal(0xA9B4FFFFu, transition.StreamingCenterLandblockId);
+ }
}
diff --git a/tests/AcDream.App.Tests/Streaming/TeleportTransitCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/TeleportTransitCoordinatorTests.cs
new file mode 100644
index 00000000..d5eea9f9
--- /dev/null
+++ b/tests/AcDream.App.Tests/Streaming/TeleportTransitCoordinatorTests.cs
@@ -0,0 +1,133 @@
+using AcDream.App.Streaming;
+
+namespace AcDream.App.Tests.Streaming;
+
+public sealed class TeleportTransitCoordinatorTests
+{
+ [Fact]
+ public void ReplacementRejectsDelayedPriorDestinationAndAcceptsCurrentAdvance()
+ {
+ var transit = new TeleportTransitCoordinator();
+ Assert.True(transit.QueueStart(10));
+ Assert.False(transit.Activate(out _));
+ transit.EndActive();
+ Assert.True(transit.QueueStart(11));
+ Assert.False(transit.Activate(out _));
+
+ Assert.False(transit.OfferDestination(10, true, 100, out _));
+ Assert.True(transit.OfferDestination(11, true, 110, out int accepted));
+ Assert.Equal(110, accepted);
+ }
+
+ [Fact]
+ public void PositionBeforeF751IsBufferedAndEqualFollowupCannotReplaceIt()
+ {
+ var transit = new TeleportTransitCoordinator();
+
+ Assert.False(transit.OfferDestination(11, true, 110, out _));
+ Assert.True(transit.QueueStart(11));
+ Assert.True(transit.Activate(out int buffered));
+ Assert.Equal(110, buffered);
+ Assert.False(transit.OfferDestination(11, false, 111, out _));
+ }
+
+ [Fact]
+ public void InactiveOrdinaryPositionIsNeverBuffered()
+ {
+ var transit = new TeleportTransitCoordinator();
+
+ Assert.False(transit.OfferDestination(11, false, 110, out _));
+ Assert.True(transit.QueueStart(11));
+ Assert.False(transit.Activate(out _));
+ }
+
+ [Fact]
+ public void DuplicateStartIsRejectedButSequenceWrapIsFresh()
+ {
+ var transit = new TeleportTransitCoordinator();
+ Assert.True(transit.QueueStart(ushort.MaxValue));
+ Assert.False(transit.Activate(out _));
+ transit.EndActive();
+
+ Assert.False(transit.CanBegin(ushort.MaxValue));
+ Assert.False(transit.QueueStart(ushort.MaxValue));
+ Assert.True(transit.CanBegin(0));
+ Assert.True(transit.QueueStart(0));
+ Assert.True(transit.HasPendingStart);
+ }
+
+ [Fact]
+ public void ExactHalfRangeUsesRetailLowerStampTieBreak()
+ {
+ var transit = new TeleportTransitCoordinator();
+ Assert.True(transit.QueueStart(0x8000));
+ Assert.False(transit.Activate(out _));
+ transit.EndActive();
+
+ Assert.True(transit.CanBegin(0x0000));
+ Assert.True(transit.QueueStart(0x0000));
+ }
+
+ [Fact]
+ public void ClearSessionAllowsSameSequenceInNewSession()
+ {
+ var transit = new TeleportTransitCoordinator();
+ Assert.True(transit.QueueStart(7));
+ Assert.False(transit.Activate(out _));
+
+ transit.ClearSession();
+
+ Assert.False(transit.IsActive);
+ Assert.True(transit.CanBegin(7));
+ }
+
+ [Fact]
+ public void PendingStartAcceptsEqualPositionUntilPresentationCanActivate()
+ {
+ var transit = new TeleportTransitCoordinator();
+ Assert.True(transit.QueueStart(11));
+
+ Assert.False(transit.OfferDestination(11, false, 110, out _));
+ Assert.True(transit.Activate(out int buffered));
+
+ Assert.Equal(110, buffered);
+ Assert.True(transit.IsActive);
+ }
+
+ [Fact]
+ public void AdvancingPositionPrunesSkippedDestination()
+ {
+ var transit = new TeleportTransitCoordinator();
+
+ Assert.False(transit.OfferDestination(11, true, 110, out _));
+ Assert.False(transit.OfferDestination(12, true, 120, out _));
+ Assert.True(transit.QueueStart(12));
+ Assert.True(transit.Activate(out int buffered));
+
+ Assert.Equal(120, buffered);
+ }
+
+ [Fact]
+ public void ObsoleteDestinationCannotSurviveSequenceWrap()
+ {
+ var transit = new TeleportTransitCoordinator();
+
+ Assert.False(transit.OfferDestination(ushort.MaxValue, true, 999, out _));
+ Assert.False(transit.OfferDestination(0, true, 0, out _));
+ Assert.True(transit.QueueStart(0));
+ Assert.True(transit.Activate(out int wrapped));
+ Assert.Equal(0, wrapped);
+ transit.EndActive();
+
+ // Advance in legal retail-newer hops until 0xFFFF is current again.
+ Assert.True(transit.QueueStart(0x7FFF));
+ Assert.False(transit.Activate(out _));
+ transit.EndActive();
+ Assert.True(transit.QueueStart(0xFFFE));
+ Assert.False(transit.Activate(out _));
+ transit.EndActive();
+ Assert.True(transit.QueueStart(ushort.MaxValue));
+
+ Assert.False(transit.Activate(out _));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
index 59d8f110..2a6e1a88 100644
--- a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
+++ b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
@@ -31,16 +31,32 @@ public sealed class TeleportAnimSequencerTests
}
[Fact]
- public void Snapshot_DefaultsAreOff_ClearAlpha()
+ public void Snapshot_DefaultsAreOff_WithGameViewPlane()
{
var snap = new TeleportAnimSnapshot(
- TeleportAnimState.Off, FadeAlpha: 0f, ShowTunnel: false, ShowPleaseWait: false);
+ TeleportAnimState.Off, ViewPlaneBlend: 0f, ShowTunnel: false, ShowPleaseWait: false);
Assert.Equal(TeleportAnimState.Off, snap.State);
- Assert.Equal(0f, snap.FadeAlpha);
+ Assert.Equal(0f, snap.ViewPlaneBlend);
Assert.False(snap.ShowTunnel);
Assert.False(snap.ShowPleaseWait);
}
+ [Fact]
+ public void Reset_CancelsTransitionAndDiscardsPendingEdges()
+ {
+ var seq = new TeleportAnimSequencer();
+ seq.Begin(TeleportEntryKind.Portal);
+
+ seq.Reset();
+ var (snap, events) = seq.Tick(0f, worldReady: true);
+
+ Assert.False(seq.IsActive);
+ Assert.Equal(TeleportAnimState.Off, snap.State);
+ Assert.Equal(0f, snap.ViewPlaneBlend);
+ Assert.False(snap.ShowTunnel);
+ Assert.Empty(events);
+ }
+
// --- Task 1.2: Begin(), IsActive, initial state ---
[Theory]
@@ -285,20 +301,20 @@ public sealed class TeleportAnimSequencerTests
Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts);
}
- // --- Task 1.4: FadeAlpha endpoints and monotonicity ---
+ // --- Retail view-plane blend endpoints and monotonicity ---
[Fact]
- public void FadeAlpha_IsZeroAtStart_OfWorldFadeOut()
+ public void ViewPlaneBlend_IsZeroAtStart_OfWorldFadeOut()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
var (snap, _) = seq.Tick(dt: 0f, worldReady: false);
- // At elapsed=0 in WorldFadeOut: retail animation level 0 => fully visible.
- Assert.Equal(0f, snap.FadeAlpha, precision: 4);
+ // At elapsed=0 the override still equals the ordinary game distance.
+ Assert.Equal(0f, snap.ViewPlaneBlend, precision: 4);
}
[Fact]
- public void FadeAlpha_IsOneAtEnd_OfWorldFadeOut()
+ public void ViewPlaneBlend_IsOneAtEnd_OfWorldFadeOut()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
@@ -309,12 +325,14 @@ public sealed class TeleportAnimSequencerTests
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
var (snap, _) = seq.Tick(0f, worldReady: false);
- // The retail animation table should be close to fully opaque here.
- Assert.True(snap.FadeAlpha > 0.95f, $"Expected FadeAlpha near 1, got {snap.FadeAlpha}");
+ // The retail animation table should be close to the transition distance here.
+ Assert.True(
+ snap.ViewPlaneBlend > 0.95f,
+ $"Expected ViewPlaneBlend near 1, got {snap.ViewPlaneBlend}");
}
[Fact]
- public void FadeAlpha_IsMonotonicallyIncreasing_DuringWorldFadeOut()
+ public void ViewPlaneBlend_IsMonotonicallyIncreasing_DuringWorldFadeOut()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
@@ -327,23 +345,23 @@ public sealed class TeleportAnimSequencerTests
{
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;
+ Assert.True(snap.ViewPlaneBlend >= prev - 0.001f,
+ $"View-plane blend decreased: {prev} -> {snap.ViewPlaneBlend} at step {i}");
+ prev = snap.ViewPlaneBlend;
}
}
[Fact]
- public void FadeAlpha_IsZero_DuringTunnelStates()
+ public void ViewPlaneBlend_IsZero_DuringStableTunnelStates()
{
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)
+ // Stable tunnel space uses the ordinary game projection.
var (snap, _) = seq.Tick(0.016f, worldReady: false);
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
- Assert.Equal(0f, snap.FadeAlpha);
+ Assert.Equal(0f, snap.ViewPlaneBlend);
}
[Fact]
@@ -372,6 +390,30 @@ public sealed class TeleportAnimSequencerTests
Assert.True(snap3.ShowTunnel);
}
+ [Fact]
+ public void Logout_EntersPortalViewportOnFirstTunnelFadeInTick()
+ {
+ var seq = new TeleportAnimSequencer();
+ seq.Begin(TeleportEntryKind.Logout);
+
+ // Retail's visibility block runs before the transition block, so the
+ // WorldFadeOut crossing queues the viewport edge for the next tick.
+ var (crossing, crossingEvents) = seq.Tick(
+ TeleportAnimSequencer.FadeTime,
+ worldReady: false);
+ Assert.Equal(TeleportAnimState.TunnelFadeIn, crossing.State);
+ Assert.True(crossing.ShowTunnel);
+ Assert.DoesNotContain(TeleportAnimEvent.EnterTunnel, crossingEvents);
+
+ var (firstFadeInFrame, firstFadeInEvents) = seq.Tick(0f, worldReady: false);
+ Assert.Equal(TeleportAnimState.TunnelFadeIn, firstFadeInFrame.State);
+ Assert.Contains(TeleportAnimEvent.EnterTunnel, firstFadeInEvents);
+
+ var (_, tunnelEvents) = seq.Tick(TeleportAnimSequencer.FadeTime, worldReady: false);
+ Assert.Equal(TeleportAnimState.Tunnel, seq.State);
+ Assert.DoesNotContain(TeleportAnimEvent.EnterTunnel, tunnelEvents);
+ }
+
[Fact]
public void ShowPleaseWait_TrueOnlyInTunnelContinue()
{