fix(world): remove non-retail portal exit fade

This commit is contained in:
Erik 2026-07-15 23:20:52 +02:00
parent 842bd89c16
commit dded9e6b17
21 changed files with 919 additions and 347 deletions

View file

@ -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 `TeleportAnimSequencer` is the pure Core owner of retail's seven-state
teleport lifecycle, exact 1/2/5-second thresholds, frame-aligned exit window, 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 `PortalTunnelPresentation` owns the synthetic DAT Setup (`0x02000306`), its
40-frame/s animation (`0x030005AC`), `CSequence`, SmartBox-FOV camera, distant light, mesh 40-frame/s animation (`0x030005AC`), `CSequence`, SmartBox-FOV camera, distant light, mesh
references, and animation-hook delivery. It renders through the existing 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 `SmartBox::SetOverrideFovDistance` / `Render::set_vdst`. It captures the active
game projection at teleport begin, then applies the same table-eased game projection at teleport begin, then applies the same table-eased
view-plane distance and near plane to the portal and world viewports through 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 `+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 away from it. Destination placement also calls the retail chase camera's
`set_viewer(player, reset_sought=1)` equivalent; the normal damped/swept camera `set_viewer(player, reset_sought=1)` equivalent; the normal damped/swept camera
path then re-extends from the arriving player. 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 The destination residency gate supplies retail's `EndTeleportAnimation` edge
once asynchronous streaming is ready. Player placement remains authoritative once asynchronous streaming is ready. Player placement remains authoritative
and separate from presentation; arrival resets camera viewer state but does and separate from presentation; arrival resets camera viewer state but does

View file

@ -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 | | # | 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-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-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-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) | | 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-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-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) ## 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)

View file

@ -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.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. - **✓ 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.** - **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. **Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.

View file

@ -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 client-enum-resolved DAT Setup `0x02000306` with animation `0x030005AC` at
40 frames/s in a dedicated CreatureMode-equivalent viewport, using retail's 40 frames/s in a dedicated CreatureMode-equivalent viewport, using retail's
camera, distant light, random eased forward-axis roll, frame-aligned exit camera, distant light, random eased forward-axis roll, frame-aligned exit
window, exact 100-sample fade curve, view-plane/FOV warp, and destination window, exact 100-sample view-plane curve, projection warp, and destination
viewer reset. The viewport and fades compose below retained UI, so 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 chat, panels, toolbar, cursor, and input remain live throughout travel. See
`docs/research/2026-07-15-retail-portal-space-pseudocode.md`. `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.

View file

@ -91,9 +91,10 @@ show portal viewport
hide world SmartBox viewport hide world SmartBox viewport
``` ```
At the end of `TunnelFadeOut`, retail restores the world view distance, hides At the end of `TunnelFadeOut`, retail commits the current transition view
the portal viewport, shows the world viewport, clears the teleport object's distance, hides the portal viewport, shows the world viewport, clears the
sequence animations, plays `Sound_UI_ExitPortal`, and enters `WorldFadeIn`. teleport object's sequence animations, plays `Sound_UI_ExitPortal`, and enters
`WorldFadeIn`. That following state restores the ordinary game view distance.
## Camera motion ## Camera motion
@ -185,31 +186,35 @@ WorldFadeIn:
-> Off -> 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 the end of the 120-frame animation. The five-second ceiling is retail's escape
for a missed window. for a missed window.
World/tunnel fades use `GetAnimLevel(elapsed / FadeTime)`. The fade belongs The four states whose retail enum names contain `FADE` use
between the 3-D viewport and the retained UI. It must never cover or disable `GetAnimLevel(elapsed / FadeTime)`. Instruction-level x86 inspection of
the retained UI. `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 ## 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 captures the current SmartBox view-plane distance; for a perspective
projection this is `cot(verticalFov / 2)`. The transition endpoint is the projection this is `cot(verticalFov / 2)`. The transition endpoint is the
named constant `TRANSITION_VIEW_PLANE_DISTANCE = 0.001` at `0x007BD260`. named constant `TRANSITION_VIEW_PLANE_DISTANCE = 0.001` at `0x007BD260`.
```text ```text
fadeLevel = GetAnimLevel(elapsed / FadeTime) / 1024 viewPlaneLevel = GetAnimLevel(elapsed / FadeTime) / 1024
WorldFadeOut or TunnelFadeOut: WorldFadeOut or TunnelFadeOut:
currentDistance = gameDistance currentDistance = gameDistance
+ (0.001 - gameDistance) * fadeLevel + (0.001 - gameDistance) * viewPlaneLevel
TunnelFadeIn or WorldFadeIn: TunnelFadeIn or WorldFadeIn:
currentDistance = 0.001 currentDistance = 0.001
+ (gameDistance - 0.001) * fadeLevel + (gameDistance - 0.001) * viewPlaneLevel
SmartBox.SetOverrideFovDistance(true, currentDistance) SmartBox.SetOverrideFovDistance(true, currentDistance)
``` ```
@ -221,10 +226,22 @@ verticalFov = 2 * atan(1 / currentDistance)
znear = max(0.1, currentDistance * 0.25) znear = max(0.1, currentDistance * 0.25)
``` ```
At `0.001`, the view is nearly 180 degrees wide. Black covers the singular At `0.001`, the view is nearly 180 degrees wide. Retail does not cover that
endpoint; as `WorldFadeIn` clears, the FOV eases back to the captured game endpoint with black: it switches directly from the tunnel viewport to the
projection. This is retail's characteristic destination-world warp, separate destination world at this projection, then `WorldFadeIn` expands the world
from both the alpha fade and chase-camera movement. 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 ## 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 retail teleport hook does not clear the character's animation sequence.
The arrival presentation is instead hidden by the portal scene and its final 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. must not add an arrival-only animation reset.
## acdream integration translation ## 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 consumes the portal object's current frame for retail exit timing and uses
the exact table-driven easing curve. the exact table-driven easing curve.
- During tunnel states the portal scene replaces world pixels before retained - 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. `RetailUiRuntime.Tick` continue unchanged.
- The projection owner captures the active camera's M22 view-plane distance at - 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 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 - Destination placement resets the retail chase camera's published and sought
positions to the player before the normal update path resumes. positions to the player before the normal update path resumes.
- Destination residency remains acdream's asynchronous adaptation. It supplies - Destination residency remains acdream's asynchronous adaptation. It supplies

View file

@ -1,5 +1,10 @@
# Retail Teleport Flow Implementation Plan # 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. > **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**. **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**.

View file

@ -1,5 +1,10 @@
# Retail Teleport — Priority Residency + Fade Cover — Implementation Plan # 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. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make a teleport materialize the player onto a resident, grounded destination within ~1 s (was 1014 s) with no movement desync, covered by a retail fade — fixing "long transition," "dropped at the wrong position," and "stops at the portal" in one coherent change. **Goal:** Make a teleport materialize the player onto a resident, grounded destination within ~1 s (was 1014 s) with no movement desync, covered by a retail fade — fixing "long transition," "dropped at the wrong position," and "stops at the portal" in one coherent change.

View file

@ -1,5 +1,11 @@
# Design — Retail teleport flow (the unified `TeleportAnimState` controller) # 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 **Date:** 2026-06-21
**Feature:** Work item B from `docs/research/2026-06-21-teleport-issues-handoff.md` — the keystone of the **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" teleport-issues cluster (#138, #145 residual, the floating-camera / input-not-locked / "takes too long"

View file

@ -1,5 +1,12 @@
# Retail-faithful teleport — priority residency + fade cover — design (2026-06-22) # 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. **Status:** approved design, pre-implementation.
**Branch:** `claude/thirsty-goldberg-51bb9b`. **Baseline:** `dd2eb8b` (+ the uncommitted `tp-probe` diagnostic). **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 **Supersedes the framing of:** `docs/research/2026-06-21-teleport-foundation-handoff.md` (the `_datLock`-starvation

View file

@ -1,119 +0,0 @@
using System;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// 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 <c>gmSmartBoxUI::UseTime</c>: the world or portal scene fades
/// while chat, toolbar, windows, and cursor remain fully visible.
///
/// <para>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.</para>
/// </summary>
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;
}
/// <summary>
/// Draw the fullscreen black cover at <paramref name="alpha"/> (0 = clear → no-op,
/// 1 = opaque). Call after the active 3-D viewport and before retained UI.
/// </summary>
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);
}
}

View file

@ -222,7 +222,6 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel; private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
private AcDream.App.Rendering.FadeOverlay? _fadeOverlay;
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition; private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain // 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 // T1: invisible portal depth writes (seal/punch) — retail
// DrawPortalPolyInternal (Ghidra 0x0059bc90). // DrawPortalPolyInternal (Ghidra 0x0059bc90).
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl); _portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
_fadeOverlay = new AcDream.App.Rendering.FadeOverlay(_gl); _portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired(
_portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.TryCreate(
_gl, _gl,
_dats!, _dats!,
_animLoader!, _animLoader!,
@ -2783,6 +2781,9 @@ public sealed class GameWindow : IDisposable
private void ClearInboundEntityState() private void ClearInboundEntityState()
{ {
EndMouseLookAndRestoreCursor(); 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 // Session-scoped origin readiness. A reconnect's first logical player
// CreateObject must initialize the render/streaming center anew; // CreateObject must initialize the render/streaming center anew;
@ -6817,118 +6818,34 @@ public sealed class GameWindow : IDisposable
entity.Rotation = rmState.Body.Orientation; entity.Rotation = rmState.Body.Orientation;
} }
// Phase B.3 / G.3a (#133): portal-space arrival detection. // F751 is only a notification gate; the accepted Position may arrive
// Only runs for our own player character while in PortalSpace. // before or after it. Canonical physics above always consumes the
if (_playerController is not null // packet first. The presentation coordinator exposes exactly one
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace // sequence-correlated destination without reordering that state.
&& update.Guid == _playerServerGuid) 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; AimTeleportDestination(teleportDestination);
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}");
} }
} }
// 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 // player in PortalSpace until the destination is resident (TeleportWorldReady), then
// fires Place (materialize) and FireLoginComplete (regain control + ack the server). // fires Place (materialize) and FireLoginComplete (regain control + ack the server).
// Replaces the old TeleportArrivalController hold/place machine. // Replaces the old TeleportArrivalController hold/place machine.
private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new(); private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new();
private readonly TeleportViewPlaneController _teleportViewPlane = 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 System.Numerics.Vector3 _pendingTeleportPos;
private uint _pendingTeleportCell; private uint _pendingTeleportCell;
private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout) private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout)
private bool _teleportForced; // true when the safety-net timeout force-places 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; private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
// Loud safety net for a destination that never streams (worker crash / corrupt dat / // 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; private const float TeleportMaxHoldSeconds = 10f;
// 2026-06-22: how many landblocks of the player's IMMEDIATE SURROUNDINGS to eager-apply // 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 // 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 // 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 // ring (out to the streaming window) still drains at the per-frame budget after portal
// lifts. Bigger = a more complete arrival but a longer (still hidden) loading fade. // space exits. Bigger = a more complete arrival but a longer portal-space hold.
private const int TeleportNearRingRadius = 1; private const int TeleportNearRingRadius = 1;
/// <summary>
/// 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.
/// </summary>
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}");
}
/// <summary>
/// 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.
/// </summary>
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 // #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 // 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, // (_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 /// 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 /// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around
/// the player, <see cref="TeleportNearRingRadius"/>) being registered — not just the /// the player, <see cref="TeleportNearRingRadius"/>) 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 /// (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, /// "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) /// 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( AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"PLACED", resolved.CellId, $"forced={forced}"); "PLACED", resolved.CellId, $"forced={forced}");
// Do NOT flip to InWorld or send LoginComplete here — the player materializes BEHIND // Do NOT flip to InWorld or send LoginComplete here — the player materializes while
// the fade and stays input-frozen (PortalSpace) until the TAS fades the world back in // the portal viewport is active and stays input-frozen (PortalSpace) until the TAS
// and fires FireLoginComplete (which regains control + acks the server). This is the // restores the destination projection and fires FireLoginComplete. This is the
// retail "pop out the other side" sequence. // retail "pop out the other side" sequence.
Console.WriteLine($"live: teleport materialized — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); Console.WriteLine($"live: teleport materialized — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
} }
/// <summary> /// <summary>
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). Freeze movement /// 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 /// input (PortalSpace) and begin retail portal transit. The per-frame TAS tick holds
/// the player behind the fade until the destination is resident, then materializes them /// the player behind the portal viewport until the destination is resident, then materializes
/// (Place) and, after the world fades back in, regains control + acks the server /// them (Place) and, after the destination view-plane restores, regains control + acks the server
/// (FireLoginComplete). /// (FireLoginComplete).
/// </summary> /// </summary>
private void OnTeleportStarted(uint sequence) private void OnTeleportStarted(uint sequence)
{ {
if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence)) ushort teleportSequence = (ushort)sequence;
if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, teleportSequence)
|| !_teleportTransit.CanBegin(teleportSequence))
return; return;
EndMouseLookAndRestoreCursor(); EndMouseLookAndRestoreCursor();
if (_playerController is not null) // A fresh sequence is a new logical transit. Discard the prior
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace; // destination and presentation before this sequence can observe them;
_teleportInProgress = true; // its destination PositionUpdate may arrive on a later network tick.
ResetTeleportTransitState();
_teleportTransit.QueueStart(teleportSequence);
TryActivatePendingTeleportPresentation();
Console.WriteLine($"live: teleport queued (seq={sequence})");
}
/// <summary>
/// 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.
/// </summary>
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; _teleportHoldSeconds = 0f;
_teleportForced = false; _teleportForced = false;
_teleportViewPlane.Begin( _teleportViewPlane.Begin(
_cameraController?.Active.Projection ?? System.Numerics.Matrix4x4.Identity); _cameraController?.Active.Projection ?? System.Numerics.Matrix4x4.Identity);
_teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal); _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})");
} }
/// <summary> /// <summary>
@ -8881,14 +8962,19 @@ public sealed class GameWindow : IDisposable
// Step 2: routed through the controller; functionally identical. // Step 2: routed through the controller; functionally identical.
_liveSessionController?.Tick(); _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 // Retail teleport transit. Runs AFTER streaming (which priority-applies the
// destination landblock this frame) and the live-session drain, so a destination // 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 // that became resident this frame materializes the same frame. The TAS holds at
// Tunnel until worldReady, fires Place (materialize, hidden), then after the min- // Tunnel until worldReady, fires Place (materialize, hidden), then after the min-
// continue + fades fires FireLoginComplete (regain control + ack). PortalTunnelPresentation // continue + view-plane transitions fires FireLoginComplete (regain control + ack).
// replaces the world viewport through the tunnel-family states; FadeOverlay transitions // PortalTunnelPresentation replaces the world viewport through the tunnel-family states;
// between the two viewports below the retained UI. // retail switches the two 3-D viewports directly, below the retained UI.
if (_teleportInProgress) if (_teleportTransit.IsActive)
{ {
bool haveDest = _pendingTeleportCell != 0u; bool haveDest = _pendingTeleportCell != 0u;
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell); bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
@ -8905,12 +8991,6 @@ public sealed class GameWindow : IDisposable
int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0; int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0;
var (snap, evts) = _teleportAnim.Tick((float)dt, ready, tunnelFrame); var (snap, evts) = _teleportAnim.Tick((float)dt, ready, tunnelFrame);
_teleportViewPlane.Update(snap); _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) foreach (var e in evts)
{ {
@ -8937,10 +9017,7 @@ public sealed class GameWindow : IDisposable
// each portal transition. // each portal transition.
_liveSession?.SendGameAction( _liveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
_teleportInProgress = false; ResetTeleportTransitState();
_pendingTeleportCell = 0u;
_teleportViewPlane.Reset();
_teleportFadeAlpha = 0f;
break; break;
default: default:
// The retained audio engine does not yet own the // The retained audio engine does not yet own the
@ -9575,8 +9652,9 @@ public sealed class GameWindow : IDisposable
if (_cameraController is not null) if (_cameraController is not null)
{ {
var camera = _cameraController.Active; var activeCamera = _cameraController.Active;
var worldProjection = _teleportViewPlane.Apply(camera.Projection); var camera = _teleportViewPlane.ApplyTo(activeCamera);
var worldProjection = camera.Projection;
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection); var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection);
// Extract camera world position from the inverse of the view // 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 // Retail gmSmartBoxUI swaps the world SmartBox viewport for a
// CreatureMode portal-space viewport. This replacement scene and // 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. // cursor, and toolbar remain visible and interactive in transit.
var portalProjection = _teleportViewPlane.Apply( var portalProjection = _teleportViewPlane.Apply(
_cameraController!.Active.Projection); _cameraController!.Active.Projection);
@ -10497,7 +10575,6 @@ public sealed class GameWindow : IDisposable
_window!.Size.X, _window!.Size.X,
_window.Size.Y, _window.Size.Y,
portalProjection); portalProjection);
_fadeOverlay?.Draw(_teleportFadeAlpha);
// Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the // 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 // 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(); _wbDrawDispatcher?.Dispose();
_envCellRenderer?.Dispose(); // Phase A8 _envCellRenderer?.Dispose(); // Phase A8
_portalDepthMask?.Dispose(); // T1 _portalDepthMask?.Dispose(); // T1
_fadeOverlay?.Dispose();
_clipFrame?.Dispose(); // Phase U.3 _clipFrame?.Dispose(); // Phase U.3
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first _skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_particleRenderer?.Dispose(); // releases particle mesh refs before WB _particleRenderer?.Dispose(); // releases particle mesh refs before WB

View file

@ -103,10 +103,11 @@ public sealed class PortalTunnelPresentation : IDisposable
/// <summary> /// <summary>
/// Resolve retail's enum-mapped portal Setup and animation through the /// Resolve retail's enum-mapped portal Setup and animation through the
/// installed DATs. Missing assets fail closed with an actionable message; /// installed DATs. Retail dereferences this scene unconditionally; missing
/// no substitute tunnel is fabricated. /// required assets therefore fail startup with an actionable diagnostic.
/// No substitute tunnel is fabricated.
/// </summary> /// </summary>
public static PortalTunnelPresentation? TryCreate( public static PortalTunnelPresentation CreateRequired(
GL gl, GL gl,
DatCollection dats, DatCollection dats,
IAnimationLoader animationLoader, IAnimationLoader animationLoader,
@ -132,21 +133,18 @@ public sealed class PortalTunnelPresentation : IDisposable
? null ? null
: animationLoader.LoadAnimation(animationDid); : animationLoader.LoadAnimation(animationDid);
if (setup is null || animation is null) EnsureRequiredAssets(
{ setupDid,
Console.Error.WriteLine( setup is not null,
"[portal-space] retail DAT assets unavailable: " animationDid,
+ $"setup=0x{setupDid:X8} ({(setup is null ? "missing" : "ok")}), " animation is not null);
+ $"animation=0x{animationDid:X8} ({(animation is null ? "missing" : "ok")})");
return null;
}
return new PortalTunnelPresentation( return new PortalTunnelPresentation(
gl, gl,
dispatcher, dispatcher,
lightUbo, lightUbo,
meshAdapter, meshAdapter,
setup, setup!,
setupDid, setupDid,
animationDid, animationDid,
animationLoader, animationLoader,
@ -155,6 +153,21 @@ public sealed class PortalTunnelPresentation : IDisposable
displayNotice); 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 bool IsVisible => _visible;
public int CurrentAnimationFrame => _sequence.GetCurrFrameNumber(); public int CurrentAnimationFrame => _sequence.GetCurrFrameNumber();
public uint SetupDid => _setupDid; public uint SetupDid => _setupDid;
@ -210,7 +223,7 @@ public sealed class PortalTunnelPresentation : IDisposable
/// <summary> /// <summary>
/// Replace the already-rendered world viewport with retail portal space. /// 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.
/// </summary> /// </summary>
public void Draw(int width, int height, Matrix4x4 smartBoxProjection) public void Draw(int width, int height, Matrix4x4 smartBoxProjection)
{ {

View file

@ -8,14 +8,16 @@ namespace AcDream.App.Rendering;
/// (<c>0x004D6E30</c>) eases SmartBox's view-plane distance between the /// (<c>0x004D6E30</c>) eases SmartBox's view-plane distance between the
/// active game's value and <c>TRANSITION_VIEW_PLANE_DISTANCE = 0.001</c>. /// active game's value and <c>TRANSITION_VIEW_PLANE_DISTANCE = 0.001</c>.
/// <c>Render::set_vdst</c> (<c>0x0054B240</c>) converts that distance back to /// <c>Render::set_vdst</c> (<c>0x0054B240</c>) converts that distance back to
/// FOV and adjusts the near plane. This is the wide projection warp visible /// FOV and adjusts the near plane. At the exit edge retail swaps directly
/// while the destination world emerges from black. /// from portal space to the destination world at the transition projection;
/// there is no black-alpha compositor between them.
/// </summary> /// </summary>
public sealed class TeleportViewPlaneController public sealed class TeleportViewPlaneController
{ {
public const float TransitionViewPlaneDistance = 0.001f; public const float TransitionViewPlaneDistance = 0.001f;
private float _gameViewPlaneDistance = 1f; private float _gameViewPlaneDistance = 1f;
private readonly ProjectionOverrideCamera _projectionCamera = new();
public bool Enabled { get; private set; } public bool Enabled { get; private set; }
public float CurrentViewPlaneDistance { get; private set; } = 1f; public float CurrentViewPlaneDistance { get; private set; } = 1f;
@ -38,20 +40,42 @@ public sealed class TeleportViewPlaneController
} }
/// <summary> /// <summary>
/// Apply the four retail fade-state branches. Their projection blend is /// Apply retail's view-plane state machine. Here "fade" is retail's state
/// numerically the same table-driven level as the black fade: normal to /// name for a projection transition: normal to 0.001 on fade-out, and
/// 0.001 on fade-out, and 0.001 back to normal on fade-in. /// 0.001 back to normal on fade-in. A logout transition retains the
/// captured override during stable Tunnel; <c>EndTeleportAnimation</c>
/// (<c>0x004D65D5</c>) releases it when entering TunnelContinue.
/// </summary> /// </summary>
public void Update(TeleportAnimSnapshot snapshot) public void Update(TeleportAnimSnapshot snapshot)
{ {
Enabled = snapshot.State is TeleportAnimState.WorldFadeOut switch (snapshot.State)
or TeleportAnimState.TunnelFadeIn {
or TeleportAnimState.TunnelFadeOut case TeleportAnimState.WorldFadeOut:
or TeleportAnimState.WorldFadeIn; case TeleportAnimState.TunnelFadeIn:
case TeleportAnimState.TunnelFadeOut:
case TeleportAnimState.WorldFadeIn:
Enabled = true;
CurrentViewPlaneDistance = Lerp(
_gameViewPlaneDistance,
TransitionViewPlaneDistance,
snapshot.ViewPlaneBlend);
break;
CurrentViewPlaneDistance = Enabled case TeleportAnimState.Tunnel:
? Lerp(_gameViewPlaneDistance, TransitionViewPlaneDistance, snapshot.FadeAlpha) // Retail preserves the captured FOV override through the
: _gameViewPlaneDistance; // 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() public void Reset()
@ -93,6 +117,39 @@ public sealed class TeleportViewPlaneController
return Matrix4x4.CreatePerspectiveFieldOfView(fov, aspect, near, far); return Matrix4x4.CreatePerspectiveFieldOfView(fov, aspect, near, far);
} }
/// <summary>
/// Decorate the active camera with the same projection returned by
/// <see cref="Apply(Matrix4x4)"/>. Retail's <c>Render::set_vdst</c> 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.
/// </summary>
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) => private static float Lerp(float from, float to, float amount) =>
from + (to - from) * Math.Clamp(amount, 0f, 1f); 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;
}
}
} }

View file

@ -118,7 +118,7 @@ public sealed class StreamingController
/// so the player arrives to a near-empty world (the "Fort Tethana only one landblock /// 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 /// 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 /// 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.
/// </summary> /// </summary>
public int PriorityRadius { get; set; } public int PriorityRadius { get; set; }
@ -377,7 +377,7 @@ public sealed class StreamingController
/// SYNCHRONOUSLY drop every resident landblock (render slot + physics + state) so none /// 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 /// survives into the next frame stale, and no async unload can race a re-bake, then null
/// the region so the next <see cref="NormalTick"/> re-bootstraps the WHOLE window fresh at /// the region so the next <see cref="NormalTick"/> 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 <see cref="PreCollapseToDungeon"/> instead. /// A sealed-dungeon destination uses <see cref="PreCollapseToDungeon"/> instead.
/// </summary> /// </summary>
public void ForceReloadWindow() 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 /// (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 /// 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 /// vs a 625 window) that also dragged FPS. Loads inside the teleport near ring
/// (<see cref="PriorityRadius"/>, applied behind the fade) likewise bypass the budget so the /// (<see cref="PriorityRadius"/>, applied during portal travel) likewise bypass the budget so the
/// player materialises in a loaded world. /// player materialises in a loaded world.
/// </summary> /// </summary>
private void DrainAndApply() private void DrainAndApply()

View file

@ -1,9 +1,11 @@
namespace AcDream.App.Streaming; namespace AcDream.App.Streaming;
/// <summary> /// <summary>
/// Classifies whether a local-player teleport crosses an AC landblock boundary. /// Classifies both whether the player crosses an AC landblock boundary and
/// Cell identity comes from the complete retail <c>Position</c>, never from its /// whether acdream's asynchronous streaming origin must recenter. These are
/// frame coordinates: dungeon EnvCells may have valid negative local origins. /// distinct while a teleport destination is aimed but not yet placed. Cell
/// identity comes from the complete retail <c>Position</c>, never from its frame
/// coordinates: dungeon EnvCells may have valid negative local origins.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Retail carries <c>Position::objcell_id</c> through /// Retail carries <c>Position::objcell_id</c> through
@ -14,9 +16,12 @@ namespace AcDream.App.Streaming;
/// </remarks> /// </remarks>
internal readonly record struct TeleportLandblockTransition( internal readonly record struct TeleportLandblockTransition(
uint SourceLandblockId, uint SourceLandblockId,
uint DestinationLandblockId) uint DestinationLandblockId,
uint StreamingCenterLandblockId)
{ {
public bool CrossesLandblock => SourceLandblockId != DestinationLandblockId; public bool CrossesLandblock => SourceLandblockId != DestinationLandblockId;
public bool ChangesStreamingCenter =>
StreamingCenterLandblockId != DestinationLandblockId;
/// <summary> /// <summary>
/// Build the transition from the player's current cell and the received /// Build the transition from the player's current cell and the received
@ -34,7 +39,8 @@ internal readonly record struct TeleportLandblockTransition(
return new TeleportLandblockTransition( return new TeleportLandblockTransition(
sourceLandblockId, sourceLandblockId,
NormalizeLandblockId(destinationCellId)); NormalizeLandblockId(destinationCellId),
NormalizeLandblockId(currentStreamingCenterLandblockId));
} }
private static uint NormalizeLandblockId(uint cellOrLandblockId) private static uint NormalizeLandblockId(uint cellOrLandblockId)

View file

@ -0,0 +1,150 @@
namespace AcDream.App.Streaming;
/// <summary>
/// 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.
/// </summary>
internal sealed class TeleportTransitCoordinator<TDestination>
where TDestination : struct
{
private bool _hasLastStart;
private ushort _lastStartSequence;
private readonly Dictionary<ushort, TDestination> _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;
/// <summary>Reject retransmitted or older F751 notifications.</summary>
public bool CanBegin(ushort sequence) =>
!_hasLastStart || IsNewer(_lastStartSequence, sequence);
/// <summary>
/// Record a fresh notification lifetime. Presentation activation is a
/// separate edge because acdream's optional developer camera can
/// temporarily withdraw the normal player projection.
/// </summary>
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;
}
/// <summary>
/// Activate the queued presentation and consume a destination that was
/// accepted before the player projection became available.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>End presentation while retaining duplicate and reorder history.</summary>
public void EndActive()
{
IsActive = false;
Sequence = 0;
_hasPendingStart = false;
_pendingStartSequence = 0;
_destinationAccepted = false;
}
/// <summary>Clear every correlation value at a network-session boundary.</summary>
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);
}
}
}

View file

@ -23,7 +23,7 @@ public enum TeleportEntryKind { Portal, Login, Death, Logout }
public enum TeleportAnimEvent public enum TeleportAnimEvent
{ {
PlayEnterSound, // Begin(): sound_ui_enter_portal 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 Place, // Tunnel -> TunnelContinue: world loaded; place the player
PlayExitSound, // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal PlayExitSound, // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal
FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1 FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1
@ -32,7 +32,7 @@ public enum TeleportAnimEvent
/// <summary>Immutable per-frame snapshot from the sequencer.</summary> /// <summary>Immutable per-frame snapshot from the sequencer.</summary>
public readonly record struct TeleportAnimSnapshot( public readonly record struct TeleportAnimSnapshot(
TeleportAnimState State, 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 ShowTunnel, // true during TunnelFadeIn..TunnelFadeOut
bool ShowPleaseWait); // true during TunnelContinue only bool ShowPleaseWait); // true during TunnelContinue only
@ -82,6 +82,21 @@ public sealed class TeleportAnimSequencer
_enterTunnelPending = _state == TeleportAnimState.Tunnel; // true for Portal/Login/Death _enterTunnelPending = _state == TeleportAnimState.Tunnel; // true for Portal/Login/Death
} }
/// <summary>
/// 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.
/// </summary>
public void Reset()
{
_state = TeleportAnimState.Off;
_elapsed = 0f;
_continueElapsed = 0f;
_enterSoundPending = false;
_enterTunnelPending = false;
}
/// <summary> /// <summary>
/// Advance the machine by <paramref name="dt"/> seconds. /// Advance the machine by <paramref name="dt"/> seconds.
/// <paramref name="worldReady"/> = destination collision landblock is resident. /// <paramref name="worldReady"/> = destination collision landblock is resident.
@ -104,12 +119,14 @@ public sealed class TeleportAnimSequencer
{ {
case TeleportAnimState.WorldFadeOut: case TeleportAnimState.WorldFadeOut:
if (_elapsed >= FadeTime) 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; break;
case TeleportAnimState.TunnelFadeIn: case TeleportAnimState.TunnelFadeIn:
if (_elapsed >= FadeTime) if (_elapsed >= FadeTime)
Advance(TeleportAnimState.Tunnel, enterTunnel: true); Advance(TeleportAnimState.Tunnel, enterTunnel: false);
break; break;
case TeleportAnimState.Tunnel: case TeleportAnimState.Tunnel:
@ -171,13 +188,13 @@ public sealed class TeleportAnimSequencer
private TeleportAnimSnapshot BuildSnapshot() private TeleportAnimSnapshot BuildSnapshot()
{ {
float alpha = ComputeFadeAlpha(_state, _elapsed); float viewBlend = ComputeViewPlaneBlend(_state, _elapsed);
bool showTunnel = _state is TeleportAnimState.TunnelFadeIn bool showTunnel = _state is TeleportAnimState.TunnelFadeIn
or TeleportAnimState.Tunnel or TeleportAnimState.Tunnel
or TeleportAnimState.TunnelContinue or TeleportAnimState.TunnelContinue
or TeleportAnimState.TunnelFadeOut; or TeleportAnimState.TunnelFadeOut;
bool pleaseWait = _state == TeleportAnimState.TunnelContinue; bool pleaseWait = _state == TeleportAnimState.TunnelContinue;
return new TeleportAnimSnapshot(_state, alpha, showTunnel, pleaseWait); return new TeleportAnimSnapshot(_state, viewBlend, showTunnel, pleaseWait);
} }
/// <summary> /// <summary>
@ -193,21 +210,26 @@ public sealed class TeleportAnimSequencer
return RetailAnimationLevels[-negativeIndex]; return RetailAnimationLevels[-negativeIndex];
} }
private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed) /// <summary>
/// Retail's four states named <c>*Fade*</c> do not drive an alpha cover.
/// <c>gmSmartBoxUI::UseTime</c> (0x004D7133-0x004D725F) uses this eased
/// level only to interpolate SmartBox's view-plane distance.
/// </summary>
private static float ComputeViewPlaneBlend(TeleportAnimState state, float elapsed)
{ {
float t = Math.Clamp(elapsed / FadeTime, 0f, 1f); float t = Math.Clamp(elapsed / FadeTime, 0f, 1f);
float level = GetRetailAnimationLevel(t) / 1024f; float level = GetRetailAnimationLevel(t) / 1024f;
return state switch return state switch
{ {
// Fading TO black (alpha 0→1): // Normal game view distance -> transition distance.
TeleportAnimState.WorldFadeOut => level, TeleportAnimState.WorldFadeOut => level,
TeleportAnimState.TunnelFadeIn => 1f - level, // tunnel fades IN: overlay goes clear TeleportAnimState.TunnelFadeOut => level,
// Full black / fully clear inside tunnel states: // Transition distance -> normal game view distance.
TeleportAnimState.Tunnel => 0f, // world hidden, overlay not needed TeleportAnimState.TunnelFadeIn => 1f - level,
TeleportAnimState.WorldFadeIn => 1f - level,
// Stable viewport states use the ordinary game projection.
TeleportAnimState.Tunnel => 0f,
TeleportAnimState.TunnelContinue => 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, _ => 0f,
}; };
} }

View file

@ -53,6 +53,21 @@ public sealed class PortalTunnelAssetTests
Assert.Equal(DatReaderWriter.Enums.AnimationHookType.SoundTweaked, hook.HookType); Assert.Equal(DatReaderWriter.Enums.AnimationHookType.SoundTweaked, hook.HookType);
} }
[Fact]
public void RequiredAssets_MissingSetupOrAnimationFailsWithActionableIds()
{
var error = Assert.Throws<InvalidOperationException>(() =>
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] [Fact]
public void PortalTunnelCamera_RollsAroundForwardAxisWithoutLeavingPassage() public void PortalTunnelCamera_RollsAroundForwardAxisWithoutLeavingPassage()
{ {
@ -96,7 +111,7 @@ public sealed class PortalTunnelAssetTests
controller.Update(new TeleportAnimSnapshot( controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.TunnelFadeOut, TeleportAnimState.TunnelFadeOut,
FadeAlpha: 0.5f, ViewPlaneBlend: 0.5f,
ShowTunnel: true, ShowTunnel: true,
ShowPleaseWait: false)); ShowPleaseWait: false));
System.Numerics.Matrix4x4 projection = controller.Apply(baseProjection); System.Numerics.Matrix4x4 projection = controller.Apply(baseProjection);
@ -126,7 +141,7 @@ public sealed class PortalTunnelAssetTests
controller.Update(new TeleportAnimSnapshot( controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.WorldFadeIn, TeleportAnimState.WorldFadeIn,
FadeAlpha: 1f, ViewPlaneBlend: 1f,
ShowTunnel: false, ShowTunnel: false,
ShowPleaseWait: false)); ShowPleaseWait: false));
System.Numerics.Matrix4x4 wideProjection = controller.Apply(baseProjection); System.Numerics.Matrix4x4 wideProjection = controller.Apply(baseProjection);
@ -139,7 +154,7 @@ public sealed class PortalTunnelAssetTests
controller.Update(new TeleportAnimSnapshot( controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.Off, TeleportAnimState.Off,
FadeAlpha: 0f, ViewPlaneBlend: 0f,
ShowTunnel: false, ShowTunnel: false,
ShowPleaseWait: false)); ShowPleaseWait: false));
@ -147,6 +162,101 @@ public sealed class PortalTunnelAssetTests
Assert.Equal(baseProjection, controller.Apply(baseProjection)); 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] [Theory]
[InlineData(45f)] [InlineData(45f)]
[InlineData(60f)] [InlineData(60f)]

View file

@ -60,4 +60,18 @@ public sealed class TeleportLandblockTransitionTests
Assert.False(transition.CrossesLandblock); Assert.False(transition.CrossesLandblock);
Assert.Equal(0x8C04FFFFu, transition.SourceLandblockId); 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);
}
} }

View file

@ -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<int>();
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<int>();
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<int>();
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<int>();
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<int>();
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<int>();
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<int>();
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<int>();
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<int>();
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 _));
}
}

View file

@ -31,16 +31,32 @@ public sealed class TeleportAnimSequencerTests
} }
[Fact] [Fact]
public void Snapshot_DefaultsAreOff_ClearAlpha() public void Snapshot_DefaultsAreOff_WithGameViewPlane()
{ {
var snap = new TeleportAnimSnapshot( 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(TeleportAnimState.Off, snap.State);
Assert.Equal(0f, snap.FadeAlpha); Assert.Equal(0f, snap.ViewPlaneBlend);
Assert.False(snap.ShowTunnel); Assert.False(snap.ShowTunnel);
Assert.False(snap.ShowPleaseWait); 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 --- // --- Task 1.2: Begin(), IsActive, initial state ---
[Theory] [Theory]
@ -285,20 +301,20 @@ public sealed class TeleportAnimSequencerTests
Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts); Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts);
} }
// --- Task 1.4: FadeAlpha endpoints and monotonicity --- // --- Retail view-plane blend endpoints and monotonicity ---
[Fact] [Fact]
public void FadeAlpha_IsZeroAtStart_OfWorldFadeOut() public void ViewPlaneBlend_IsZeroAtStart_OfWorldFadeOut()
{ {
var seq = new TeleportAnimSequencer(); var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout); seq.Begin(TeleportEntryKind.Logout);
var (snap, _) = seq.Tick(dt: 0f, worldReady: false); var (snap, _) = seq.Tick(dt: 0f, worldReady: false);
// At elapsed=0 in WorldFadeOut: retail animation level 0 => fully visible. // At elapsed=0 the override still equals the ordinary game distance.
Assert.Equal(0f, snap.FadeAlpha, precision: 4); Assert.Equal(0f, snap.ViewPlaneBlend, precision: 4);
} }
[Fact] [Fact]
public void FadeAlpha_IsOneAtEnd_OfWorldFadeOut() public void ViewPlaneBlend_IsOneAtEnd_OfWorldFadeOut()
{ {
var seq = new TeleportAnimSequencer(); var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout); seq.Begin(TeleportEntryKind.Logout);
@ -309,12 +325,14 @@ public sealed class TeleportAnimSequencerTests
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State); Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
var (snap, _) = seq.Tick(0f, worldReady: false); var (snap, _) = seq.Tick(0f, worldReady: false);
// The retail animation table should be close to fully opaque here. // The retail animation table should be close to the transition distance here.
Assert.True(snap.FadeAlpha > 0.95f, $"Expected FadeAlpha near 1, got {snap.FadeAlpha}"); Assert.True(
snap.ViewPlaneBlend > 0.95f,
$"Expected ViewPlaneBlend near 1, got {snap.ViewPlaneBlend}");
} }
[Fact] [Fact]
public void FadeAlpha_IsMonotonicallyIncreasing_DuringWorldFadeOut() public void ViewPlaneBlend_IsMonotonicallyIncreasing_DuringWorldFadeOut()
{ {
var seq = new TeleportAnimSequencer(); var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout); seq.Begin(TeleportEntryKind.Logout);
@ -327,23 +345,23 @@ public sealed class TeleportAnimSequencerTests
{ {
var (snap, _) = seq.Tick(step, worldReady: false); var (snap, _) = seq.Tick(step, worldReady: false);
if (seq.State != TeleportAnimState.WorldFadeOut) break; if (seq.State != TeleportAnimState.WorldFadeOut) break;
Assert.True(snap.FadeAlpha >= prev - 0.001f, Assert.True(snap.ViewPlaneBlend >= prev - 0.001f,
$"Alpha decreased: {prev} -> {snap.FadeAlpha} at step {i}"); $"View-plane blend decreased: {prev} -> {snap.ViewPlaneBlend} at step {i}");
prev = snap.FadeAlpha; prev = snap.ViewPlaneBlend;
} }
} }
[Fact] [Fact]
public void FadeAlpha_IsZero_DuringTunnelStates() public void ViewPlaneBlend_IsZero_DuringStableTunnelStates()
{ {
var seq = new TeleportAnimSequencer(); var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal); seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false); 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); var (snap, _) = seq.Tick(0.016f, worldReady: false);
Assert.Equal(TeleportAnimState.Tunnel, seq.State); Assert.Equal(TeleportAnimState.Tunnel, seq.State);
Assert.Equal(0f, snap.FadeAlpha); Assert.Equal(0f, snap.ViewPlaneBlend);
} }
[Fact] [Fact]
@ -372,6 +390,30 @@ public sealed class TeleportAnimSequencerTests
Assert.True(snap3.ShowTunnel); 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] [Fact]
public void ShowPleaseWait_TrueOnlyInTunnelContinue() public void ShowPleaseWait_TrueOnlyInTunnelContinue()
{ {