diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index a3d1df73..6b9f9e17 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -46,6 +46,46 @@ Copy this block when adding a new issue:
---
+## #215 — Same-dungeon death respawn removed the dungeon floor physics
+
+**Status:** IN-PROGRESS — root fix implemented; Release build + 5,054 tests green, pending user gate
+**Severity:** HIGH
+**Component:** teleport / streaming / physics cell identity
+
+**Description:** After `/die` in the starter dungeon, the character respawned
+at the dungeon start but fell through the world as soon as movement began.
+
+**Root cause:** `OnLivePositionUpdated` decided whether a teleport crossed a
+landblock by flooring the player's render-space XYZ against the current
+streaming center. Dungeon EnvCells can have valid negative local frame origins;
+at the captured `0x8C0401AD` position, Y `-30.4 m` falsely reconstructed source
+landblock Y `3` instead of authoritative Y `4`. The same-cell respawn was
+therefore treated as cross-landblock travel, and the `0x8C04FFFF` floor physics
+was removed. Streaming still considered that collapsed landblock resident, so
+the idempotent dungeon gate correctly did not enqueue a duplicate load.
+
+**Resolution:** A dedicated `TeleportLandblockTransition` now compares the
+player controller's current cell ID with the received destination cell ID,
+matching retail's complete `Position::objcell_id + Frame` flow. XYZ is not used
+for teleport identity. A zero pre-placement source falls back to the current
+streaming-center ID, never a coordinate inference. Same-landblock respawns keep
+the resident physics; true cross-landblock teleports retain the existing
+recenter and hydration path.
+
+**Files:** `src/AcDream.App/Streaming/TeleportLandblockTransition.cs`;
+`src/AcDream.App/Rendering/GameWindow.cs`;
+`tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs`.
+
+**Research:**
+`docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md`.
+
+**Acceptance:** In the starter dungeon, use `/die`, confirm the death dialog,
+respawn in `0x8C0401AD`, then move in several directions. The character remains
+on the dungeon floor and the teleport log reports old/new landblock `(140,4)`
+without a physics removal or ten-second readiness timeout.
+
+---
+
## #214 — Dungeon login could replace a complete EnvCell render with a partial second load
**Status:** IN-PROGRESS — architectural fix complete 2026-07-13, pending user visual gate
diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 7b633f0b..c1bdc49e 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -412,6 +412,24 @@ replace state produced by another job. CPU mesh extraction may still be schedule
onto `ObjectMeshManager`'s thread-safe work queue, but the worker never publishes
partially hydrated cell membership or shell placement to the renderer.
+### Teleport cell identity at the streaming boundary
+
+The network and physics layers carry retail's complete `Position`: an
+authoritative `objcell_id` plus a frame. Any streaming decision that asks
+whether a teleport crossed a landblock compares the source and destination cell
+IDs. It must never reconstruct either landblock by flooring render/world XYZ.
+Dungeon EnvCells have arbitrary frame origins, including valid negative local
+coordinates, so XYZ-to-grid inference can evict the landblock the player is
+still standing in.
+
+`TeleportLandblockTransition` owns this App-layer classification. When the
+source cell is unavailable before first placement, it uses the typed current
+streaming-center landblock as the fallback identity. The existing async
+recenter/hydration path runs only for a real cell-ID landblock change; a
+same-landblock teleport keeps the already-published render and physics unit.
+This is the asynchronous adapter for retail's direct full-`Position` placement
+(`SmartBox::TeleportPlayer @ 0x00453910`).
+
## Roadmap Model
The old R1-R8 architecture sequence was a useful early refactor sketch, but it
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 2ef898d4..c058cb24 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -146,7 +146,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| 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). **#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-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). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#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/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `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. The cell-ID comparison matches retail's complete `Position` flow. | 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; retail `SmartBox::TeleportPlayer` 0x00453910 |
| 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 meters collapse Type-3 slice descendants into `UiMeter.BackLeft..FrontRight` and reuse `UiMeter.DrawHBar` rather than building those media descendants and dispatching retail `UIElement_Meter::DrawChildren`. Non-Type-3 meter children are imported normally. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`); `LayoutImporter.cs` meter child predicate | The current vitals/character meter shapes are visually accepted and fixture-pinned; this is a representation adaptation, not a controller overlay. | A meter with a different descendant/media structure can render empty or with incorrect clipping/direction | `UIElement_Meter::DrawChildren @ 0x0046FBD0`; production meter LayoutDesc fixtures |
diff --git a/docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md b/docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md
new file mode 100644
index 00000000..3b07a2f2
--- /dev/null
+++ b/docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md
@@ -0,0 +1,115 @@
+# Same-dungeon respawn landblock identity — retail pseudocode
+
+## Symptom and captured cause
+
+After `/die` in the starter dungeon, ACE respawned the player in the same cell,
+`0x8C0401AD`. The player stood correctly until the first movement input, then
+lost floor contact and fell through the world.
+
+The destination update and the player controller both named cell
+`0x8C0401AD`, but acdream reconstructed the source landblock from render-space
+XYZ. The dungeon cell's valid local Y was about `-30.4 m`, so this expression:
+
+```text
+sourceLbY = streamingCenterY + floor(renderY / 192 m)
+ = 4 + floor(-30.4 / 192)
+ = 3
+```
+
+misidentified the source as landblock `(140,3)`. The destination was `(140,4)`,
+so the same-cell respawn entered the cross-landblock branch, removed
+`0x8C04FFFF` from the physics engine, and left the player with no hydrated floor.
+
+Dungeon EnvCells may have arbitrary negative frame origins. Their XYZ values
+therefore cannot identify an AC landblock.
+
+## Named retail oracle
+
+Retail stores cell identity inside every position:
+
+```c
+// acclient.h:30658-30662
+struct Position : PackObj
+{
+ unsigned int objcell_id;
+ Frame frame;
+};
+```
+
+The local-player teleport path preserves that complete value end to end:
+
+- `SmartBox::TeleportPlayer @ 0x00453910` passes its `Position const*` directly
+ to `CPhysicsObj::SetPositionSimple`.
+- `CPhysicsObj::SetPositionSimple @ 0x005162B0` copies that complete `Position`
+ into a `SetPositionStruct`, then calls `CPhysicsObj::SetPosition`.
+- `SmartBox::HandleReceivedPosition`'s local-player teleport branch
+ (`0x0045415F`–`0x00454168`) passes the received `Position` to
+ `SmartBox::TeleportPlayer`; it does not derive a cell from XYZ.
+
+References:
+
+- `docs/research/named-retail/acclient.h:30658`
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:92514`
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:93013`
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:284276`
+
+## Reference-client/server cross-check
+
+The existing L.3 research traces the same contract through two independent
+implementations:
+
+- ACE sends death respawn as `GameMessagePlayerTeleport`, immediately followed
+ by an `UpdatePosition` whose `PositionPack` contains the destination cell and
+ frame (`docs/research/2026-05-04-l3-port/12-hard-teleport-branch.md`,
+ sections 7 and 9).
+- The retail packet path unpacks `PositionPack.position` as
+ `objcell_id + Frame` and routes that complete value into the hard-teleport
+ branch (`docs/research/2026-05-04-l3-port/03-up-routing.md`, sections 0 and 3).
+
+The repository's old external ACE checkout is no longer present after the
+WorldBuilder extraction, so the checked-in cross-reference research is the
+stable ACE source citation for this change.
+
+## Faithful pseudocode
+
+Retail has synchronous cell access and can install the received `Position`
+directly. acdream keeps its asynchronous loading cover, but the identity
+decision must use the same `Position.objcell_id` values:
+
+```text
+on local player teleport destination(receivedPosition):
+ sourceCell = player.currentPosition.objcell_id
+ destinationCell = receivedPosition.objcell_id
+
+ if sourceCell is zero:
+ # acdream-only pre-placement fallback; no XYZ inference
+ sourceLandblock = currentStreamingCenterLandblock
+ else:
+ sourceLandblock = landblockOf(sourceCell)
+
+ destinationLandblock = landblockOf(destinationCell)
+ crossesLandblock = sourceLandblock != destinationLandblock
+
+ if crossesLandblock:
+ evict the old streaming-origin physics landblock
+ recenter streaming on destinationLandblock
+ start the existing destination hydration path
+
+ retain receivedPosition.objcell_id as the pending destination cell
+ when the existing readiness gate opens:
+ place the player with the complete destination cell + frame
+```
+
+`landblockOf(cell)` means `(cell & 0xFFFF0000) | 0xFFFF`. At no point does
+the decision divide or floor position coordinates.
+
+## Conformance cases
+
+1. Source and destination are both `0x8C0401AD`, while render Y is negative:
+ same landblock; do not evict or recenter.
+2. Source and destination are different EnvCells in `0x8C04`:
+ same landblock; do not evict or recenter.
+3. Source `0x8C0401AD`, destination `0xA9B40022`:
+ cross-landblock; retain the existing recenter/hydration behavior.
+4. Source cell is zero before first placement:
+ compare against the current streaming-center ID; never infer from XYZ.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 53824db3..fea729d0 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -5985,13 +5985,24 @@ public sealed class GameWindow : IDisposable
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
&& update.Guid == _playerServerGuid)
{
- // Compute old landblock coords from controller position (using the
- // current streaming origin as the reference center).
var oldPos = _playerController.Position;
- int oldLbX = _liveCenterX + (int)System.Math.Floor(oldPos.X / 192f);
- int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f);
+ uint streamingOriginLandblockId =
+ AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
+ _liveCenterX, _liveCenterY);
- bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
+ // Retail preserves the full Position (objcell_id + Frame) through
+ // SmartBox::TeleportPlayer @ 0x00453910. Dungeon EnvCells can have
+ // negative local frame origins, so XYZ cannot identify the source
+ // landblock (#215). Compare the controller's authoritative current
+ // cell with the received destination cell instead.
+ var landblockTransition =
+ AcDream.App.Streaming.TeleportLandblockTransition.Classify(
+ _playerController.CellId,
+ p.LandblockId,
+ streamingOriginLandblockId);
+ int oldLbX = (int)((landblockTransition.SourceLandblockId >> 24) & 0xFFu);
+ int oldLbY = (int)((landblockTransition.SourceLandblockId >> 16) & 0xFFu);
+ bool differentLandblock = landblockTransition.CrossesLandblock;
Console.WriteLine(
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
@@ -6013,9 +6024,7 @@ public sealed class GameWindow : IDisposable
// 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);
+ _physicsEngine.RemoveLandblock(streamingOriginLandblockId);
// Recenter the streaming controller on the new landblock NOW (kick
// off the dungeon load). After recentering, the destination is
diff --git a/src/AcDream.App/Streaming/TeleportLandblockTransition.cs b/src/AcDream.App/Streaming/TeleportLandblockTransition.cs
new file mode 100644
index 00000000..c982d24f
--- /dev/null
+++ b/src/AcDream.App/Streaming/TeleportLandblockTransition.cs
@@ -0,0 +1,42 @@
+namespace AcDream.App.Streaming;
+
+///
+/// Classifies whether a local-player teleport crosses an AC landblock boundary.
+/// Cell identity comes from the complete retail Position, never from its
+/// frame coordinates: dungeon EnvCells may have valid negative local origins.
+///
+///
+/// Retail carries Position::objcell_id through
+/// SmartBox::TeleportPlayer (0x00453910) into
+/// CPhysicsObj::SetPositionSimple (0x005162B0). acdream's asynchronous
+/// streaming layer needs this extra comparison, but preserves that identity rule.
+/// See docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md.
+///
+internal readonly record struct TeleportLandblockTransition(
+ uint SourceLandblockId,
+ uint DestinationLandblockId)
+{
+ public bool CrossesLandblock => SourceLandblockId != DestinationLandblockId;
+
+ ///
+ /// Build the transition from the player's current cell and the received
+ /// destination cell. A zero source can occur before initial placement; in
+ /// that case the current streaming center is the only valid identity fallback.
+ ///
+ public static TeleportLandblockTransition Classify(
+ uint sourceCellId,
+ uint destinationCellId,
+ uint currentStreamingCenterLandblockId)
+ {
+ uint sourceLandblockId = sourceCellId != 0
+ ? NormalizeLandblockId(sourceCellId)
+ : NormalizeLandblockId(currentStreamingCenterLandblockId);
+
+ return new TeleportLandblockTransition(
+ sourceLandblockId,
+ NormalizeLandblockId(destinationCellId));
+ }
+
+ private static uint NormalizeLandblockId(uint cellOrLandblockId)
+ => (cellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
+}
diff --git a/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs b/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs
new file mode 100644
index 00000000..027d4665
--- /dev/null
+++ b/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs
@@ -0,0 +1,63 @@
+using AcDream.App.Streaming;
+
+namespace AcDream.App.Tests.Streaming;
+
+public sealed class TeleportLandblockTransitionTests
+{
+ [Fact]
+ public void SameDungeonRespawn_WithNegativeLocalCoordinate_DoesNotCrossLandblock()
+ {
+ const uint cell = 0x8C0401ADu;
+
+ // The removed implementation inferred the source from render-space XYZ.
+ // A valid dungeon Y of -30.412 m floors into the previous grid row even
+ // though the authoritative source cell still belongs to 0x8C04.
+ int legacyInferredLandblockY = 4 + (int)Math.Floor(-30.412f / 192f);
+ Assert.Equal(3, legacyInferredLandblockY);
+
+ var transition = TeleportLandblockTransition.Classify(
+ sourceCellId: cell,
+ destinationCellId: cell,
+ currentStreamingCenterLandblockId: 0x8C04FFFFu);
+
+ Assert.False(transition.CrossesLandblock);
+ Assert.Equal(0x8C04FFFFu, transition.SourceLandblockId);
+ Assert.Equal(0x8C04FFFFu, transition.DestinationLandblockId);
+ }
+
+ [Fact]
+ public void DifferentEnvCells_InSameLandblock_DoNotCrossLandblock()
+ {
+ var transition = TeleportLandblockTransition.Classify(
+ sourceCellId: 0x8C0401ADu,
+ destinationCellId: 0x8C040145u,
+ currentStreamingCenterLandblockId: 0x8C04FFFFu);
+
+ Assert.False(transition.CrossesLandblock);
+ }
+
+ [Fact]
+ public void DifferentPositionCellLandblocks_CrossLandblock()
+ {
+ var transition = TeleportLandblockTransition.Classify(
+ sourceCellId: 0x8C0401ADu,
+ destinationCellId: 0xA9B40022u,
+ currentStreamingCenterLandblockId: 0x8C04FFFFu);
+
+ Assert.True(transition.CrossesLandblock);
+ Assert.Equal(0x8C04FFFFu, transition.SourceLandblockId);
+ Assert.Equal(0xA9B4FFFFu, transition.DestinationLandblockId);
+ }
+
+ [Fact]
+ public void UnplacedSource_UsesStreamingCenterWithoutCoordinateInference()
+ {
+ var transition = TeleportLandblockTransition.Classify(
+ sourceCellId: 0,
+ destinationCellId: 0x8C0401ADu,
+ currentStreamingCenterLandblockId: 0x8C04FFFFu);
+
+ Assert.False(transition.CrossesLandblock);
+ Assert.Equal(0x8C04FFFFu, transition.SourceLandblockId);
+ }
+}