diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1142a081..7a6441f6 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -61,12 +61,12 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 30 rows +## 2. Adaptation (AD) — 31 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | -| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted). **#145 refinement:** an OUTDOOR teleport additionally requires the destination's OWN landblock to be resident (`_worldState.IsLoaded(destLandblock)`) 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 factored into `TeleportArrivalRules.Decide` | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportArrivalReadiness` ~5405 → `src/AcDream.App/World/TeleportArrivalController.cs` `TeleportArrivalRules.Decide`) (+ `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 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-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) | | AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | @@ -95,6 +95,7 @@ accepted-divergence entries (#96, #49, #50). | AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 | | AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` | | AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path | +| AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) | --- diff --git a/src/AcDream.App/Rendering/FadeOverlay.cs b/src/AcDream.App/Rendering/FadeOverlay.cs new file mode 100644 index 00000000..caca95fd --- /dev/null +++ b/src/AcDream.App/Rendering/FadeOverlay.cs @@ -0,0 +1,121 @@ +using System; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// Fullscreen black quad at a given alpha — the teleport fade cover (retail-teleport +/// spec §C, 2026-06-22). Drawn LAST in the frame (over the world + UI) so it covers +/// everything; the drives the alpha +/// (opaque-black through the transit, ramping the world back in on WorldFadeIn). The +/// authentic 3D portal-swirl is deferred — this black cover replaces it for now. +/// +/// Draws in NDC, so it needs no view/projection. Self-contained GL state +/// (feedback_render_self_contained_gl_state): sets blend + disables depth, restores the +/// frame-global convention (depth test/write on) on exit. +/// +public sealed class FadeOverlay : IDisposable +{ + private const string VertSrc = @"#version 430 core +layout(location = 0) in vec2 aPos; +void main() { gl_Position = vec4(aPos, 0.0, 1.0); }"; + + private const string FragSrc = @"#version 430 core +uniform float uAlpha; +out vec4 FragColor; +void main() { FragColor = vec4(0.0, 0.0, 0.0, uAlpha); }"; + + private readonly GL _gl; + private readonly uint _program; + private readonly uint _vao; + private readonly uint _vbo; + private readonly int _locAlpha; + + // Two triangles covering the whole NDC viewport. + private static readonly float[] QuadVerts = + { + -1f, -1f, 1f, -1f, 1f, 1f, + -1f, -1f, 1f, 1f, -1f, 1f, + }; + + public FadeOverlay(GL gl) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + + uint vs = Compile(ShaderType.VertexShader, VertSrc); + uint fs = Compile(ShaderType.FragmentShader, FragSrc); + _program = _gl.CreateProgram(); + _gl.AttachShader(_program, vs); + _gl.AttachShader(_program, fs); + _gl.LinkProgram(_program); + _gl.GetProgram(_program, ProgramPropertyARB.LinkStatus, out int linked); + if (linked == 0) + throw new InvalidOperationException($"FadeOverlay link failed: {_gl.GetProgramInfoLog(_program)}"); + _gl.DeleteShader(vs); + _gl.DeleteShader(fs); + + _locAlpha = _gl.GetUniformLocation(_program, "uAlpha"); + + _vao = _gl.GenVertexArray(); + _vbo = _gl.GenBuffer(); + _gl.BindVertexArray(_vao); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); + unsafe + { + fixed (float* v = QuadVerts) + _gl.BufferData(BufferTargetARB.ArrayBuffer, + (nuint)(QuadVerts.Length * sizeof(float)), v, BufferUsageARB.StaticDraw); + _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), (void*)0); + } + _gl.EnableVertexAttribArray(0); + _gl.BindVertexArray(0); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + } + + private uint Compile(ShaderType type, string src) + { + uint s = _gl.CreateShader(type); + _gl.ShaderSource(s, src); + _gl.CompileShader(s); + _gl.GetShader(s, ShaderParameterName.CompileStatus, out int ok); + if (ok == 0) + throw new InvalidOperationException($"FadeOverlay {type} compile failed: {_gl.GetShaderInfoLog(s)}"); + return s; + } + + /// + /// Draw the fullscreen black cover at (0 = clear → no-op, + /// 1 = opaque). Must be called LAST in the frame, after the world and UI, so it covers + /// everything on screen. + /// + public void Draw(float alpha) + { + if (alpha <= 0f) return; + alpha = Math.Clamp(alpha, 0f, 1f); + + // ---- set state (everything this draw depends on) ---- + _gl.Disable(EnableCap.DepthTest); + _gl.DepthMask(false); + _gl.Disable(EnableCap.CullFace); + _gl.Enable(EnableCap.Blend); + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + + _gl.UseProgram(_program); + _gl.Uniform1(_locAlpha, alpha); + _gl.BindVertexArray(_vao); + _gl.DrawArrays(PrimitiveType.Triangles, 0, 6); + _gl.BindVertexArray(0); + _gl.UseProgram(0); + + // ---- restore the frame-global convention ---- + _gl.DepthMask(true); + _gl.Enable(EnableCap.DepthTest); + } + + public void Dispose() + { + _gl.DeleteProgram(_program); + _gl.DeleteVertexArray(_vao); + _gl.DeleteBuffer(_vbo); + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9e9478c9..96abb8b3 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -171,6 +171,7 @@ public sealed class GameWindow : IDisposable // each frame on an indoor root (null on the outdoor root). private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; + private AcDream.App.Rendering.FadeOverlay? _fadeOverlay; // teleport fade cover (spec C) private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition; // Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain @@ -2211,6 +2212,7 @@ public sealed class GameWindow : IDisposable // T1: invisible portal depth writes (seal/punch) — retail // DrawPortalPolyInternal (Ghidra 0x0059bc90). _portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl); + _fadeOverlay = new AcDream.App.Rendering.FadeOverlay(_gl); } // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) @@ -5462,29 +5464,44 @@ public sealed class GameWindow : IDisposable newWorldPos = worldPos; } - // G.3a: do NOT snap here. The destination dungeon landblock has not - // streamed in yet; an immediate Resolve falls back to the resident - // (old) landblocks and lands the player in ocean (#133). HOLD the snap - // in portal space — TeleportArrivalController.Tick (per frame) places - // the player via PlaceTeleportArrival once the destination cell - // hydrates (TeleportArrivalReadiness == Ready), or force-places on an - // impossible claim / timeout. PortalSpace keeps input frozen meanwhile. - EnsureTeleportArrivalController(); - _pendingTeleportRot = rot; - _teleportArrival!.BeginArrival(newWorldPos, p.LandblockId); + // 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; + _teleportHoldFrames = 0; + _teleportForced = false; + if (_streamingController is not null) + _streamingController.PriorityLandblockId = + AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY); AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( "AIM", p.LandblockId, $"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}"); } } - // G.3a (#133): holds a teleport arrival in portal space until the destination - // dungeon landblock/cell has hydrated, then places the player via the unchanged - // validated-claim Resolve path. Lazily constructed on the first teleport (all - // runtime deps are wired by then). - private AcDream.App.World.TeleportArrivalController? _teleportArrival; + // Retail teleport transit: the dormant 7-state TAS drives the fade cover, holds the + // player in PortalSpace until the destination is resident (TeleportWorldReady), then + // fires Place (materialize) and FireLoginComplete (regain control + ack the server). + // Replaces the old TeleportArrivalController hold/place machine. + private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new(); + private bool _teleportInProgress; + private System.Numerics.Vector3 _pendingTeleportPos; + private uint _pendingTeleportCell; + private int _teleportHoldFrames; // frames waiting for residency (safety-net timeout) + private bool _teleportForced; // true when the safety-net timeout force-places + private float _teleportFadeAlpha; // 0 = clear, 1 = full black; consumed by the fade overlay private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity; + // ~10s at 60fps. Loud safety net for a destination that never streams (worker crash / + // corrupt dat / OOB coords) — mirrors the retired controller's 600-frame ceiling. Now + // rarely fires because priority-apply makes residency fast. + private const int TeleportMaxHoldFrames = 600; + // #145: the LANDBLOCK-relative (cell-local) position used to SEED the player // body's cell-relative CellPosition. This is the ONE place the streaming center // (_liveCenter) is allowed to touch the physics frame — at the placement seam, @@ -5501,35 +5518,21 @@ public sealed class GameWindow : IDisposable return worldPos - origin; } - private void EnsureTeleportArrivalController() + /// + /// worldReady for the TAS transit: is the player's teleport destination resident so we + /// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell + /// struct hydrating (#135); outdoor gates on the destination terrain landblock being + /// registered (priority-applied). An impossible claim (indoor cell id outside the dat's + /// NumCells) returns true so the TAS stops holding and the forced placement surfaces the + /// failure loudly rather than holding forever. + /// + private bool TeleportWorldReady(uint destCell) { - if (_teleportArrival is not null) return; - _teleportArrival = new AcDream.App.World.TeleportArrivalController( - readiness: TeleportArrivalReadiness, - place: PlaceTeleportArrival); - } - - // Reuses the #107 login readiness triplet (GameWindow.cs:1010-1024), evaluated - // against the teleport's (destPos, destCell): an impossible indoor claim short- - // circuits to immediate placement; otherwise hold until terrain is sampled and, - // for an indoor cell, the cell struct has hydrated. - private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness( - System.Numerics.Vector3 destPos, uint destCell) - { - bool claimUnhydratable = IsSpawnClaimUnhydratable(destCell); - - // #135: an INDOOR destination (sealed dungeon / building interior) gates on the - // EnvCell FLOOR hydrating. Retail places on the cell floor. An OUTDOOR destination - // places immediately on the server-authoritative position — see TeleportArrivalRules - // (#145: holding is futile because streaming doesn't progress during a PortalSpace - // hold; the stale source landblock is dropped at recenter so the resolve trusts the - // server cell until the destination streams in). + if (IsSpawnClaimUnhydratable(destCell)) return true; bool indoor = (destCell & 0xFFFFu) >= 0x0100u; - bool indoorCellReady = indoor && !claimUnhydratable - && _physicsEngine.IsSpawnCellReady(destCell); - - return AcDream.App.World.TeleportArrivalRules.Decide( - claimUnhydratable, indoor, indoorCellReady); + return indoor + ? _physicsEngine.IsSpawnCellReady(destCell) + : _physicsEngine.IsLandblockTerrainResident(destCell); } // The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only @@ -5563,29 +5566,30 @@ public sealed class GameWindow : IDisposable contactPlaneNormal: System.Numerics.Vector3.UnitZ, dt: 1f / 60f); - _playerController.State = AcDream.App.Input.PlayerState.InWorld; AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( "PLACED", resolved.CellId, $"forced={forced}"); - Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); - - // Tell the server the client finished loading the new landblock (holtburger - // client/messages.rs:434 — re-send LoginComplete after each portal transition). - _liveSession?.SendGameAction( - AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); + // Do NOT flip to InWorld or send LoginComplete here — the player materializes BEHIND + // the fade and stays input-frozen (PortalSpace) until the TAS fades the world back in + // and fires FireLoginComplete (which regains control + acks the server). This is the + // retail "pop out the other side" sequence. + Console.WriteLine($"live: teleport materialized — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); } /// - /// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). - /// Freeze movement input by setting the player controller to PortalSpace. - /// The controller's Update() will return a zero-movement result until the - /// destination UpdatePosition arrives and OnLivePositionUpdated resets the - /// state to InWorld. + /// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). Freeze movement + /// input (PortalSpace) and begin the retail fade transit. The per-frame TAS tick holds + /// the player behind the fade until the destination is resident, then materializes them + /// (Place) and, after the world fades back in, regains control + acks the server + /// (FireLoginComplete). /// private void OnTeleportStarted(uint sequence) { if (_playerController is not null) _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; - EnsureTeleportArrivalController(); + _teleportInProgress = true; + _teleportHoldFrames = 0; + _teleportForced = false; + _teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal); Console.WriteLine($"live: teleport started (seq={sequence})"); } @@ -7573,11 +7577,52 @@ public sealed class GameWindow : IDisposable // Step 2: routed through the controller; functionally identical. _liveSessionController?.Tick(); - // G.3a (#133): advance any held teleport arrival. Runs AFTER streaming - // (which applies the destination landblock) and the live-session drain - // (which may have just called BeginArrival), so a destination that - // hydrated this frame is placed the same frame. - _teleportArrival?.Tick(); + // Retail teleport transit. Runs AFTER streaming (which priority-applies the + // destination landblock this frame) and the live-session drain, so a destination + // that became resident this frame materializes the same frame. The TAS holds at + // Tunnel until worldReady, fires Place (materialize, hidden), then after the min- + // continue + fades fires FireLoginComplete (regain control + ack). The fade overlay + // is opaque black during the tunnel states (we render a fade, not the 3D swirl) and + // ramps the world back in on WorldFadeIn. + if (_teleportInProgress) + { + bool haveDest = _pendingTeleportCell != 0u; + bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell); + if (haveDest && !ready && ++_teleportHoldFrames >= TeleportMaxHoldFrames) + { + ready = true; + _teleportForced = true; + } + + var (snap, evts) = _teleportAnim.Tick((float)dt, ready); + _teleportFadeAlpha = snap.ShowTunnel ? 1f : snap.FadeAlpha; + + foreach (var e in evts) + { + switch (e) + { + case AcDream.Core.World.TeleportAnimEvent.Place: + PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced); + if (_streamingController is not null) + _streamingController.PriorityLandblockId = 0u; + break; + case AcDream.Core.World.TeleportAnimEvent.FireLoginComplete: + if (_playerController is not null) + _playerController.State = AcDream.App.Input.PlayerState.InWorld; + // holtburger client/messages.rs:434 — re-send LoginComplete after + // each portal transition. + _liveSession?.SendGameAction( + AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); + _teleportInProgress = false; + _pendingTeleportCell = 0u; + _teleportFadeAlpha = 0f; + break; + default: + // PlayEnterSound / EnterTunnel / PlayExitSound — audio polish deferred. + break; + } + } + } // Phase K.1a — tick the input dispatcher so Hold-type bindings // re-fire while their chord is held. K.1b adds the subscribers @@ -9019,6 +9064,11 @@ public sealed class GameWindow : IDisposable _uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); } + // Teleport fade cover (retail-teleport spec C). Drawn AFTER the world + retail UI so + // it covers them during a transit; the ImGui devtools below composite on top so they + // stay visible for debugging. Alpha is 0 outside a teleport → Draw is a no-op. + _fadeOverlay?.Draw(_teleportFadeAlpha); + // Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws // so ImGui composites on top. ImGuiController save/restores the // GL state it touches (blend, scissor, VAO, shader, texture); any @@ -13072,6 +13122,7 @@ public sealed class GameWindow : IDisposable _wbDrawDispatcher?.Dispose(); _envCellRenderer?.Dispose(); // Phase A8 _portalDepthMask?.Dispose(); // T1 + _fadeOverlay?.Dispose(); _clipFrame?.Dispose(); // Phase U.3 _skyRenderer?.Dispose(); // depends on sampler cache; dispose first _samplerCache?.Dispose(); diff --git a/src/AcDream.App/Streaming/DungeonStreamingGate.cs b/src/AcDream.App/Streaming/DungeonStreamingGate.cs index ee87a6c7..86aaeb5d 100644 --- a/src/AcDream.App/Streaming/DungeonStreamingGate.cs +++ b/src/AcDream.App/Streaming/DungeonStreamingGate.cs @@ -37,8 +37,8 @@ public static class DungeonStreamingGate // must follow the DESTINATION, which the PortalSpace observer pin already does, so // the source-cell gate is suppressed. Otherwise a teleport OUT of a dungeon keeps // streaming collapsed on the source dungeon (CurrCell still sealed) → the outdoor - // destination never hydrates → TeleportArrivalReadiness holds 600 frames → force- - // snap to ocean. A teleport INTO a dungeon is handled explicitly upstream by + // destination never hydrates → the TAS transit holds 600 frames → force-snap to + // ocean. A teleport INTO a dungeon is handled explicitly upstream by // StreamingController.PreCollapseToDungeon (and the controller's _collapsed latch // holds it through the hold), so suppressing the gate here doesn't regress it. if (isTeleportHold) diff --git a/src/AcDream.App/World/TeleportArrivalController.cs b/src/AcDream.App/World/TeleportArrivalController.cs deleted file mode 100644 index eeddf858..00000000 --- a/src/AcDream.App/World/TeleportArrivalController.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System; -using System.Numerics; - -namespace AcDream.App.World; - -/// Verdict from the per-frame readiness probe for a held teleport arrival. -public enum ArrivalReadiness -{ - /// Destination not yet hydrated; keep holding. - NotReady, - - /// Destination terrain + cell are ready; place now. - Ready, - - /// The claim can never hydrate (e.g. an indoor cell id outside the dat's - /// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net - /// demote rather than hold forever. - Impossible, -} - -/// Lifecycle of a single teleport arrival. -public enum TeleportArrivalPhase { Idle, Holding } - -/// -/// G.3a (#133) — holds a teleport arrival in portal space until the destination -/// dungeon landblock/cell has streamed in, THEN places the player. Replaces the -/// unconditional snap in GameWindow.OnLivePositionUpdated that resolved the -/// arrival against the resident (old) landblocks before the destination hydrated -/// and landed the player in ocean. -/// -/// The controller is pure: readiness and placement are injected delegates, -/// so it carries no GL / dat / network dependency and is fully unit-testable. The -/// player stays input-frozen while this is Holding because the GameWindow keeps -/// PlayerState.PortalSpace until the placement delegate flips it back to -/// InWorld. -/// -/// The timeout is a coarse frame count (not wall-clock) so the controller -/// needs no external clock; it is a loud safety net for a never-hydrating -/// destination, not a precise deadline. -/// -public sealed class TeleportArrivalController -{ - /// ~10 s at 60 fps. Coarse safety net for a destination that never streams. - public const int DefaultMaxHoldFrames = 600; - - private readonly Func _readiness; - private readonly Action _place; // (destPos, destCell, forced) - private readonly int _maxHoldFrames; - - private Vector3 _destPos; - private uint _destCell; - private int _heldFrames; - - public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle; - - public TeleportArrivalController( - Func readiness, - Action place, - int maxHoldFrames = DefaultMaxHoldFrames) - { - _readiness = readiness ?? throw new ArgumentNullException(nameof(readiness)); - _place = place ?? throw new ArgumentNullException(nameof(place)); - _maxHoldFrames = maxHoldFrames; - } - - /// Begin holding for a teleport arrival. Called from OnLivePositionUpdated - /// AFTER the streaming origin has been recentered on the destination landblock. - /// Re-calling with a fresh server position resets the hold (server-authoritative). - public void BeginArrival(Vector3 destPos, uint destCell) - { - _destPos = destPos; - _destCell = destCell; - _heldFrames = 0; - Phase = TeleportArrivalPhase.Holding; - } - - /// Per-frame: evaluate readiness and place when ready / impossible / timed out. - /// No-op when Idle. - public void Tick() - { - if (Phase != TeleportArrivalPhase.Holding) return; - _heldFrames++; - - ArrivalReadiness verdict = _readiness(_destPos, _destCell); - if (verdict == ArrivalReadiness.Ready) - { - Place(forced: false); - return; - } - - if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames) - { - Place(forced: true); - } - // else NotReady -> keep holding - } - - private void Place(bool forced) - { - // Flip to Idle BEFORE invoking the placement delegate so the machine - // reflects "done holding" even if the delegate were to re-enter Tick. - Phase = TeleportArrivalPhase.Idle; - _place(_destPos, _destCell, forced); - } -} - -/// -/// Pure readiness decision for a teleport arrival, factored out of -/// GameWindow.TeleportArrivalReadiness for testing. -/// -public static class TeleportArrivalRules -{ - /// - /// Decide whether a held teleport arrival can place now. - /// - /// The destination cell can never hydrate. - /// Destination is an indoor cell (cell index ≥ 0x0100). - /// For an indoor destination, the EnvCell floor has hydrated. - public static ArrivalReadiness Decide( - bool claimUnhydratable, - bool indoor, - bool indoorCellReady) - { - if (claimUnhydratable) - return ArrivalReadiness.Impossible; - - // Indoor (sealed dungeon / building interior): gate on the EnvCell floor hydrating, - // exactly as #135. The dungeon-IN path pre-collapses + waits for the cell struct, and - // its placement validates the specific claimed cell (the indoor resolve is cell-keyed, - // not the grid-snap), so it is unaffected by the overlap that broke teleport-OUT. - if (indoor) - return indoorCellReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady; - - // Outdoor: place IMMEDIATELY on the server-authoritative position. #145/#138 — holding - // for the destination to stream in is futile: streaming does NOT progress while the - // player is held in PortalSpace (the destination only loads once placement flips the - // state to InWorld). The stale source center landblock is dropped at recenter (see - // GameWindow.OnLivePositionUpdated), so the arrival resolve falls through to the - // server-authoritative cell/position (Resolve NO-LANDBLOCK verbatim) and the player is - // placed grounded there; streaming then loads the destination and the per-frame resolve - // grounds onto it. This trusts the server's teleport position — retail-faithful. - return ArrivalReadiness.Ready; - } -} diff --git a/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs b/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs deleted file mode 100644 index 54a23f2f..00000000 --- a/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.World; -using Xunit; - -namespace AcDream.App.Tests.World; - -public class TeleportArrivalControllerTests -{ - // Records each Place(destPos, destCell, forced) call. - private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced); - - private static TeleportArrivalController Make( - ArrivalReadiness verdict, - List placed, - int maxHoldFrames = TeleportArrivalController.DefaultMaxHoldFrames) - => new( - readiness: (_, _) => verdict, - place: (pos, cell, forced) => placed.Add(new PlaceCall(pos, cell, forced)), - maxHoldFrames: maxHoldFrames); - - [Fact] - public void BeginArrival_EntersHolding() - { - var placed = new List(); - var c = Make(ArrivalReadiness.NotReady, placed); - - c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); - - Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); - Assert.Empty(placed); - } - - [Fact] - public void Tick_WhenIdle_IsNoOp() - { - var placed = new List(); - var c = Make(ArrivalReadiness.Ready, placed); - - c.Tick(); // never began - - Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); - Assert.Empty(placed); - } - - [Fact] - public void Tick_NotReady_KeepsHolding_DoesNotPlace() - { - var placed = new List(); - var c = Make(ArrivalReadiness.NotReady, placed); - c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); - - c.Tick(); - c.Tick(); - - Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); - Assert.Empty(placed); - } - - [Fact] - public void Tick_Ready_PlacesUnforced_AndIdles() - { - var placed = new List(); - var c = Make(ArrivalReadiness.Ready, placed); - c.BeginArrival(new Vector3(30, -60, 6.005f), 0x01250126u); - - c.Tick(); - - Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); - var call = Assert.Single(placed); - Assert.False(call.Forced); - Assert.Equal(0x01250126u, call.Cell); - Assert.Equal(new Vector3(30, -60, 6.005f), call.Pos); - } - - [Fact] - public void Tick_Impossible_PlacesForced_AndIdles() - { - var placed = new List(); - var c = Make(ArrivalReadiness.Impossible, placed); - c.BeginArrival(new Vector3(1, 2, 3), 0x0125FF00u); - - c.Tick(); - - Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); - var call = Assert.Single(placed); - Assert.True(call.Forced); - } - - [Fact] - public void Tick_Timeout_PlacesForced_AfterMaxHoldFrames() - { - var placed = new List(); - var c = Make(ArrivalReadiness.NotReady, placed, maxHoldFrames: 3); - c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); - - c.Tick(); // 1 - c.Tick(); // 2 - Assert.Empty(placed); - Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); - - c.Tick(); // 3 -> timeout - - var call = Assert.Single(placed); - Assert.True(call.Forced); - Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); - } - - [Fact] - public void BeginArrival_AfterPlace_ReArms() - { - var placed = new List(); - var c = Make(ArrivalReadiness.Ready, placed); - - c.BeginArrival(new Vector3(1, 0, 0), 0x01250126u); - c.Tick(); // places #1, idle - c.BeginArrival(new Vector3(2, 0, 0), 0x01250127u); - c.Tick(); // places #2, idle - - Assert.Equal(2, placed.Count); - Assert.Equal(0x01250127u, placed[1].Cell); - } - - [Fact] - public void BeginArrival_DuringHold_ResetsTimeoutCounter() - { - var placed = new List(); - var c = Make(ArrivalReadiness.NotReady, placed, maxHoldFrames: 3); - - c.BeginArrival(new Vector3(1, 0, 0), 0x01250126u); - c.Tick(); // held=1 - c.Tick(); // held=2 (one short of the timeout) - - // Re-arm mid-hold with a fresh destination: the counter must restart. - c.BeginArrival(new Vector3(2, 0, 0), 0x01250199u); - c.Tick(); // held=1 again (NOT 3 -> no placement yet) - c.Tick(); // held=2 - Assert.Empty(placed); - Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); - - c.Tick(); // held=3 -> timeout, forced place of the SECOND destination - var call = Assert.Single(placed); - Assert.True(call.Forced); - Assert.Equal(0x01250199u, call.Cell); - Assert.Equal(new Vector3(2, 0, 0), call.Pos); - } -} diff --git a/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs b/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs deleted file mode 100644 index eb0b12ea..00000000 --- a/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using AcDream.App.World; -using Xunit; - -namespace AcDream.App.Tests.World; - -/// -/// The teleport-arrival readiness decision (#145/#138). -/// -/// Unhydratable claim → place immediately via the caller's safety-net (Impossible). -/// Indoor (sealed dungeon / building interior) → hold until the EnvCell floor hydrates. -/// Outdoor → place IMMEDIATELY on the server-authoritative position. Holding is futile — -/// streaming does not progress while the player is held in PortalSpace, so the -/// destination can't stream in during a hold. The stale source landblock is dropped at -/// recenter (GameWindow.OnLivePositionUpdated), so the resolve falls through to the -/// server cell (Resolve NO-LANDBLOCK verbatim) until the destination streams in. -/// -/// -public class TeleportArrivalRulesTests -{ - [Fact] - public void Unhydratable_IsImpossible() - { - Assert.Equal(ArrivalReadiness.Impossible, - TeleportArrivalRules.Decide(claimUnhydratable: true, indoor: false, indoorCellReady: false)); - // …even for an indoor claim. - Assert.Equal(ArrivalReadiness.Impossible, - TeleportArrivalRules.Decide(claimUnhydratable: true, indoor: true, indoorCellReady: false)); - } - - [Fact] - public void Indoor_CellReady_IsReady() - { - Assert.Equal(ArrivalReadiness.Ready, - TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: true, indoorCellReady: true)); - } - - [Fact] - public void Indoor_CellNotReady_HoldsNotReady() - { - Assert.Equal(ArrivalReadiness.NotReady, - TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: true, indoorCellReady: false)); - } - - [Fact] - public void Outdoor_PlacesImmediately() - { - // #145: never hold an outdoor arrival — place at once on the server position. The - // indoorCellReady flag is irrelevant for an outdoor destination. - Assert.Equal(ArrivalReadiness.Ready, - TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: false, indoorCellReady: false)); - } -}