feat(teleport C): TAS-driven fade transit + retire TeleportArrivalController
Wires the dormant TeleportAnimSequencer as the transit driver: on PlayerTeleport the player holds in PortalSpace behind a full-screen fade (FadeOverlay) until the destination terrain is resident (TeleportWorldReady, gated on the priority-applied landblock), then materializes (Place), and after the world fades back in regains control + acks the server (FireLoginComplete). No movement resolves against the empty world, so the outbound cell frame can't corrupt. Outdoor changes from place-immediately back to hold-until-resident (now fast, not a band-aid). - FadeOverlay: fullscreen NDC black quad, alpha = ShowTunnel ? 1 : FadeAlpha. - Retires TeleportArrivalController + its 2 tests (TAS subsumes the driver role). - Divergence register: AD-2 updated to the new mechanism; AD-31 (fade vs swirl). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9ac719424c
commit
3ce1fae332
7 changed files with 237 additions and 407 deletions
|
|
@ -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 |
|
| # | 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:** 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-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) |
|
||||||
|
|
@ -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-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-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-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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
121
src/AcDream.App/Rendering/FadeOverlay.cs
Normal file
121
src/AcDream.App/Rendering/FadeOverlay.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
using System;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="AcDream.Core.World.TeleportAnimSequencer"/> 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.
|
||||||
|
///
|
||||||
|
/// <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). Must be called LAST in the frame, after the world and UI, so it covers
|
||||||
|
/// everything on screen.
|
||||||
|
/// </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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -171,6 +171,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// each frame on an indoor root (null on the outdoor root).
|
// each frame on an indoor root (null on the outdoor root).
|
||||||
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.FadeOverlay? _fadeOverlay; // teleport fade cover (spec C)
|
||||||
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
|
||||||
|
|
@ -2211,6 +2212,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
||||||
|
|
@ -5462,29 +5464,44 @@ public sealed class GameWindow : IDisposable
|
||||||
newWorldPos = worldPos;
|
newWorldPos = worldPos;
|
||||||
}
|
}
|
||||||
|
|
||||||
// G.3a: do NOT snap here. The destination dungeon landblock has not
|
// Retail "pink-bubble" transit: do NOT snap here. Record the destination and
|
||||||
// streamed in yet; an immediate Resolve falls back to the resident
|
// PRIORITIZE its landblock in streaming so it applies ahead of the per-frame
|
||||||
// (old) landblocks and lands the player in ocean (#133). HOLD the snap
|
// budget (residency in ~hundreds of ms, not 10-14s). The TAS — ticked per frame
|
||||||
// in portal space — TeleportArrivalController.Tick (per frame) places
|
// in OnUpdate — holds the player in PortalSpace behind the fade until the
|
||||||
// the player via PlaceTeleportArrival once the destination cell
|
// destination terrain is resident (TeleportWorldReady), then fires Place. While
|
||||||
// hydrates (TeleportArrivalReadiness == Ready), or force-places on an
|
// held, no movement resolve runs, so the outbound cell frame can't corrupt.
|
||||||
// impossible claim / timeout. PortalSpace keeps input frozen meanwhile.
|
_pendingTeleportRot = rot;
|
||||||
EnsureTeleportArrivalController();
|
_pendingTeleportPos = newWorldPos;
|
||||||
_pendingTeleportRot = rot;
|
_pendingTeleportCell = p.LandblockId;
|
||||||
_teleportArrival!.BeginArrival(newWorldPos, 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(
|
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
||||||
"AIM", p.LandblockId,
|
"AIM", p.LandblockId,
|
||||||
$"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}");
|
$"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// G.3a (#133): holds a teleport arrival in portal space until the destination
|
// Retail teleport transit: the dormant 7-state TAS drives the fade cover, holds the
|
||||||
// dungeon landblock/cell has hydrated, then places the player via the unchanged
|
// player in PortalSpace until the destination is resident (TeleportWorldReady), then
|
||||||
// validated-claim Resolve path. Lazily constructed on the first teleport (all
|
// fires Place (materialize) and FireLoginComplete (regain control + ack the server).
|
||||||
// runtime deps are wired by then).
|
// Replaces the old TeleportArrivalController hold/place machine.
|
||||||
private AcDream.App.World.TeleportArrivalController? _teleportArrival;
|
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;
|
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
|
// #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,
|
||||||
|
|
@ -5501,35 +5518,21 @@ public sealed class GameWindow : IDisposable
|
||||||
return worldPos - origin;
|
return worldPos - origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureTeleportArrivalController()
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private bool TeleportWorldReady(uint destCell)
|
||||||
{
|
{
|
||||||
if (_teleportArrival is not null) return;
|
if (IsSpawnClaimUnhydratable(destCell)) return true;
|
||||||
_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).
|
|
||||||
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
||||||
bool indoorCellReady = indoor && !claimUnhydratable
|
return indoor
|
||||||
&& _physicsEngine.IsSpawnCellReady(destCell);
|
? _physicsEngine.IsSpawnCellReady(destCell)
|
||||||
|
: _physicsEngine.IsLandblockTerrainResident(destCell);
|
||||||
return AcDream.App.World.TeleportArrivalRules.Decide(
|
|
||||||
claimUnhydratable, indoor, indoorCellReady);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
// 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,
|
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
|
||||||
dt: 1f / 60f);
|
dt: 1f / 60f);
|
||||||
|
|
||||||
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
|
|
||||||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
||||||
"PLACED", resolved.CellId, $"forced={forced}");
|
"PLACED", resolved.CellId, $"forced={forced}");
|
||||||
Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
|
// 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
|
||||||
// Tell the server the client finished loading the new landblock (holtburger
|
// and fires FireLoginComplete (which regains control + acks the server). This is the
|
||||||
// client/messages.rs:434 — re-send LoginComplete after each portal transition).
|
// retail "pop out the other side" sequence.
|
||||||
_liveSession?.SendGameAction(
|
Console.WriteLine($"live: teleport materialized — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
|
||||||
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751).
|
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). Freeze movement
|
||||||
/// Freeze movement input by setting the player controller to PortalSpace.
|
/// input (PortalSpace) and begin the retail fade transit. The per-frame TAS tick holds
|
||||||
/// The controller's Update() will return a zero-movement result until the
|
/// the player behind the fade until the destination is resident, then materializes them
|
||||||
/// destination UpdatePosition arrives and OnLivePositionUpdated resets the
|
/// (Place) and, after the world fades back in, regains control + acks the server
|
||||||
/// state to InWorld.
|
/// (FireLoginComplete).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void OnTeleportStarted(uint sequence)
|
private void OnTeleportStarted(uint sequence)
|
||||||
{
|
{
|
||||||
if (_playerController is not null)
|
if (_playerController is not null)
|
||||||
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
|
_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})");
|
Console.WriteLine($"live: teleport started (seq={sequence})");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7573,11 +7577,52 @@ 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();
|
||||||
|
|
||||||
// G.3a (#133): advance any held teleport arrival. Runs AFTER streaming
|
// Retail teleport transit. Runs AFTER streaming (which priority-applies the
|
||||||
// (which applies the destination landblock) and the live-session drain
|
// destination landblock this frame) and the live-session drain, so a destination
|
||||||
// (which may have just called BeginArrival), so a destination that
|
// that became resident this frame materializes the same frame. The TAS holds at
|
||||||
// hydrated this frame is placed the same frame.
|
// Tunnel until worldReady, fires Place (materialize, hidden), then after the min-
|
||||||
_teleportArrival?.Tick();
|
// 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
|
// Phase K.1a — tick the input dispatcher so Hold-type bindings
|
||||||
// re-fire while their chord is held. K.1b adds the subscribers
|
// 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));
|
_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
|
// Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws
|
||||||
// so ImGui composites on top. ImGuiController save/restores the
|
// so ImGui composites on top. ImGuiController save/restores the
|
||||||
// GL state it touches (blend, scissor, VAO, shader, texture); any
|
// GL state it touches (blend, scissor, VAO, shader, texture); any
|
||||||
|
|
@ -13072,6 +13122,7 @@ 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
|
||||||
_samplerCache?.Dispose();
|
_samplerCache?.Dispose();
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,8 @@ public static class DungeonStreamingGate
|
||||||
// must follow the DESTINATION, which the PortalSpace observer pin already does, so
|
// 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
|
// 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
|
// streaming collapsed on the source dungeon (CurrCell still sealed) → the outdoor
|
||||||
// destination never hydrates → TeleportArrivalReadiness holds 600 frames → force-
|
// destination never hydrates → the TAS transit holds 600 frames → force-snap to
|
||||||
// snap to ocean. A teleport INTO a dungeon is handled explicitly upstream by
|
// ocean. A teleport INTO a dungeon is handled explicitly upstream by
|
||||||
// StreamingController.PreCollapseToDungeon (and the controller's _collapsed latch
|
// StreamingController.PreCollapseToDungeon (and the controller's _collapsed latch
|
||||||
// holds it through the hold), so suppressing the gate here doesn't regress it.
|
// holds it through the hold), so suppressing the gate here doesn't regress it.
|
||||||
if (isTeleportHold)
|
if (isTeleportHold)
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Numerics;
|
|
||||||
|
|
||||||
namespace AcDream.App.World;
|
|
||||||
|
|
||||||
/// <summary>Verdict from the per-frame readiness probe for a held teleport arrival.</summary>
|
|
||||||
public enum ArrivalReadiness
|
|
||||||
{
|
|
||||||
/// <summary>Destination not yet hydrated; keep holding.</summary>
|
|
||||||
NotReady,
|
|
||||||
|
|
||||||
/// <summary>Destination terrain + cell are ready; place now.</summary>
|
|
||||||
Ready,
|
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
Impossible,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Lifecycle of a single teleport arrival.</summary>
|
|
||||||
public enum TeleportArrivalPhase { Idle, Holding }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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 <c>GameWindow.OnLivePositionUpdated</c> that resolved the
|
|
||||||
/// arrival against the resident (old) landblocks before the destination hydrated
|
|
||||||
/// and landed the player in ocean.
|
|
||||||
///
|
|
||||||
/// <para>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
|
|
||||||
/// <c>PlayerState.PortalSpace</c> until the placement delegate flips it back to
|
|
||||||
/// InWorld.</para>
|
|
||||||
///
|
|
||||||
/// <para>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.</para>
|
|
||||||
/// </summary>
|
|
||||||
public sealed class TeleportArrivalController
|
|
||||||
{
|
|
||||||
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
|
|
||||||
public const int DefaultMaxHoldFrames = 600;
|
|
||||||
|
|
||||||
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
|
|
||||||
private readonly Action<Vector3, uint, bool> _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<Vector3, uint, ArrivalReadiness> readiness,
|
|
||||||
Action<Vector3, uint, bool> place,
|
|
||||||
int maxHoldFrames = DefaultMaxHoldFrames)
|
|
||||||
{
|
|
||||||
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
|
|
||||||
_place = place ?? throw new ArgumentNullException(nameof(place));
|
|
||||||
_maxHoldFrames = maxHoldFrames;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>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).</summary>
|
|
||||||
public void BeginArrival(Vector3 destPos, uint destCell)
|
|
||||||
{
|
|
||||||
_destPos = destPos;
|
|
||||||
_destCell = destCell;
|
|
||||||
_heldFrames = 0;
|
|
||||||
Phase = TeleportArrivalPhase.Holding;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Per-frame: evaluate readiness and place when ready / impossible / timed out.
|
|
||||||
/// No-op when Idle.</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pure readiness decision for a teleport arrival, factored out of
|
|
||||||
/// <c>GameWindow.TeleportArrivalReadiness</c> for testing.
|
|
||||||
/// </summary>
|
|
||||||
public static class TeleportArrivalRules
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Decide whether a held teleport arrival can place now.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="claimUnhydratable">The destination cell can never hydrate.</param>
|
|
||||||
/// <param name="indoor">Destination is an indoor cell (cell index ≥ 0x0100).</param>
|
|
||||||
/// <param name="indoorCellReady">For an indoor destination, the EnvCell floor has hydrated.</param>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<PlaceCall> 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<PlaceCall>();
|
|
||||||
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<PlaceCall>();
|
|
||||||
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<PlaceCall>();
|
|
||||||
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<PlaceCall>();
|
|
||||||
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<PlaceCall>();
|
|
||||||
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<PlaceCall>();
|
|
||||||
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<PlaceCall>();
|
|
||||||
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<PlaceCall>();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
using AcDream.App.World;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace AcDream.App.Tests.World;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The teleport-arrival readiness decision (#145/#138).
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>Unhydratable claim → place immediately via the caller's safety-net (Impossible).</item>
|
|
||||||
/// <item>Indoor (sealed dungeon / building interior) → hold until the EnvCell floor hydrates.</item>
|
|
||||||
/// <item>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.</item>
|
|
||||||
/// </list>
|
|
||||||
/// </summary>
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue