From 2c923755c41eb7d42d6d3ccdeea114441fc8a618 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 17:13:12 +0200 Subject: [PATCH] fix(G.3): place the player on the cell floor for an indoor dungeon login (#135 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions from the pre-collapse (712f17f), found by live gate + a runtime probe: 1) Login-into-dungeon stopped loading the dungeon. The login-hold streaming observer fell through to the OFFLINE fly-camera branch once _lastLivePlayerLandblockId was filtered to the player guid (a dungeon-local NPC used to keep it pinned). A camera-derived observer far from the pre-collapsed dungeon tripped ExitDungeonExpand and unloaded it. Fix: a LIVE in-world session never uses the fly camera for the observer — it follows the player's server landblock, falling back to the recentered spawn center (_liveCenterX/Y). The fly camera is the OFFLINE observer only. 2) Even with the dungeon resident, auto-entry hung: the #106 "ground ready" gate required SampleTerrainZ under the spawn, but a dungeon's negative-offset cells place the spawn's WORLD position in a NEIGHBOUR terrain landblock the #135 collapse deliberately doesn't load (probe: cellReady=True, terrReady=False forever). The terrain gate is wrong for an indoor spawn — the player lands on the EnvCell FLOOR. Fix: gate an indoor (hydratable) spawn/teleport on IsSpawnCellReady, not the terrain heightmap; outdoor (and unhydratable→demote) spawns still hold on terrain. Applied to both isSpawnGroundReady (login auto- entry) and TeleportArrivalReadiness (teleport). This is the faithful equivalent of retail's synchronous cell load + place-on-floor; the pre-#135 terrain hold only passed because the 25x25 window streamed the neighbour terrain. Verified live: login into 0x0007 → auto-entered player mode, snapped to 0x00070145, dungeon renders, FPS steady. Register AD-2 amended. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 2 +- src/AcDream.App/Rendering/GameWindow.cs | 86 ++++++++++++++----- 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 080c4d01..8a9ddd3c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | -| AD-2 | Async spawn gates replacing retail's synchronous cell load: terrain-ready hold (**#106**) + indoor cell-hydration hold (**#107**, `IsSpawnCellReady`); claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs:1008` (+ `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 | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode | 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) | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportArrivalReadiness` ~5012) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) | | AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) | | AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d4729ab1..f9baef23 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1007,21 +1007,36 @@ public sealed class GameWindow : IDisposable // integrates gravity against an empty world and free-falls // the player into the void (retail loads cells synchronously; // this is the async-streaming equivalent of that invariant). - isSpawnGroundReady: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe) - && _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null - // #107 gate-2 extension (2026-06-10): an INDOOR spawn claim - // additionally waits for the claimed cell's hydration so the - // entry snap's AdjustPosition validation can act (retail loads - // the cell synchronously before SetPosition; this is the - // async-streaming equivalent). Claims that can never hydrate - // (id outside the landblock's NumCells range per the dat) - // don't hold the gate — the Resolve-head safety net demotes - // them loudly. - && (!_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp) - || sp.Position is not { } spawnClaim - || spawnClaim.LandblockId == 0 - || _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId) - || IsSpawnClaimUnhydratable(spawnClaim.LandblockId)), + isSpawnGroundReady: () => + { + if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false; + + // #107 / #135: spawn-ground readiness is spawn-claim aware. For an + // INDOOR claim (sealed dungeon / building interior) the ground the + // player lands on is the EnvCell FLOOR (its BSP), so gate on the + // cell's hydration (IsSpawnCellReady) — NOT the terrain heightmap. + // A dungeon's cells sit in their landblock at an arbitrary (often + // negative) offset, so the spawn's WORLD position can fall in a + // NEIGHBOUR terrain landblock that the #135 dungeon collapse + // deliberately does not load; requiring terrain there hangs login + // forever (cellReady true, SampleTerrainZ null). Retail loads the + // cell synchronously and places the player on the cell floor — + // cellReady is the faithful indoor equivalent (#106/#107, AD-2). + // (Before #135 this only passed by accident: the 25×25 window + // happened to stream the neighbour terrain.) + if (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp) + && sp.Position is { } spawnClaim + && spawnClaim.LandblockId != 0 + && (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u + && !IsSpawnClaimUnhydratable(spawnClaim.LandblockId)) + return _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId); + + // Outdoor spawn, OR an unhydratable indoor claim that will demote to + // an outdoor position: hold until the terrain under the spawn streams + // (the original #106 gate — entering against an empty world free-falls + // the player into the void). + return _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null; + }, enterPlayerMode: EnterPlayerModeFromAutoEntry); } @@ -5013,10 +5028,19 @@ public sealed class GameWindow : IDisposable { if (IsSpawnClaimUnhydratable(destCell)) return AcDream.App.World.ArrivalReadiness.Impossible; - if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null) - return AcDream.App.World.ArrivalReadiness.NotReady; + + // #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. bool indoor = (destCell & 0xFFFFu) >= 0x0100u; - if (indoor && !_physicsEngine.IsSpawnCellReady(destCell)) + if (indoor) + return _physicsEngine.IsSpawnCellReady(destCell) + ? AcDream.App.World.ArrivalReadiness.Ready + : AcDream.App.World.ArrivalReadiness.NotReady; + + if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null) return AcDream.App.World.ArrivalReadiness.NotReady; return AcDream.App.World.ArrivalReadiness.Ready; } @@ -6929,12 +6953,28 @@ public sealed class GameWindow : IDisposable observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); } else if (_liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld - && _lastLivePlayerLandblockId is { } lid) + && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld) { - // Live mode (fly camera): follow the server's last-known player position. - observerCx = (int)((lid >> 24) & 0xFFu); - observerCy = (int)((lid >> 16) & 0xFFu); + // Live, not yet in player mode: the login auto-entry hold, or a live + // fly-camera spectator. Follow the PLAYER's server-known landblock; if it + // hasn't arrived yet, KEEP the _liveCenterX/_liveCenterY default — which is + // the spawn/teleport recenter (the dungeon landblock at a dungeon login). + // + // #135 regression fix (2026-06-14): this MUST NOT fall through to the + // fly-camera projection below. During a dungeon-login hold the streaming is + // pre-collapsed onto the spawn landblock; a camera-derived observer far from + // it trips ExitDungeonExpand and unloads the dungeon before it can hydrate — + // the player is never placed and login hangs with no dungeon. Previously + // _lastLivePlayerLandblockId was set by ANY entity, so a dungeon-local NPC + // kept this branch on the dungeon; once it was filtered to the player guid + // (line ~4507), a not-yet-arrived player UP dropped to the camera branch. + // The fly camera is the OFFLINE observer only. + if (_lastLivePlayerLandblockId is { } lid) + { + observerCx = (int)((lid >> 24) & 0xFFu); + observerCy = (int)((lid >> 16) & 0xFFu); + } + // else: keep the _liveCenterX/_liveCenterY default (the spawn recenter). } else {