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

4.4 KiB
Raw Blame History

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:

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:

// 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 (0x0045415F0x00454168) 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:

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.