Warp: restore the translocation-vortex look + fix texture-scroll precision collapse (task #52)

The death/respawn "blue whirlwind" (tsphere) now matches the original cabinet
photo (capture.png): a smooth spinning lavender vortex with a bright core.

Root cause of the long-standing "radial spokes" artifact was NOT the warp code
but a general engine bug: L4D3D::SetTextureScrolling computed its texture-matrix
offset as scrollDelta * absolute_time, which grows unbounded and collapses UV
float precision -> a smooth scrolled cloud shatters into grainy radial steps.
Wrapped with fmodf(..., 1.0f) (identical under REPEAT tiling, full precision).
This also cleans the scrolling bexp beam grit and any other SCROLL material.

Visual reconstruction (verified against the real 45-vtx TSPHERE.BGF bicone,
offline-rasterized then ported):
  - view ON-AXIS (eye centred on the throat) + spin in place -> concentric rings
    (decomp FUN_00453dc4 does spin-about-local-Z + submit; the port had stubbed it)
  - bintA cloud through a WIDE lavender ramp at full contrast (drawn as SKY);
    no geometry "bands", no log-polar twist, no off-axis tornado (all discarded)
  - tessellate the 12-facet bicone smooth; isotropic + trilinear; ramp baked into
    the texture and drawn SELECTARG1(TEXTURE) to avoid double-tinting

Env knobs (BT_WARP_*) default to the verified values; BT_WARP_SELFTEST/SELFSHOT
are an off-by-default visual-verification harness (backbuffer frame dump).

Docs: new context/translocation-warp.md (geometry/material/visual/lifecycle/env);
reconstruction-gotchas.md gains the accumulated-time precision-collapse bug class;
rendering.md / multiplayer.md / decomp-reference.md / CLAUDE.md cross-linked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-10 14:53:50 -05:00
co-authored by Claude Opus 4.8
parent a35f321ba1
commit 0bfb3d4ab3
10 changed files with 591 additions and 22 deletions
+10 -1
View File
@@ -163,9 +163,18 @@ The authentic stack is DEFAULT-ON (`BTEnvOn`, mechrecon.hpp): `BT_GAIT_CUTOVER`,
| `BT_AIM_LOG` | `[pick]` ray/box/hit diagnostics (Mech::PickRayHit) |
| `BT_FIRE_ARC=<deg>` | OPT-IN external-camera fire-arc clamp (unset = authentic no-arc) |
| `BT_START_INSIDE` | begin in the cockpit view (V toggles) |
| `BT_TLOC_LOG` | translocation-warp lifecycle log (`[tloc] warp COLLAPSE/EXPAND/phase=…`) |
| `BT_WARP_*` | translocation-warp visual tuning — all default to the verified values (see [[translocation-warp]]): `EYE_UP=8.25` (on-axis), `SPIN=4`, `TWIST=0`, `CONTRAST=1.0`, `LO_*/HI_*` (lavender ramp), `BLUR=0`, `TESS=3`, `MIP=1`, `CULL=cw`, `ANISO=0` |
| `BT_WARP_SELFTEST` / `BT_WARP_SELFSHOT=<prefix>` | DIAG (off by default): force a held warp in a solo game / dump backbuffer frames to disk (visual-verification harness) |
Full render/locomotion gates (BT_RAMP, BT_MATPRI, BT_CULL, BT_SHADOW_*, BT_LODSEL, BT_ADDLOD,
BT_PUNCH, …) are catalogued in [[rendering]].
BT_PUNCH, …) are catalogued in [[rendering]]. Warp visuals: [[translocation-warp]].
**Translocation-warp addresses** [T1]: `POVTranslocateRenderable` = L4VIDRND.cpp:1749 / .h:638; its
per-frame Execute (spin about local Z + submit, NO texcoord/ramp work) = **`FUN_00453dc4`**
(`reference/decomp/all/part_007.c`), using axis-rotation helper `FUN_0040998c` (axis idx 2 = Z).
Respawn SIM path: `DropZoneReply` `FUN_004bffd0``Mech::Reset` @0x4009fb74; `CreatePlayerVehicle`
`FUN_004bfcac`. See [[translocation-warp]], [[multiplayer]].
---
+13 -4
View File
@@ -247,10 +247,19 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
player-attribute replication (SimulationState/DropZoneLocation) isn't wired (on a replicant both read
0xCD… uninitialised — confirmed via `BT_TLOC_LOG`). Verified 2-node: on B's respawn the sphere
COLLAPSES (scale ~100→5) then EXPANDS (→150) at the valid drop-zone origin, deduped to ≤2 active,
respawn cycle un-regressed, no render errors. Env gate `BT_TLOC_LOG`. REMAINING (follow-up): the
visual appearance (colour/translucency/size on screen) is unverified by logs — needs a live look;
and the AUTHENTIC peer path (see OTHERS' spheres) needs player SimulationState/DropZoneLocation
replication — until then the local-player sphere stands in.
respawn cycle un-regressed, no render errors. Env gate `BT_TLOC_LOG`.
- **✅ VISUAL DONE + LIVE-VERIFIED (task #52, follow-up):** the on-screen look now matches the
original cabinet photo (`capture.png`) — a smooth spinning lavender vortex with a bright core. The
full geometry/material/render authority is now **[[translocation-warp]]**; headlines: tsphere is a
12-facet BICONE viewed ON-AXIS + spun in place (decomp `FUN_00453dc4` Z-spin, which the WinTesla
port had stubbed), coloured by the `bintA` cloud through a WIDE lavender ramp, drawn as SKY. The
defect that hid it for the whole effort was NOT the warp code — it was a general
**texture-scroll precision-collapse bug** in `L4D3D::SetTextureScrolling` (offset = `scrollDelta ×
absolute_time` → unbounded → UV precision loss → radial "spokes"), fixed with `fmodf`; that also
cleans the scrolling beam grit. See [[reconstruction-gotchas]] §13. REMAINING: the AUTHENTIC peer
path (see OTHERS' spheres) still needs player SimulationState/DropZoneLocation replication — until
then the local-player sphere stands in; and confirm the COLLAPSE (death-side) warp fires on every
MP death path (the force-damage test logged EXPAND reliably, COLLAPSE less so).
NOTE the respawn-cycle SIM path is separate + done: `DropZoneReply` (FUN_004bffd0) → `Mech::Reset`
(@0049fb74) + the level-2 "translocated" cockpit alarm; `CreatePlayerVehicle` (FUN_004bfcac,
disassembled — make+link only, matches RP analog). The sphere is a pure RENDER-side watcher on the
+21
View File
@@ -203,6 +203,27 @@ AVs. Fill gaps with a shared read-only pad member. Same for a class's `<Name>Att
---
## 13. Accumulated-time precision collapse (rate × absolute-time in a matrix)
Any matrix element (or coordinate) computed as **`rate × absolute_runtime`** grows without bound. Float
has ~7 significant digits, so once the value is large its FRACTIONAL precision is gone — and if that
value is then added to a per-pixel/per-vertex quantity, the result **quantizes into coarse steps**. The
visible signature is a smooth field shattering into grainy stair-steps or radial "spokes" that get
worse the longer the app runs (and are invisible right after launch).
- **Archetype (the translocation-warp spokes):** `L4D3D::SetTextureScrolling` set a texture-matrix
translate `_31 = -scrollUDelta * targetRenderFrame` (targetRenderFrame = absolute time). Within
seconds the UV offset was large enough that adding it to the per-pixel UV collapsed precision → the
scrolled cloud rendered as radial grain. It degraded EVERY scrolling texture (beams `bexp`, exhaust),
but the full-screen warp on black made it obvious. **Fix:** wrap into the periodic range —
`fmodf(rate*time, period)` — identical under REPEAT tiling / rotation, but full precision. Prefer
delta-time accumulators that you wrap each frame, over `rate*absolute_time`. [T2]
- **Tell from a symptom:** if a smooth animated/scrolled surface looks progressively grainier or
"steps" and a STILL/offline render of the same data is clean, suspect an unwrapped time accumulator,
not the geometry, texture, or filter. (Cost most of task #52's visual effort — see [[translocation-warp]].)
---
## Diagnostic recipe (the standard loop)
1. Read the RAW decomp `reference/decomp/all/part_*.c` for the `FUN_xxxx`.
2. Map `FUN_`/`DAT_`/`this+0xNN` to engine symbols via BT headers + WinTesla MUNGA source +
+9
View File
@@ -61,6 +61,15 @@ The `dpl_*` beam renderable was never ported → beams are drawn by `BTPushBeam`
origin re-anchors to the live muzzle every frame (can't run out from under your own beam). See
[[combat-damage]] for fire-rate/muzzle. [T2]
## Translocation warp (the respawn vortex) — see [[translocation-warp]]
The death/respawn "blue whirlwind" is `tsphere.bgf` (a 12-facet bicone) spun on-axis with the `bintA`
cloud through a wide lavender ramp, drawn as SKY. Full geometry/material/lifecycle/env in
[[translocation-warp]]. ⚠ **Rendering-wide fix that came out of it:** `L4D3D::SetTextureScrolling`
computed its texture-matrix offset as `scrollDelta × absolute_time`, which grew unbounded and collapsed
UV float precision → grainy/spoked scrolled textures the longer a session ran. Wrapped with `fmodf(…,
1.0f)`. This also cleans up the scrolling `bexp` beam grit and any other `SCROLL` material. [T2]
(Bug class: [[reconstruction-gotchas]] §13.)
## Performance — the 10fps→60fps fix (~50-80× render speedup)
The 10fps baseline had THREE stacked causes, all fixed (engine tree): (1) **`D3DCREATE_SOFTWARE_
VERTEXPROCESSING`** — every vertex transformed+lit on the CPU → HARDWARE first; (2) D3DX `DrawSubset`
+158
View File
@@ -0,0 +1,158 @@
---
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/<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. 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=<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_ANISO=0` ·
`BT_WARP_CULL=cw` · `BT_WARP_TINT=0` · `BT_WARP_TILT=0` · `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).