acdream/docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md
Erik 0ad6700a07 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>
2026-07-13 20:08:21 +02:00

115 lines
4.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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