User observed peer warp spheres in 2-player mode; the KB (translocation-warp.md
frontmatter+body, multiplayer.md frontmatter+body) still called the peer path a
"local-player sphere stand-in until SimulationState/DropZoneLocation replication."
STALE: mechdmg.cpp:1074 fires BTStartWarpEffect at the peer's position gated to
ReplicantInstance, and simulationState rides every update-record header -- so an
observer DOES see a peer's un-wreck/respawn warp. Committed 160b78e ("observer
sees peer un-wreck"). The only genuine remainder is a [T3] fidelity nuance: the
peer sphere is anchored to the peer's WORLD position rather than the peer's
authentic (un-replicated) DropZoneLocation. (The staleness audit itself missed
this -- it trusted the file's own stale wording over its body + the code.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
13 KiB
Markdown
166 lines
13 KiB
Markdown
---
|
||
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).
|