---
id: translocation-warp
title: "Translocation warp — the death/respawn vortex (tsphere)"
status: established
source_sections: "multiplayer.md (lifecycle + trigger wiring, task #52); reference/decomp FUN_00453dc4 (translocate Execute); L4VIDRND POVTranslocateRenderable @1749/.h:638; scratchpad/render_bicone.py (offline mesh match vs capture.png)"
related_topics: [rendering, multiplayer, combat-damage, reconstruction-gotchas, asset-formats, bgf-format]
key_terms: [material-ramp]
open_questions:
  - "Peer warp IS wired + visible -- observer sees a peer's un-wreck warp (world-anchored BTStartWarpEffect, 160b78e). Remaining nuance [T3]: anchor to the peer's authentic DropZoneLocation (not replicated) vs the peer's world position."
  - "In the 2-node force-damage test only the EXPAND (respawn) warp logs reliably; confirm the COLLAPSE (death-side) fires on every MP death path."
---

# Translocation warp (the respawn vortex)

The "blue whirlwind" respawn effect from the 1995 pod: on death the pilot is sucked into a spinning
energy vortex; on respawn it blows outward and the reborn mech's world snaps back. VWE calls it
**translocate** (not "whirlwind" — that keyword search is why an earlier scout wrongly declared it
"not a shipped asset"). It is a pure **render-side** effect watching the player's `SimulationState`
dial; the respawn SIM path (DropZoneReply → `Mech::Reset`) is separate ([[multiplayer]], [[combat-damage]]).

This topic is the VISUAL/RENDER authority. The trigger wiring + the state machine's engine grounding
live in [[multiplayer]] (task #52). The systemic bug the effect exposed is in [[reconstruction-gotchas]] §13.

## The asset — `content/VIDEO/GEO/TSPHERE.BGF` [T1, dumped]
NOT a sphere — a coarse **12-facet BICONE** (two cones base-to-base along local **Z**), 2085 bytes:
- 45 vertices, one `CONN` list = **40 single-sided triangles** (material `btfx:tsphere_mtl`).
- Apexes at **z = ±21** (both at x=0, y=8.25, radius 0); shared **ring at z=0, radius 10.5** (a 12-gon).
- Geometric axis = the line (x=0, **y=8.25**, z) — the "throat." Bound center (0, 8.25, 0), r≈21.
- **UVs are cylindrical:** U = angle around the throat, authored into the narrow band **[0.131, 0.656]**
  (~half the texture, wrapped around the full 360°); V = axial, **[0, 2]** (ring at v=1.0, apexes at
  v≈0 / 2). Viewed straight down the throat these U,V ARE (angle, radius) — a polar map. This is why
  spinning it reads as concentric rings, and why UV twist shears rings into spokes.
- Dump/parse tool: `scratchpad/dump_tsphere.py` (BGF magic `DIV-BIZ2` at 0; chunk tagword id =
  `tw & 0x2fff`, len-width from bits 0x8000/0x4000; vertex tag `0x0089` = XYZ|N|UV stride 32).

The loader **doubles** every tri (fwd `a b c` + rev `a c b`) → 80 tris (`emitTri`, bgfload.cpp), then
`tessellateWarpCone()` subdivides 4:1 × `BT_WARP_TESS` (default 3) projecting each new midpoint onto
the true cone surface (radius `10.5*(1-|z|/21)`) → ~5120 smooth tris. Untessellated, the 12 flat
facets read as a "12-sided funnel"; tessellation is what makes the swirl smooth on a modern display.
(The 1995 low-res CRT + phosphor + fast spin hid the facets for free.)

## The material — `btfx:tsphere_mtl` (in `VIDEO/MAT/<tod>/BTFX.BMF`) [T1]
`tsphere_mtl` → texture **`tsphere_scr_tex`** (a scrolling texture, SPECIAL `SCROLL 0.0 0.0 0.1 0.5`) →
image **`bintA`** (`VIDEO/TEX/BINTA.VTX`, `DIV-VTX2`, **64×64 RGB, pixels at offset 57**, a smooth
cloud-noise field). Ramp ref `sky`; `IMMUNE 1` (fog-immune). So authentically the swirl COLOUR is the
`sky` ramp remapping the grayscale bintA cloud, scrolled, fog-immune.

## The visual model — VERIFIED against `capture.png` [T2, in-game + offline-matched]
The correct look = **the bintA cloud, on the bicone, viewed ON-AXIS, spun in place, coloured by a wide
lavender ramp.** Concretely, and each of these was a hard-won correction:

- **On-axis, not off-axis.** The eye is parented so the bicone THROAT axis passes through it (matrix
  term `eyeUp = Translate(0,-8.25,0)` cancels the +8.25 authored offset). Looking straight down the
  throat, the cloud's polar UVs read as **concentric rings** converging to a bright center. Placing the
  eye off-axis (the authentic mesh-origin, 8.25 below the axis) gives an off-center "tornado" that does
  NOT match the original. `BT_WARP_EYE_UP` (default 8.25 = on-axis).
- **Spun in place (this IS the animation).** The decomp translocate Execute **FUN_00453dc4** does ONLY:
  accumulate an angle (`+0x80 += +0x7c`, wrap 2π) and write a **Z-axis** quaternion rotation
  (`FUN_0040998c` axis idx 2), then submit. No texcoord/palette/ramp/texture-matrix work — proof the
  swirl is the MATERIAL, not geometry. The WinTesla port STUBBED this spin; restoring it is essential.
  Matrix order matters: `world = eyeUp * spin * local * tilt * invView` puts the axis ON the spin-Z
  BEFORE rotating, so it spins in place (no precession). `BT_WARP_SPIN` (rad/s, default 4).
- **Wide lavender ramp, full contrast — NOT the sky ramp, NOT geometry bands.** The bake overrides the
  material's coarse `sky` ramp with a NARROW-hued but FULL-RANGE lavender: `LO=(0.15,0.12,0.35)`,
  `HI=(0.98,0.94,1.0)`, contrast 1.0. The bintA luminance indexes LO→HI per texel (baked into the
  texture, drawn WHITE). Dark→lavender across the full range = distinct soft rings + a bright core.
  (`BT_WARP_LO_R/G/B`, `BT_WARP_HI_R/G/B`, `BT_WARP_CONTRAST`.)
- **No UV twist, radfreq 1.0.** U += twist·V shears the concentric rings into a spiral/spokes — wrong
  for this near-concentric target. `BT_WARP_TWIST` default **0**; `BT_WARP_RADFREQ`/`TILE_U` default 1.
- **Isotropic linear + trilinear mips, no blur.** Anisotropic filtering smears the grazing throat wall
  radially → streaks; keep isotropic. Mips help (trilinear); base-only is sharper/worse. The pre-blur
  hook (`BT_WARP_BLUR`) defaults **0** — it only ever existed to mask the scroll bug (below); with that
  fixed the raw cloud reads as clean rings.

**Discarded WRONG models (do not revive):** per-vertex geometry "shade bands" (concentric contours from
`|z|`); a screen-space **log-polar** billboard; a narrow low-contrast ramp; an off-axis "tornado"; a
helical UV twist; anisotropic filtering; heavy texture blur; a "garbage autogen-mip" theory. Each was
plausible and each was wrong — the real defect was the scroll bug.

## The draw [T2] (`btl4vid.cpp BTDrawTranslocationSpheres`, POV branch)
Drawn as **SKY** (`drawAsSky`, `PASS_SKY`): OPAQUE, `CULLMODE=D3DCULL_CW` (one winding of the
double-sided mesh → no z-fight), z-test on, **LIGHTING off**, **FOG off** (`IMMUNE 1`). `COLOROP =
SELECTARG1(TEXTURE)` — the ramp is already baked into the texture, so draw it DIRECT; do NOT modulate
by a TFACTOR/vertex tint (that double-tints the ramp — the ramp-bake KB note, `docs/PROGRESS_LOG.md`).
The world behind is blacked by the `SetIsDead` mask (below) so only the vortex shows during the wait.
Non-POV (observer over-the-shoulder view of a peer) is a port EXTENSION: alpha-blended in the alpha
pass so it isn't sky-occluded.

## The lifecycle state machine [T1 grounding, T2 impl] — mirrors `POVTranslocateRenderable`
`gWarpPhase`: 0 idle → **1 InitialCollapse** (scale `COLLAPSE_START 100`→1 over `COLLAPSE_TIME 1.3s`) →
**3 WaitForReincarnate** (scale 1, Lissajous wobble `cos(t·3.33)/sin(t·2.5)·TRANSLATE_LIMIT 2.0`,
raises the world mask) → **2 ExpandReveal** (scale 1→`EXPAND_END 150` over `EXPAND_TIME 1.0s`, drops
the mask) → 0. Spins throughout.

**The world mask** = `L4Application::SetIsDead(bool)` (L4APP.h:54), reached via the `BTSetWorldDead(int)`
bridge added in L4VIDRND.cpp. It is a PLAIN BOOL that gates ONLY render passes (world/decal/sky/alpha/
particles/reticle IsDead() readers in L4VIDEO.cpp) — NOT camera/input/targeting/sim. Safe to drive
directly. (⚠ An earlier regression came from pulsing the player `SimulationState` for the same effect —
that DID corrupt camera/targeting; `SetIsDead` is the correct lever. See f053535.) Failsafes: the wait
times out (12s) and force-unmasks on mission-end / no-DropZones so the world can never stay black.

## THE BUG that hid the swirl for the whole effort — texture-scroll precision collapse [T2]
**Symptom:** grainy radial "spokes" from screen center, on black — nothing like the smooth rings.
Survived every geometry/param/filter change; my offline mesh rasterizer (`scratchpad/render_bicone.py`)
rendered the SAME mesh+texture+params **cleanly**. The one thing offline didn't do was **scroll**.

**Cause (`L4D3D.cpp SetTextureScrolling`):** the scroll set a texture-matrix translate
`_31 = -scrollUDelta * time`, `_32 = scrollVDelta * time`, where `time = targetRenderFrame` is
**absolute runtime**. That product grows without bound; within seconds the UV offset is large enough
that adding it to the per-pixel UV **collapses float precision** — the coordinates quantize into coarse
steps and the smooth cloud shatters into radial streaks + grain.

**Fix:** wrap each offset into [0,1) with `fmodf(scrollDelta*time, 1.0f)` — identical under REPEAT
tiling, full precision. Smooth swirl restored. **This bug silently degraded EVERY scrolling texture
(weapon beams `bexp`, exhaust) the longer a session ran** — the warp (full-screen, on black) just made
it impossible to ignore. Generalized in [[reconstruction-gotchas]] §13.

## Trigger wiring (summary; full detail in [[multiplayer]]) [T2]
Local player's own death → `BTStartWarpCollapsePOV()` (btplayer.cpp `VehicleDeadMessageHandler`,
`deathCount==-1`, guarded `this==GetMissionPlayer()`). Respawn `DropZoneReply` → `BTStartWarpExpandPOV()`
(local-guarded). `BTWarpForceUnmask()` on mission-end / no-DropZones. **Peer warp IS wired + visible
(`160b78e`):** an observer sees a peer's death/respawn warp — the replicant un-wreck fires the
world-anchored `BTStartWarpEffect(x,y,z)` at the peer's position (mechdmg.cpp:1074, gated to
`ReplicantInstance`; `simulationState` rides every update-record header so the observer tracks the
peer's state). The ONLY remaining nuance [T3, non-gating]: the observer's peer sphere is anchored to
the peer's WORLD position rather than the peer's authentic `DropZoneLocation` (that attribute isn't
replicated) — a fidelity refinement, not a missing effect.

## How it was solved (method) + verification [T2]
1. **Dump the real asset** (`dump_tsphere.py`) — established it's a bicone with cylindrical UVs, ending
   a long chain of wrong guesses about the geometry.
2. **Offline mesh rasterizer** (`render_bicone.py`) — projected + textured the true 45-vtx mesh with the
   game's exact transform order and swept eye-height / spin / ramp / contrast against a crop of
   `capture.png`. Matched at on-axis + wide ramp + no twist (`scratchpad/cmp_final2.png`). This proved
   the LOOK is achievable and isolated the in-game defect (offline was clean → the delta is scroll).
3. **In-engine self-shot** (`BT_WARP_SELFSHOT=<prefix>` dumps backbuffer frames to disk; `BT_WARP_SELFTEST=1`
   forces a held warp in a solo game) — compared in-game frames to the target WITHOUT foregrounding the
   window. This is how the scroll cause was pinned (scroll-off frame was instantly clean).
4. **Confirmed live** in the 2-node force-damage respawn test (`scratchpad/mp_respawn6.sh` pattern):
   victim node dies → masked swirl → EXPAND-reveal, un-regressed.

## Env gates (all default to the verified values)
`BT_WARP_EYE_UP=8.25` · `BT_WARP_SPIN=4` · `BT_WARP_TWIST=0` · `BT_WARP_CONTRAST=1.0` ·
`BT_WARP_LO_R/G/B=0.15/0.12/0.35` · `BT_WARP_HI_R/G/B=0.98/0.94/1.0` · `BT_WARP_BLUR=0` ·
`BT_WARP_TESS=3` · `BT_WARP_MIP=1` · `BT_WARP_TILE_U=1` · `BT_WARP_RADFREQ=1` (`BT_WARP_TILE_V` is an
accepted alias — bgfload.cpp:665) · `BT_WARP_ANISO=0` · `BT_WARP_CULL=cw` · `BT_WARP_TEXMOD=0`
(diag: =1 re-enables the TEXTURE×DIFFUSE modulate — double-tints; btl4vid.cpp:2640) · `BT_WARP_TILT=0` ·
`BT_WARP_SCALE` (btl4vid.cpp:2263-2266, collapse =v / expand =1.5·v) · `BT_WARP_NOSCROLL` (diag,
btl4vid.cpp:2373) · `BT_TLOC_LOG` (lifecycle log) ·
`BT_WARP_SELFTEST` / `BT_WARP_SELFSHOT` (DIAG capture harness — off by default; retained for visual verification of the open peer/collapse work).

## Key files
- `game/reconstructed/btl4vid.cpp` — state machine, matrix, POV/peer draw, self-test/self-shot hooks.
- `engine/MUNGA_L4/bgfload.cpp` — tsphere detect (`tsphere_mtl`), cylindrical-UV + lavender-ramp
  override, `tessellateWarpCone`, batch `warpBands` flag.
- `engine/MUNGA_L4/L4D3D.cpp` — ramp bake (lavender + contrast + optional blur) **and the
  `SetTextureScrolling` fmod fix**.
- `engine/MUNGA_L4/L4VIDRND.cpp` — `BTSetWorldDead` bridge to `L4Application::SetIsDead`.

## Key Relationships
- Draws through: [[rendering]] (L4D3D sky pass, material ramp, scrolling textures).
- Triggered by / wired in: [[multiplayer]] (SimulationState dial, DropZone respawn), [[combat-damage]]
  (death transition, un-wreck).
- Exposed the bug class: [[reconstruction-gotchas]] §13 (accumulated-time precision collapse).
- Asset formats: [[asset-formats]] (VTX), [[bgf-format]] (BGF chunks, LOD/CONN).
