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:
Erik 2026-06-20 21:38:00 +02:00
parent f6a576af8e
commit a15bd3b56d
9 changed files with 290 additions and 32 deletions

View file

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