diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 2f5a9fe9..a18767c1 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -48,18 +48,25 @@ Copy this block when adding a new issue: ## #145 — Portals only work once per session (can't run in, run out, re-enter) -**Status:** OPEN +**Status:** CORE FIXED + user-verified 2026-06-20 (in→out→re-enter repeatedly works; no ACE errors; world renders). Remaining facets (server objects + own-avatar not rendering after a teleport-out) split to **#138** — they are entity render/lifecycle, a different subsystem from the cell-rooting bug this issue was. **Severity:** HIGH (blocks repeated portal/dungeon travel — user, 2026-06-20) **Filed:** 2026-06-20 -**Component:** net/streaming — teleport (0xF751) + PortalSpace + arrival +**Component:** net/streaming — teleport (0xF751) + PortalSpace + arrival + cell rooting -**Description (user, 2026-06-20):** A portal can only be used ONCE per session. The first teleport works (run into a portal); a subsequent portal use (run out again, or re-enter) does not. Goal: run into a portal, run out again, repeatedly — portals usable any number of times in one session. +**Description (user, 2026-06-20):** A portal can only be used ONCE per session. The first teleport works; a subsequent portal use (run out, or re-enter) does not. Goal: run in, run out, repeatedly. -**Root cause / status (UNVERIFIED):** Likely a stuck teleport-state issue — after the first 0xF751 PlayerTeleport (PortalSpace hold → `TeleportArrivalController` places → back to InWorld), some state isn't reset for the second teleport: candidates are PortalSpace not cleared, the arrival `Phase` stuck at `Holding`, a cell-membership/streaming gap, or the dungeon collapse↔expand state (#133/#135 machinery). Likely overlaps **#138** (teleport OUT of a dungeon loads incompletely) — may be the same root or a sibling. **First step:** instrument a SECOND teleport and find WHERE it stalls (does the 0xF751 arrive? does PortalSpace toggle? does `TeleportArrivalController.Tick` place? does the streaming window re-expand?). +**Root cause (CONFIRMED — code trace + live cdb-style [phys-lb] probe):** the teleport OUT of a dungeon mis-rooted the player into the SOURCE dungeon's coordinate frame, desyncing all movement from ACE. acdream uses a streaming-RELATIVE coordinate frame (positions relative to `_liveCenterX/Y`) and recenters on teleport, but **resident physics landblocks keep their load-time world-offset**. After recentering onto the outdoor destination, the collapsed source dungeon (loaded at offset (0,0) when it was the center) and the destination (also the new center → offset (0,0)) **overlap**, and the Z-agnostic outdoor cell-snap (`AdjustPosition`, iterating `_physicsEngine._landblocks`) returns the dungeon — for the arrival placement AND every per-frame resolve — so the player was rooted at a dungeon cell (`0x00070019`) at Holtburg's position. acdream then sent dungeon-frame positions; ACE (which knows the player is at Holtburg) rejected every one (`WARN: failed transition … to 0x0007…`), so the player couldn't move, never reached a portal, and ACE never re-broadcast the Holtburg objects. Teleport IN works because its placement is cell-keyed (indoor `FindVisibleChildCell` validates the specific claimed cell) and it pre-collapses; the OUTDOOR resolve is the grid-snap, which the overlap fools. -**Files:** `WorldSession.cs:999` (0xF751 handler); `PlayerMovementController.cs:75/840` (`PlayerState.PortalSpace`); `TeleportArrivalController.cs` (`Phase` Idle/Holding, the placement flip); `GameWindow.cs:4903/5332/5362` (PortalSpace observer + arrival placement); streaming collapse↔expand (see #138). +**Fix (2026-06-20) — server-authoritative teleport placement (user-approved approach):** +1. **Drop the stale source center landblock from physics at the teleport recenter** (`GameWindow.OnLivePositionUpdated`, `differentLandblock` branch → `_physicsEngine.RemoveLandblock(EncodeLandblockId(oldCenter))`). Only the offset-(0,0) center collides with the destination-local position, so removing it alone clears the overlap; the arrival + per-frame resolve then fall through to the server position (`PhysicsEngine.Resolve` NO-LANDBLOCK verbatim, `:605`) until the destination streams in. +2. **Place outdoor teleports immediately** (`TeleportArrivalRules.Decide` — outdoor → Ready). Holding is futile: streaming does NOT progress while the player is held in PortalSpace (the destination only loads once placement flips to InWorld). Indoor unchanged (`IsSpawnCellReady` hold). Gate suppression during the hold kept (`DungeonStreamingGate`). +3. **Clear a dangling `CellGraph.CurrCell` when its landblock is removed** (`PhysicsEngine.RemoveLandblock`). Without this, dropping the dungeon left CurrCell pointing at the orphaned dungeon cell; the dungeon-streaming gate (keyed on CurrCell) kept streaming collapsed onto the gone landblock, so the destination never streamed → only skybox. Clearing it lets the gate read "not in a dungeon" → `ExitDungeonExpand` → destination streams in → CurrCell re-acquires. -**Acceptance:** run into a portal, run out, re-enter — repeatedly in one session, each time placing + streaming correctly. +Tests: `DungeonStreamingGateTests` (4), `TeleportArrivalRulesTests` (4). Registers AP-36 + AD-2. Build + 2727 tests green. Edge logs added to `EnterDungeonCollapse`/`ExitDungeonExpand`. + +**Files:** `GameWindow.cs` `OnLivePositionUpdated` (drop stale center) + `TeleportArrivalReadiness`; `src/AcDream.App/World/TeleportArrivalController.cs` (`TeleportArrivalRules`); `src/AcDream.App/Streaming/DungeonStreamingGate.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`RemoveLandblock` CurrCell clear + NO-LANDBLOCK verbatim). + +**Acceptance:** run into a portal, run out, re-enter — repeatedly in one session, each time placing + streaming correctly. Verify the dungeon→outdoor exit completes promptly (log `teleport complete`, not `teleport HOLD gave up … force-snapping`) with `streaming: dungeon EXIT-expand`, the outdoor world fully streamed, collision working. Also re-checks **#138**. --- @@ -211,13 +218,15 @@ exit; (c) the position desync = the player controller / streaming observer disag post-exit world position (the avatar moves in one frame, the streaming/camera in another). Pairs with #135 (`712f17f`/`2c92375`) — same collapse machinery; the EXIT path is the gap. -**Files:** `src/AcDream.App/Streaming/StreamingController.cs` (`ExitDungeonExpand`, the -collapse/expand hysteresis), `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` -teleport recenter ~4912, the streaming Tick gate ~6890, the PortalSpace observer branch), -`TeleportArrivalController`. Cross-check the post-exit shadow-object/collision registration. +**UPDATE 2026-06-20 — the position-desync HALF is FIXED via #145; the remaining half is narrowed + RE-SCOPED to entity render/lifecycle:** #145 fixed the cell-rooting (the player was rooted into the source dungeon's frame on teleport-out → ACE rejected all movement → "avatar moves but position doesn't track"). With that fixed (server-authoritative placement + drop-stale-center + CurrCell clear), the outdoor TERRAIN now streams + renders and movement is accepted. What REMAINS, observed in the #145 verification run: +- **(A) Server-spawned objects don't render after teleport-back.** NOT a re-broadcast gap — the `ACDREAM_DUMP_LIVE_SPAWNS` trace shows ACE DOES re-send `CreateObject` for all Holtburg weenies on return (Doors, NPCs "Agent of the Arcanum"/"Wedding Planner", "Holtburg Meeting Hall Portal", chests, …) and acdream receives + processes them (`OnLiveEntitySpawnedLocked` → `AppendLiveEntity`). They just don't appear. So the bug is downstream: entity render/storage during the teleport streaming churn — candidates: the re-added live entities are dropped by a subsequent `GpuWorldState.AddLandblock` record-replace for the same landblock, OR the per-instance render data (`WbEntitySpawnAdapter.OnCreate`) isn't built/registered, OR a render-root/visibility gate while `CurrCell` re-acquires. +- **(B) Own avatar stops rendering after a couple of round-trips.** The player entity (persistent, rescued+re-injected via `GpuWorldState.DrainRescued`→`AppendLiveEntity` at `GameWindow` ~:7421) is lost/duplicated across repeated rescue/re-inject cycles. Cumulative (first trip OK, later trips vanish). -**Acceptance:** portal out of the 0x0007 dungeon → full outdoor world streams (trees/scenery -present), collision works, and the player position tracks correctly (no avatar-vs-camera desync). +Both are the **entity-lifecycle/render path across a teleport**, NOT the streaming collapse/expand (which now works — terrain streams). Start here: instrument `GpuWorldState` (AppendLiveEntity / AddLandblock / RemoveLandblock / DrainRescued) to trace one guid (a Door + the player 0x5000000B) across an in→out cycle and find where it leaves the rendered set. + +**Files:** `src/AcDream.App/Streaming/GpuWorldState.cs` (`AddLandblock` pending-merge vs record-replace, `AppendLiveEntity`, `RemoveLandblock`/rescue, `DrainRescued`, `RelocateEntity`), `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawnedLocked` ~:2696, rescued re-inject ~:7421), the per-instance render adapter (`WbEntitySpawnAdapter`). The streaming collapse/expand (`StreamingController`) is no longer the suspect. + +**Acceptance:** portal out of the 0x0007 dungeon → full outdoor world streams (trees/scenery present), **server objects (doors/NPCs/portals) render**, **own avatar renders across repeated round-trips**, collision works, position tracks (no avatar-vs-camera desync). --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b1f7a65e..42bccdc3 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -66,7 +66,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. **#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-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-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) | @@ -135,7 +135,7 @@ accepted-divergence entries (#96, #49, #50). | AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | -| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | +| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression, called from `GameWindow.OnUpdate` ~:7401) + `GameWindow:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | | AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) | | AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | | AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` | diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b16ef017..68c5865c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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); } diff --git a/src/AcDream.App/Streaming/DungeonStreamingGate.cs b/src/AcDream.App/Streaming/DungeonStreamingGate.cs new file mode 100644 index 00000000..ee87a6c7 --- /dev/null +++ b/src/AcDream.App/Streaming/DungeonStreamingGate.cs @@ -0,0 +1,51 @@ +namespace AcDream.App.Streaming; + +/// Result of the per-frame dungeon streaming-gate decision. +/// Passed to — collapse +/// streaming to the single dungeon landblock. +/// 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. +public readonly record struct DungeonGateResult(bool InsideDungeon, uint? ObserverLandblockKey); + +/// +/// 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 (CellGraph.CurrCell, 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. +/// +/// Extracted from GameWindow.OnUpdate as a pure function so the +/// teleport-hold rule (below) is unit-testable without the GL/dat/network stack. +/// +public static class DungeonStreamingGate +{ + /// + /// Decide the streaming gate from the player's current cell. + /// + /// True while a teleport arrival is held (the controller is in + /// PortalSpace): the player is NOT yet placed, so is the frozen + /// SOURCE cell, not where the player is going. + /// CurrCell is EnvCell && !SeenOutside. + /// The current cell id (0xXXYYNNNN). + 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); + } +} diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index d6d00518..358f8ca7 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -215,6 +215,8 @@ public sealed class StreamingController /// 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 /// 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); diff --git a/src/AcDream.App/World/TeleportArrivalController.cs b/src/AcDream.App/World/TeleportArrivalController.cs index 096f0cce..eeddf858 100644 --- a/src/AcDream.App/World/TeleportArrivalController.cs +++ b/src/AcDream.App/World/TeleportArrivalController.cs @@ -103,3 +103,42 @@ public sealed class TeleportArrivalController _place(_destPos, _destCell, forced); } } + +/// +/// Pure readiness decision for a teleport arrival, factored out of +/// GameWindow.TeleportArrivalReadiness for testing. +/// +public static class TeleportArrivalRules +{ + /// + /// Decide whether a held teleport arrival can place now. + /// + /// The destination cell can never hydrate. + /// Destination is an indoor cell (cell index ≥ 0x0100). + /// For an indoor destination, the EnvCell floor has hydrated. + 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; + } +} diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 378afe92..6038ab69 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -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); } diff --git a/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs b/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs new file mode 100644 index 00000000..eb0b12ea --- /dev/null +++ b/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs @@ -0,0 +1,52 @@ +using AcDream.App.World; +using Xunit; + +namespace AcDream.App.Tests.World; + +/// +/// 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() + { + 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)); + } +} diff --git a/tests/AcDream.Core.Tests/Streaming/DungeonStreamingGateTests.cs b/tests/AcDream.Core.Tests/Streaming/DungeonStreamingGateTests.cs new file mode 100644 index 00000000..007bdf81 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/DungeonStreamingGateTests.cs @@ -0,0 +1,67 @@ +using AcDream.App.Streaming; +using Xunit; + +namespace AcDream.Core.Tests.Streaming; + +/// +/// The GameWindow side of the dungeon streaming gate (AP-36): given the player's +/// CURRENT cell, decide whether to collapse streaming and where to pin the observer. +/// The StreamingController's reaction to these inputs is covered by +/// ; this fixture covers the +/// INPUT computation — in particular the teleport-hold rule (#145/#138). +/// +public class DungeonStreamingGateTests +{ + // A 0x0007 Town Network dungeon cell (landblock 0x0007, cell 0x0145). + private const uint DungeonCell = 0x00070145u; + // An outdoor terrain cell near Holtburg (landblock 0xAB34, structure cell 0x0001). + private const uint OutdoorCell = 0xAB340001u; + + [Fact] + public void SealedDungeonCell_NotHold_CollapsesAndPinsObserverToCellLandblock() + { + var r = DungeonStreamingGate.Compute( + isTeleportHold: false, currCellIsSealedDungeon: true, currCellId: DungeonCell); + + Assert.True(r.InsideDungeon); + Assert.Equal(0x0007u, r.ObserverLandblockKey); // 0x00070145 >> 16 + } + + [Fact] + public void OutdoorCell_NotHold_NoCollapse_NoObserverOverride() + { + var r = DungeonStreamingGate.Compute( + isTeleportHold: false, currCellIsSealedDungeon: false, currCellId: OutdoorCell); + + Assert.False(r.InsideDungeon); + Assert.Null(r.ObserverLandblockKey); + } + + [Fact] + public void TeleportHold_StaleSealedDungeonCurrCell_SuppressesGate() + { + // #145/#138 — teleport OUT of a dungeon. During the arrival hold the player is + // unplaced, so CurrCell is still the frozen SOURCE dungeon cell. The gate MUST be + // suppressed: streaming has to follow the DESTINATION (the PortalSpace observer + // pin), not re-pin onto the source dungeon. The pre-fix logic returned + // (true, 0x0007) here, which kept streaming collapsed on the source dungeon → the + // outdoor destination never hydrated → 600-frame timeout → force-snap to ocean. + var r = DungeonStreamingGate.Compute( + isTeleportHold: true, currCellIsSealedDungeon: true, currCellId: DungeonCell); + + Assert.False(r.InsideDungeon); + Assert.Null(r.ObserverLandblockKey); + } + + [Fact] + public void TeleportHold_OutdoorCurrCell_AlsoNoCollapse() + { + // Sanity: an outdoor→outdoor or building→outdoor hold is already correct, but the + // suppression rule must not change that. + var r = DungeonStreamingGate.Compute( + isTeleportHold: true, currCellIsSealedDungeon: false, currCellId: OutdoorCell); + + Assert.False(r.InsideDungeon); + Assert.Null(r.ObserverLandblockKey); + } +}