From dd2eb8b39dd8dfb65cce1bbc98955d806feb8407 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 21:46:24 +0200 Subject: [PATCH] revert(teleport): drop the Slice 2 outdoor readiness-gate hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-tested: the Slice 2 'hold outdoor until landblock loaded' gate made EVERY outdoor teleport a ~10 s freeze, because the destination landblock does NOT load fast during the hold (lbs=0 the whole time — the #138 streaming gap + _datLock starvation from the CreateObject flood). The hold was band-aiding a broken/slow foundation rather than fixing it, and it never actually prevented the #145 edge cascade anyway (it force-snapped onto NO-LANDBLOCK after the timeout regardless). Reverts ad8c24e..c880973 to the pre-Slice-2 state (00ef47e): outdoor places immediately again (fast teleports). The genuine bug found along the way — IsLandblockLoaded queried the wrong key form (& 0xFFFF0000 vs the stored | 0xFFFF) — is preserved in the history (c880973) and will be re-applied when we re-introduce a proper hold ON A FIXED FOUNDATION. Decision (user, 2026-06-21): fix the foundation FIRST — fast/complete streaming during teleport (#138), the post-teleport lost-collision bug, and the FPS leak (Work item C) — then revisit the teleport-flow animation. Slice 1 (the pure TeleportAnimSequencer) stays in (dormant, unwired, harmless). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 33 ++----- .../World/TeleportArrivalController.cs | 26 +++--- .../Physics/PhysicsDiagnostics.cs | 14 --- src/AcDream.Core/Physics/PhysicsEngine.cs | 19 ---- .../World/TeleportArrivalRulesTests.cs | 56 +++++------- .../PhysicsEngineIsLandblockLoadedTests.cs | 90 ------------------- 6 files changed, 43 insertions(+), 195 deletions(-) delete mode 100644 tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 35514cdd..4320fbb8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5471,7 +5471,6 @@ public sealed class GameWindow : IDisposable // impossible claim / timeout. PortalSpace keeps input frozen meanwhile. EnsureTeleportArrivalController(); _pendingTeleportRot = rot; - _lastArrivalVerdict = AcDream.App.World.ArrivalReadiness.NotReady; // reset probe state for each new hold _teleportArrival!.BeginArrival(newWorldPos, p.LandblockId); } } @@ -5482,8 +5481,6 @@ public sealed class GameWindow : IDisposable // runtime deps are wired by then). private AcDream.App.World.TeleportArrivalController? _teleportArrival; private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity; - // §3.4 arrival-gate probe: tracks previous verdict so we log only the flip. - private AcDream.App.World.ArrivalReadiness _lastArrivalVerdict = AcDream.App.World.ArrivalReadiness.NotReady; // #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 @@ -5512,36 +5509,24 @@ public sealed class GameWindow : IDisposable // 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. For an outdoor cell, hold - // until the physics landblock is registered (§3.4 #145 residual fix). + // 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 indoorCellReady = indoor && !claimUnhydratable && _physicsEngine.IsSpawnCellReady(destCell); - // §3.4: for outdoor arrivals, gate on the physics landblock being resident. - // The landblock id is the high 16 bits of the cell id (i.e. destCell & 0xFFFF0000). - // Streaming progresses during the hold because the streaming controller runs before - // TeleportArrivalController.Tick. The 10 s _maxHoldFrames safety-net remains active. - bool outdoorReady = !indoor - && _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u); - var verdict = AcDream.App.World.TeleportArrivalRules.Decide( - claimUnhydratable, indoor, indoorCellReady, outdoorReady); - - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeArrivalGateEnabled - && verdict != _lastArrivalVerdict - && verdict != AcDream.App.World.ArrivalReadiness.NotReady) - { - Console.WriteLine(System.FormattableString.Invariant( - $"[arrival-gate] destCell=0x{destCell:X8} landblock=0x{(destCell & 0xFFFF0000u):X8} verdict={verdict} indoor={indoor} landblockCount={_physicsEngine.LandblockCount}")); - } - _lastArrivalVerdict = verdict; - - return verdict; + return AcDream.App.World.TeleportArrivalRules.Decide( + claimUnhydratable, indoor, indoorCellReady); } // The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only diff --git a/src/AcDream.App/World/TeleportArrivalController.cs b/src/AcDream.App/World/TeleportArrivalController.cs index 7263299b..eeddf858 100644 --- a/src/AcDream.App/World/TeleportArrivalController.cs +++ b/src/AcDream.App/World/TeleportArrivalController.cs @@ -116,29 +116,29 @@ public static class TeleportArrivalRules /// The destination cell can never hydrate. /// Destination is an indoor cell (cell index ≥ 0x0100). /// For an indoor destination, the EnvCell floor has hydrated. - /// For an outdoor destination, the physics landblock is loaded - /// ( returned true for the - /// destination landblock id). Ignored for indoor destinations. public static ArrivalReadiness Decide( bool claimUnhydratable, bool indoor, - bool indoorCellReady, - bool outdoorReady) + bool indoorCellReady) { if (claimUnhydratable) return ArrivalReadiness.Impossible; // Indoor (sealed dungeon / building interior): gate on the EnvCell floor hydrating, - // exactly as #135. Unaffected by the outdoor-landblock gate. + // 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 (#145 §3.4): hold until the physics landblock is registered. - // AddLandblock is atomic (terrain + cells + portals in one write — PhysicsEngine.cs:70), - // so ContainsKey implies the collision mesh is present and resolvable. The hold is - // self-resolving: streaming progresses unconditionally during the hold because the - // streaming controller runs before TeleportArrivalController.Tick (GameWindow.cs:7420-7551). - // The 10 s timeout safety-net (_maxHoldFrames) remains active. - return outdoorReady ? 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/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 51ee9977..06a3180c 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -31,19 +31,6 @@ public static class PhysicsDiagnostics public static bool ProbeResolveEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1"; - /// - /// Retail teleport readiness gate (§3.4, 2026-06-21). When true, every transition of - /// the TeleportArrivalReadiness verdict (NotReady → Ready / Impossible) emits one - /// [arrival-gate] line: the destination cell id, landblock id, verdict, - /// PhysicsEngine.LandblockCount at the moment it flips, and whether the - /// flip was forced (Impossible) or natural (Ready). - /// - /// Initial state from ACDREAM_PROBE_RESOLVE=1 — shares the existing probe - /// flag because this is a readiness-path extension; no new env var needed. - /// - public static bool ProbeArrivalGateEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1"; - /// /// When true, every change to PlayerMovementController.CellId /// emits one [cell-transit] line: old → new cell, current @@ -547,7 +534,6 @@ public static class PhysicsDiagnostics public static void ResetForTest() { ProbeResolveEnabled = false; - ProbeArrivalGateEnabled = false; ProbeCellEnabled = false; ProbeBuildingEnabled = false; ProbeCellSetEnabled = false; diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 078e8ea5..108b9234 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -31,25 +31,6 @@ public sealed class PhysicsEngine /// Number of registered landblocks (diagnostic). public int LandblockCount => _landblocks.Count; - /// - /// Returns true iff the landblock CONTAINING the given id has been registered via - /// . Because registers terrain + - /// collision cells + portals atomically, a hit implies the collision mesh is present - /// and resolvable. - /// - /// The argument may be any id within the landblock — a full cell id (low 16 = cell - /// index), the landblock-prefix form (low 16 = 0x0000, as the teleport gate passes via - /// destCell & 0xFFFF0000), or the dat-id storage form. The low 16 bits are - /// normalized to the canonical landblock key (0xFFFF) that streaming stores under - /// ( ORs in 0xFFFF), - /// so all three forms resolve. The earlier raw ContainsKey(id & 0xFFFF0000) - /// never matched the 0xFFFF-terminated stored key, leaving the outdoor teleport gate - /// permanently NotReady (2026-06-21 #145 apparatus re-test). - /// - /// - public bool IsLandblockLoaded(uint landblockId) => - _landblocks.ContainsKey((landblockId & 0xFFFF0000u) | 0xFFFFu); - /// /// Cell-based spatial index for static object collision. /// Populated during landblock streaming; queried by the Transition system. diff --git a/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs b/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs index a9602a9d..eb0b12ea 100644 --- a/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs +++ b/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs @@ -4,63 +4,49 @@ using Xunit; namespace AcDream.App.Tests.World; /// -/// Full truth-table for TeleportArrivalRules.Decide after the §3.4 readiness-gate change. +/// 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_Outdoor() + public void Unhydratable_IsImpossible() { Assert.Equal(ArrivalReadiness.Impossible, - TeleportArrivalRules.Decide( - claimUnhydratable: true, indoor: false, - indoorCellReady: false, outdoorReady: false)); - } - - [Fact] - public void Unhydratable_IsImpossible_Indoor() - { + TeleportArrivalRules.Decide(claimUnhydratable: true, indoor: false, indoorCellReady: false)); + // …even for an indoor claim. Assert.Equal(ArrivalReadiness.Impossible, - TeleportArrivalRules.Decide( - claimUnhydratable: true, indoor: true, - indoorCellReady: true, outdoorReady: false)); + 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, outdoorReady: false)); + TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: true, indoorCellReady: true)); } [Fact] - public void Indoor_CellNotReady_IsNotReady() + public void Indoor_CellNotReady_HoldsNotReady() { Assert.Equal(ArrivalReadiness.NotReady, - TeleportArrivalRules.Decide( - claimUnhydratable: false, indoor: true, - indoorCellReady: false, outdoorReady: false)); + TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: true, indoorCellReady: false)); } [Fact] - public void Outdoor_LandblockLoaded_IsReady() + 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, outdoorReady: true)); - } - - [Fact] - public void Outdoor_LandblockNotLoaded_IsNotReady() - { - // §3.4: the #145 residual — outdoor arrival on a not-yet-streamed landblock - // must hold. Previously returned Ready immediately. - Assert.Equal(ArrivalReadiness.NotReady, - TeleportArrivalRules.Decide( - claimUnhydratable: false, indoor: false, - indoorCellReady: false, outdoorReady: false)); + TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: false, indoorCellReady: false)); } } diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs deleted file mode 100644 index d32b5a6d..00000000 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -public class PhysicsEngineIsLandblockLoadedTests -{ - private static TerrainSurface MakeFlatTerrain() - { - var heights = new byte[81]; - Array.Fill(heights, (byte)50); - var heightTable = new float[256]; - for (int i = 0; i < 256; i++) heightTable[i] = i * 1f; - return new TerrainSurface(heights, heightTable); - } - - [Fact] - public void IsLandblockLoaded_AbsentId_ReturnsFalse() - { - var engine = new PhysicsEngine(); - Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu)); - } - - [Fact] - public void IsLandblockLoaded_AddedId_ReturnsTrue() - { - var engine = new PhysicsEngine(); - engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(), - Array.Empty(), Array.Empty(), - worldOffsetX: 0f, worldOffsetY: 0f); - - Assert.True(engine.IsLandblockLoaded(0xA9B4FFFFu)); - } - - [Fact] - public void IsLandblockLoaded_AnotherIdAbsent_ReturnsFalse() - { - var engine = new PhysicsEngine(); - engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(), - Array.Empty(), Array.Empty(), - worldOffsetX: 0f, worldOffsetY: 0f); - - Assert.False(engine.IsLandblockLoaded(0xDEADBEEFu)); - } - - [Fact] - public void IsLandblockLoaded_AfterRemove_ReturnsFalse() - { - var engine = new PhysicsEngine(); - engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(), - Array.Empty(), Array.Empty(), - worldOffsetX: 0f, worldOffsetY: 0f); - engine.RemoveLandblock(0xA9B4FFFFu); - - Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu)); - } - - // Regression for the Slice 2 key-mismatch bug (#145 apparatus re-test, 2026-06-21): - // landblocks are stored under the EncodeLandblockId form (low 16 = 0xFFFF), but the - // teleport gate (TeleportArrivalReadiness) queries with (destCell & 0xFFFF0000) (low - // 16 = 0x0000). A raw ContainsKey never matched, so the outdoor gate was permanently - // NotReady and every outdoor teleport timed out at 10 s. IsLandblockLoaded must - // normalize so ANY id within the landblock resolves to the stored entry. - [Theory] - [InlineData(0xA9B40000u)] // landblock-prefix form — exactly what the gate passes (destCell & 0xFFFF0000) - [InlineData(0xA9B4000Du)] // an outdoor cell id within the landblock - [InlineData(0xA9B40143u)] // an indoor cell id within the landblock - [InlineData(0xA9B4FFFFu)] // the dat-id storage form - public void IsLandblockLoaded_NormalizesLow16_FindsLandblockForAnyContainedId(uint queryId) - { - var engine = new PhysicsEngine(); - engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(), - Array.Empty(), Array.Empty(), - worldOffsetX: 0f, worldOffsetY: 0f); - - Assert.True(engine.IsLandblockLoaded(queryId)); - } - - [Fact] - public void IsLandblockLoaded_DifferentLandblockPrefix_ReturnsFalse() - { - var engine = new PhysicsEngine(); - engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(), - Array.Empty(), Array.Empty(), - worldOffsetX: 0f, worldOffsetY: 0f); - - // A different landblock prefix must NOT match, even in the 0x..0000 query form. - Assert.False(engine.IsLandblockLoaded(0xA9B50000u)); - } -}