--- 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: - "AUTHENTIC peer path (seeing OTHERS' spheres) needs player SimulationState/DropZoneLocation replication; until then the LOCAL-player sphere stands in." - "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//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. Observer replicant un-wreck fires the world-anchored `BTStartWarpEffect(x,y,z)` (mechdmg.cpp). The peer path is a stand-in until player `SimulationState`/`DropZoneLocation` replication exists. ## 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=` 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).