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>
115 lines
4.4 KiB
Markdown
115 lines
4.4 KiB
Markdown
# 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.
|