fix(streaming): #145 — teleport re-use via server-authoritative placement
Portals only worked once per session: teleporting OUT of a dungeon
mis-rooted the player into the SOURCE dungeon's coordinate frame, so every
move was sent dungeon-framed and ACE rejected it ("failed transition") —
the player couldn't move, never reached a portal, and the world wouldn't
re-render (only skybox).
Root cause: acdream's streaming-relative frame recenters on teleport, but
resident physics landblocks keep their load-time world-offset. After
recentering onto the outdoor destination, the collapsed source dungeon
(offset 0,0 as the prior center) and the destination (offset 0,0 as the
new center) overlap, and the Z-agnostic outdoor cell-snap returns the
dungeon for both the arrival placement and every per-frame resolve.
Fix (server-authoritative teleport placement):
- Drop the stale source center landblock from physics at the teleport
recenter (GameWindow.OnLivePositionUpdated) so the resolve falls through
to the server position (Resolve NO-LANDBLOCK verbatim) until the
destination streams in.
- Place outdoor teleports immediately (TeleportArrivalRules) — holding is
futile because streaming does not progress during a PortalSpace hold.
- Clear a dangling CellGraph.CurrCell when its landblock is removed
(PhysicsEngine.RemoveLandblock) — otherwise the dungeon-streaming gate
keeps streaming collapsed onto the gone dungeon (only skybox renders).
Keeps DungeonStreamingGate (gate suppression during the hold). Indoor
(dungeon-entry) placement is unchanged (cell-keyed, IsSpawnCellReady).
User-verified: in->out->re-enter works repeatedly, no ACE errors, world
renders. Remaining facets (server objects + own avatar not rendering after
a teleport-out) are entity render/lifecycle — split to #138.
Registers AP-36 + AD-2 updated. New: DungeonStreamingGate (+4 tests),
TeleportArrivalRules (+4 tests). Build + 2727 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f6a576af8e
commit
a15bd3b56d
9 changed files with 290 additions and 32 deletions
|
|
@ -5349,6 +5349,23 @@ public sealed class GameWindow : IDisposable
|
|||
System.Numerics.Vector3 newWorldPos;
|
||||
if (differentLandblock)
|
||||
{
|
||||
// #145: drop the stale SOURCE center landblock from the physics engine
|
||||
// BEFORE recentering. The destination loads at world-offset (0,0) (the new
|
||||
// center), but the prior center was ALSO loaded at offset (0,0) and its
|
||||
// offset is never re-based — so the two overlap, and the Z-agnostic outdoor
|
||||
// cell-snap (AdjustPosition / Resolve, iterating _landblocks) resolves the
|
||||
// player into the STALE source landblock (a dungeon's frame). That happens
|
||||
// for the arrival snap AND every per-frame resolve until the source finally
|
||||
// unloads, so outbound movement is sent in the dungeon's frame and the server
|
||||
// rejects every move as a failed transition (#145 / #138). Removing the stale
|
||||
// center here makes both resolves fall through to the server-authoritative
|
||||
// position (Resolve's NO-LANDBLOCK verbatim branch, :605) until the
|
||||
// destination streams in. Only the offset-(0,0) center can collide with the
|
||||
// destination-local position, so removing it alone is sufficient.
|
||||
uint staleCenterId = AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
|
||||
_liveCenterX, _liveCenterY);
|
||||
_physicsEngine.RemoveLandblock(staleCenterId);
|
||||
|
||||
// Recenter the streaming controller on the new landblock NOW (kick
|
||||
// off the dungeon load). After recentering, the destination is
|
||||
// (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin.
|
||||
|
|
@ -5405,23 +5422,20 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
|
||||
System.Numerics.Vector3 destPos, uint destCell)
|
||||
{
|
||||
if (IsSpawnClaimUnhydratable(destCell))
|
||||
return AcDream.App.World.ArrivalReadiness.Impossible;
|
||||
bool claimUnhydratable = IsSpawnClaimUnhydratable(destCell);
|
||||
|
||||
// #135: an INDOOR destination (sealed dungeon / building interior) gates on the
|
||||
// EnvCell FLOOR, not the terrain heightmap. A dungeon's negative-offset cells can
|
||||
// place destPos in a NEIGHBOUR terrain landblock the #135 collapse doesn't load,
|
||||
// so SampleTerrainZ would stay null forever (the cell IS ready). Retail places on
|
||||
// the cell floor. Outdoor: the terrain heightmap is the ground.
|
||||
// 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;
|
||||
if (indoor)
|
||||
return _physicsEngine.IsSpawnCellReady(destCell)
|
||||
? AcDream.App.World.ArrivalReadiness.Ready
|
||||
: AcDream.App.World.ArrivalReadiness.NotReady;
|
||||
bool indoorCellReady = indoor && !claimUnhydratable
|
||||
&& _physicsEngine.IsSpawnCellReady(destCell);
|
||||
|
||||
if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null)
|
||||
return AcDream.App.World.ArrivalReadiness.NotReady;
|
||||
return AcDream.App.World.ArrivalReadiness.Ready;
|
||||
return AcDream.App.World.TeleportArrivalRules.Decide(
|
||||
claimUnhydratable, indoor, indoorCellReady);
|
||||
}
|
||||
|
||||
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
||||
|
|
@ -7398,11 +7412,20 @@ public sealed class GameWindow : IDisposable
|
|||
// delayed the collapse and let the full 25×25 neighbor window churn in
|
||||
// first (the "~30s to stabilize" report). CurrCell.SeenOutside is set the
|
||||
// moment the player is placed, so the collapse now engages at the snap.
|
||||
bool insideDungeon = false;
|
||||
if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv
|
||||
&& !pcEnv.SeenOutside)
|
||||
// AP-36 / #145: during a teleport HOLD the player is unplaced and CurrCell is
|
||||
// the frozen SOURCE cell. Suppress the source-cell gate so a teleport OUT of a
|
||||
// dungeon follows the destination (the PortalSpace observer pin above) instead
|
||||
// of staying pinned to the source dungeon — see DungeonStreamingGate.
|
||||
bool isTeleportHold =
|
||||
_playerController is { State: AcDream.App.Input.PlayerState.PortalSpace };
|
||||
var currCell = _physicsEngine.DataCache?.CellGraph.CurrCell;
|
||||
bool currSealedDungeon =
|
||||
currCell is AcDream.Core.World.Cells.EnvCell pcEnv && !pcEnv.SeenOutside;
|
||||
var gate = AcDream.App.Streaming.DungeonStreamingGate.Compute(
|
||||
isTeleportHold, currSealedDungeon, currCell?.Id ?? 0u);
|
||||
bool insideDungeon = gate.InsideDungeon;
|
||||
if (gate.ObserverLandblockKey is { } cellLb)
|
||||
{
|
||||
insideDungeon = true;
|
||||
// Pin the collapse to the cell's OWN landblock (cell id high 16 bits),
|
||||
// NOT the position-derived observer landblock. A dungeon's EnvCells sit
|
||||
// at arbitrary world coords (the "ocean" placement) with negative local
|
||||
|
|
@ -7410,7 +7433,6 @@ public sealed class GameWindow : IDisposable
|
|||
// onto the WRONG landblock and unloads the real dungeon, nulling CurrCell
|
||||
// and breaking the render (the Bug-A coordinate class). The cell id is the
|
||||
// authoritative landblock.
|
||||
uint cellLb = pcEnv.Id >> 16;
|
||||
observerCx = (int)((cellLb >> 8) & 0xFFu);
|
||||
observerCy = (int)(cellLb & 0xFFu);
|
||||
}
|
||||
|
|
|
|||
51
src/AcDream.App/Streaming/DungeonStreamingGate.cs
Normal file
51
src/AcDream.App/Streaming/DungeonStreamingGate.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>Result of the per-frame dungeon streaming-gate decision.</summary>
|
||||
/// <param name="InsideDungeon">Passed to <see cref="StreamingController.Tick"/> — collapse
|
||||
/// streaming to the single dungeon landblock.</param>
|
||||
/// <param name="ObserverLandblockKey">When non-null, override the streaming observer to this
|
||||
/// landblock key (the cell id's high 16 bits, 0xXXYY). Null leaves the caller's observer as-is.</param>
|
||||
public readonly record struct DungeonGateResult(bool InsideDungeon, uint? ObserverLandblockKey);
|
||||
|
||||
/// <summary>
|
||||
/// AP-36: the dungeon streaming gate (#133 FPS). When the player stands in a SEALED
|
||||
/// EnvCell (an indoor cell that doesn't see outside), streaming collapses to the single
|
||||
/// dungeon landblock — AC dungeons have no adjacent landblocks, so the normal 25×25
|
||||
/// window would pull in ~129 unrelated ocean-grid dungeons. The trigger is the player's
|
||||
/// CURRENT cell (<c>CellGraph.CurrCell</c>, set the moment the player is placed), and the
|
||||
/// observer is pinned to that cell's OWN landblock (the cell id high 16 bits) because a
|
||||
/// dungeon's EnvCells sit at arbitrary ocean-grid world coords with negative local offsets.
|
||||
///
|
||||
/// <para>Extracted from <c>GameWindow.OnUpdate</c> as a pure function so the
|
||||
/// teleport-hold rule (below) is unit-testable without the GL/dat/network stack.</para>
|
||||
/// </summary>
|
||||
public static class DungeonStreamingGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Decide the streaming gate from the player's current cell.
|
||||
/// </summary>
|
||||
/// <param name="isTeleportHold">True while a teleport arrival is held (the controller is in
|
||||
/// PortalSpace): the player is NOT yet placed, so <paramref name="currCellId"/> is the frozen
|
||||
/// SOURCE cell, not where the player is going.</param>
|
||||
/// <param name="currCellIsSealedDungeon"><c>CurrCell is EnvCell && !SeenOutside</c>.</param>
|
||||
/// <param name="currCellId">The current cell id (0xXXYYNNNN).</param>
|
||||
public static DungeonGateResult Compute(
|
||||
bool isTeleportHold, bool currCellIsSealedDungeon, uint currCellId)
|
||||
{
|
||||
// #145/#138: during a teleport hold the player is NOT yet placed, so CurrCell is
|
||||
// the frozen SOURCE cell — where the player IS, not where they're going. Streaming
|
||||
// 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
|
||||
// StreamingController.PreCollapseToDungeon (and the controller's _collapsed latch
|
||||
// holds it through the hold), so suppressing the gate here doesn't regress it.
|
||||
if (isTeleportHold)
|
||||
return new DungeonGateResult(false, null);
|
||||
|
||||
if (currCellIsSealedDungeon)
|
||||
return new DungeonGateResult(true, currCellId >> 16);
|
||||
return new DungeonGateResult(false, null);
|
||||
}
|
||||
}
|
||||
|
|
@ -215,6 +215,8 @@ public sealed class StreamingController
|
|||
/// </summary>
|
||||
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
|
||||
{
|
||||
if (!_collapsed || _collapsedCenter != centerId)
|
||||
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
|
||||
_collapsed = true;
|
||||
_collapsedCenter = centerId;
|
||||
_clearPendingLoads?.Invoke();
|
||||
|
|
@ -263,6 +265,9 @@ public sealed class StreamingController
|
|||
/// </summary>
|
||||
private void ExitDungeonExpand(int observerCx, int observerCy)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
|
||||
$"(was collapsed on 0x{_collapsedCenter:X8})");
|
||||
_collapsed = false;
|
||||
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||||
|
||||
|
|
|
|||
|
|
@ -103,3 +103,42 @@ public sealed class TeleportArrivalController
|
|||
_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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,19 @@ public sealed class PhysicsEngine
|
|||
_landblocks.Remove(landblockId);
|
||||
ShadowObjects.RemoveLandblock(landblockId);
|
||||
|
||||
// #145: if the player's current cell belonged to the landblock being removed (a teleport
|
||||
// drops the stale source center via OnLivePositionUpdated), clear it. Otherwise CurrCell
|
||||
// dangles on an orphaned cell and the dungeon-streaming gate — keyed on CurrCell — keeps
|
||||
// streaming collapsed onto the gone landblock, so the destination never streams in and
|
||||
// only the skybox renders. Clearing it lets the gate read "not in a dungeon" → the
|
||||
// controller ExitDungeonExpands to the destination, and CurrCell re-acquires once the
|
||||
// destination landblock hydrates and the per-frame resolve roots into it.
|
||||
if (DataCache?.CellGraph is { } cg && cg.CurrCell is { } cur
|
||||
&& (cur.Id & 0xFFFF0000u) == (landblockId & 0xFFFF0000u))
|
||||
{
|
||||
cg.CurrCell = null;
|
||||
}
|
||||
|
||||
// UCG Stage 1: mirror removal into the unified graph (inert this stage).
|
||||
DataCache?.CellGraph.RemoveLandblock(landblockId);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue