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

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