fix(teleport): preserve dungeon cell identity on respawn (#215)

Classify teleport landblock transitions from the source and destination Position cell IDs instead of render-space coordinates. This keeps same-dungeon death respawns on their resident floor physics while preserving the existing true cross-landblock streaming path.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-13 20:08:21 +02:00
parent b0c175afc0
commit 0ad6700a07
7 changed files with 296 additions and 9 deletions

View file

@ -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

View file

@ -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

View file

@ -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 |

View file

@ -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.

View file

@ -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

View file

@ -0,0 +1,42 @@
namespace AcDream.App.Streaming;
/// <summary>
/// Classifies whether a local-player teleport crosses an AC landblock boundary.
/// Cell identity comes from the complete retail <c>Position</c>, never from its
/// frame coordinates: dungeon EnvCells may have valid negative local origins.
/// </summary>
/// <remarks>
/// Retail carries <c>Position::objcell_id</c> through
/// <c>SmartBox::TeleportPlayer</c> (0x00453910) into
/// <c>CPhysicsObj::SetPositionSimple</c> (0x005162B0). acdream's asynchronous
/// streaming layer needs this extra comparison, but preserves that identity rule.
/// See <c>docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md</c>.
/// </remarks>
internal readonly record struct TeleportLandblockTransition(
uint SourceLandblockId,
uint DestinationLandblockId)
{
public bool CrossesLandblock => SourceLandblockId != DestinationLandblockId;
/// <summary>
/// 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.
/// </summary>
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;
}

View file

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