diff --git a/CLAUDE.md b/CLAUDE.md index 099f033..9ed5afe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,7 @@ precise than anything you can infer. | Subsystems, the factory, heat/weapons/power | `context/subsystems.md` | | Damage zones, targeting, firing, death | `context/combat-damage.md` | | Rendering, LODs, materials, sky, shadows, beams | `context/rendering.md` | +| The death/respawn translocation warp (tsphere vortex) | `context/translocation-warp.md` | | Cockpit gauges / MFD HUD | `context/gauges-hud.md` | | Multiplayer, replication, netcode | `context/multiplayer.md` | | Pod hardware, monitors, RIO, MFD surfaces | `context/pod-hardware.md` | diff --git a/context/decomp-reference.md b/context/decomp-reference.md index 974835f..d4c296f 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -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=` | 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=` | 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]]. --- diff --git a/context/multiplayer.md b/context/multiplayer.md index a0484f2..39a0505 100644 --- a/context/multiplayer.md +++ b/context/multiplayer.md @@ -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 diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index 056539e..75884e9 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -203,6 +203,27 @@ AVs. Fill gaps with a shared read-only pad member. Same for a class's `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 + diff --git a/context/rendering.md b/context/rendering.md index 5c00f31..797cd2a 100644 --- a/context/rendering.md +++ b/context/rendering.md @@ -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` diff --git a/context/translocation-warp.md b/context/translocation-warp.md new file mode 100644 index 0000000..86db9ed --- /dev/null +++ b/context/translocation-warp.md @@ -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//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_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). diff --git a/engine/MUNGA_L4/L4D3D.cpp b/engine/MUNGA_L4/L4D3D.cpp index aa2c3db..987ca8a 100644 --- a/engine/MUNGA_L4/L4D3D.cpp +++ b/engine/MUNGA_L4/L4D3D.cpp @@ -441,6 +441,12 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName) // texChannel: which BSL bit-slice this texture samples (BMF tag 0x18; // mech skins pack 4+ grayscale sheets per .BSL). Ignored for VTX/TGA. Image img = decodeImage(tp, data.batches[i].texChannel); + if (getenv("BT_TLOC_LOG") && data.batches[i].warpBands > 0) + DEBUG_STREAM << "[tloc-bake] tsphere batch#" << i << " useRamp=" << useRamp + << " hasRamp=" << data.batches[i].hasRamp << " tex='" << tp << "' img.ok=" << img.ok + << " w=" << img.w << " h=" << img.h + << " lo=(" << data.batches[i].rampLo[0] << "," << data.batches[i].rampLo[1] << "," << data.batches[i].rampLo[2] << ")" + << "\n" << std::flush; if (img.ok && img.w > 0 && img.h > 0) { LPDIRECT3DTEXTURE9 tex = NULL; @@ -480,6 +486,64 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName) // the texel LUMINANCE. argb = lerp(rampLo, rampHi, lum). const float *lo = data.batches[i].rampLo; const float *hi = data.batches[i].rampHi; + // TRANSLOCATION WARP (tsphere_mtl, warpBands>0): the swirl is the + // bintA cloud sampled in the bicone's cylindrical UVs (U=angle, + // V=axial) which, viewed down the throat axis, ARE log-polar + // coordinates -- exactly the mapping that matched the original + // (capture.png). Colour it by a NARROW lavender ramp at LOW + // contrast: compress the cloud luminance toward mid-grey before the + // ramp so the bands read as soft, intersecting spiral streaks (the + // "looking down a tornado" look) rather than hard concentric rings. + // BT_WARP_CONTRAST (default 0.55). Terrain/sky ramps (warpBands==0) + // stay full-contrast. + float warpContrast = 0.0f; + if (data.batches[i].warpBands > 0) { + static float s_ct = -1.0f; + // Full contrast (1.0 = cloud luminance untouched): the original's + // rings span the whole range dark->bright. Compressing toward mid- + // grey (<1) washed the swirl flat; 1.0 matched (cmp_final2.png). + if (s_ct < 0.0f) { const char *cv = getenv("BT_WARP_CONTRAST"); s_ct = cv ? (float)atof(cv) : 1.0f; if (s_ct < 0) s_ct = 0; if (s_ct > 4) s_ct = 4; } + warpContrast = s_ct; + } + // PRE-BLUR (tsphere only): the 64x64 bintA cloud is far too high- + // frequency for the huge throat wall -- minified there it ALIASES into + // the grainy radial "spokes" (the modern high-res render exposing what + // the 1995 low-res IG board low-passed away; the original swirl is soft + // concentric rings). Low-pass the cloud FIRST. Separable box blur, + // wrap-around (the texture tiles), 2 passes. BT_WARP_BLUR = radius + // (default 3; 0 = off). + std::vector blurLum; + if (warpContrast > 0.0f) + { + static int s_blur = -1; + // Default OFF: with the scroll precision bug fixed the raw cloud reads + // as clean concentric rings (cmp_blur.png). Blur only ever masked the + // scroll "spokes"; >0 now just softens the rings. BT_WARP_BLUR=radius. + if (s_blur < 0) { const char *bv = getenv("BT_WARP_BLUR"); s_blur = bv ? atoi(bv) : 0; if (s_blur < 0) s_blur = 0; if (s_blur > 31) s_blur = 31; } + const int BW = img.w, BH = img.h; + blurLum.resize((size_t)BW * BH); + for (int y = 0; y < BH; y++) + for (int x = 0; x < BW; x++) { + uint32_t p = img.argb[(size_t)y * BW + x]; + blurLum[(size_t)y*BW+x] = (0.299f*((p>>16)&0xFF)+0.587f*((p>>8)&0xFF)+0.114f*(p&0xFF))/255.0f; + } + if (s_blur > 0) { + std::vector tmp((size_t)BW * BH); + const int R = s_blur; const float inv = 1.0f/(2*R+1); + for (int pass = 0; pass < 2; ++pass) { + for (int y = 0; y < BH; y++) // horizontal (wrap) + for (int x = 0; x < BW; x++) { + float s = 0; for (int k=-R;k<=R;k++){int xx=((x+k)%BW+BW)%BW; s+=blurLum[(size_t)y*BW+xx];} + tmp[(size_t)y*BW+x] = s*inv; + } + for (int y = 0; y < BH; y++) // vertical (wrap) + for (int x = 0; x < BW; x++) { + float s = 0; for (int k=-R;k<=R;k++){int yy=((y+k)%BH+BH)%BH; s+=tmp[(size_t)yy*BW+x];} + blurLum[(size_t)y*BW+x] = s*inv; + } + } + } + } for (int y = 0; y < img.h; y++) { unsigned char *dst = (unsigned char*)lr.pBits + y * lr.Pitch; @@ -487,7 +551,13 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName) for (int x = 0; x < img.w; x++) { uint32_t p = src[x]; - float lum = (0.299f*((p>>16)&0xFF) + 0.587f*((p>>8)&0xFF) + 0.114f*(p&0xFF)) / 255.0f; + float lum = blurLum.empty() + ? (0.299f*((p>>16)&0xFF) + 0.587f*((p>>8)&0xFF) + 0.114f*(p&0xFF)) / 255.0f + : blurLum[(size_t)y*img.w + x]; + if (warpContrast > 0.0f) { // soften the cloud toward mid-grey + lum = (lum - 0.5f) * warpContrast + 0.5f; + if (lum < 0.0f) lum = 0.0f; else if (lum > 1.0f) lum = 1.0f; + } int rr = (int)((lo[0] + (hi[0]-lo[0])*lum) * 255.0f + 0.5f); int gg = (int)((lo[1] + (hi[1]-lo[1])*lum) * 255.0f + 0.5f); int bb = (int)((lo[2] + (hi[2]-lo[2])*lum) * 255.0f + 0.5f); @@ -1179,8 +1249,14 @@ void d3d_OBJECT::SetTextureScrolling(const L4TEXOP *texture, Time targetRenderFr D3DXMATRIX translate; D3DXMatrixIdentity(&translate); - translate._31 = -texture->scrollUDelta * time; - translate._32 = texture->scrollVDelta * time; + // WRAP the scroll offset into [0,1) (the texture REPEAT-tiles, so only the + // fractional part is meaningful). Without this, scrollDelta*time grows without + // bound as the app runs; once the offset is large, adding it to the per-pixel UV + // COLLAPSES float precision -> the UVs quantise into coarse steps and a smooth + // texture shatters into grainy radial "spokes" (this was the translocation-warp + // swirl artifact, and would degrade every scrolling texture over a long session). + translate._31 = -fmodf(texture->scrollUDelta * time, 1.0f); + translate._32 = fmodf(texture->scrollVDelta * time, 1.0f); mDevice->SetTransform(D3DTS_TEXTURE0, &translate); } } diff --git a/engine/MUNGA_L4/bgfload.cpp b/engine/MUNGA_L4/bgfload.cpp index bde1db7..257928f 100644 --- a/engine/MUNGA_L4/bgfload.cpp +++ b/engine/MUNGA_L4/bgfload.cpp @@ -501,6 +501,7 @@ struct Builder { bool currentPunch = false; // inside a PUNCH patch (SV_SPECIAL 0x2037, geogroup level) bool currentShadowMat = false; // material name contains "shadow" (baked ground-shadow quads) bool currentTSphere = false; // material is tsphere_mtl (translocation warp): ramp it despite normals + bool meshIsTSphere = false; // this OBJECT is the translocation warp -> smooth-tessellate the cone bool currentHasEmissive = false; float currentEmissive[3] = {0,0,0}; @@ -595,10 +596,33 @@ struct Builder { currentHasEmissive && (currentColor & 0x00FFFFFFu) == 0; const uint32_t vcol = pureEmissive ? 0xFF000000u : (useRamp ? rampTint : currentColor); + // TRANSLOCATION WARP: tsphere's UVs run U=angle around the Z throat, V=axial + // along the tunnel -- i.e. cylindrical. Viewed down the throat axis (as the + // effect is), those ARE log-polar coordinates (angle, log-radius), the exact + // mapping the offline render matched to the original. Two shaping terms: + // * TWIST (BT_WARP_TWIST, default 0.3): a helical shear U += twist*V so the + // cloud SPIRALS inward (the "looking down a tornado" swirl) instead of + // forming plain concentric rings. + // * RADFREQ (BT_WARP_RADFREQ, default 0.8): axial (log-radius) scale -> band + // spacing. (BT_WARP_TILE_U still scales the angular frequency.) + // AUTHENTIC UVs (verified by rendering the true mesh vs the original -- see + // scratchpad/render_bicone.py + cmp_final2.png): the bicone is viewed ON its + // throat axis (eye centred via BT_WARP_EYE_UP=8.25) and merely SPUN in place, so + // the swirl reads as smooth CONCENTRIC rings. Any helical TWIST shears those + // rings into radial SPOKES (the "sphincter" the user rejected) -> default 0. + // RADFREQ 1.0 = the mesh's authored V (matches the original's ring spacing). + float tileU = 1.0f, tileV = 1.0f, twist = 0.0f; + if (currentTSphere) { + static float sTileU = -1.0f, sTileV = -1.0f, sTwist = -999.0f; + if (sTileU < 0.0f) { const char* e = getenv("BT_WARP_TILE_U"); sTileU = e ? (float)atof(e) : 1.0f; if (sTileU <= 0) sTileU = 1.0f; } + if (sTileV < 0.0f) { const char* e = getenv("BT_WARP_RADFREQ"); if (!e) e = getenv("BT_WARP_TILE_V"); sTileV = e ? (float)atof(e) : 1.0f; if (sTileV <= 0) sTileV = 1.0f; } + if (sTwist < -900.0f) { const char* e = getenv("BT_WARP_TWIST"); sTwist = e ? (float)atof(e) : 0.0f; } + tileU = sTileU; tileV = sTileV; twist = sTwist; + } for (size_t i = 0; i < lx.size(); ++i) { px.push_back(lx[i]); py.push_back(ly[i]); pz.push_back(lz[i]); nx.push_back(0); ny.push_back(0); nz.push_back(0); - uu.push_back(lu[i]); vv.push_back(lv[i]); + uu.push_back(lu[i] * tileU + twist * lv[i]); vv.push_back(lv[i] * tileV); col.push_back(vcol); } @@ -659,7 +683,15 @@ struct Builder { batch.texPath = currentTex; batch.texChannel = currentTexChannel; batch.hasRamp = useRamp; + // tsphere warp flag: warpBands>0 signals L4D3D's ramp bake to CONTRAST- + // COMPRESS the bintA cloud toward mid-grey (BT_WARP_CONTRAST) before applying + // the narrow lavender ramp -> soft intersecting spiral streaks. (Value is + // just a nonzero flag now; the old per-vertex "band" model was wrong.) + if (currentTSphere) + batch.warpBands = 1; for (int i = 0; i < 3; ++i) { batch.rampLo[i] = currentRampLo[i]; batch.rampHi[i] = currentRampHi[i]; } + batch.hasEmissive = currentHasEmissive; + for (int i = 0; i < 3; ++i) batch.emissive[i] = currentEmissive[i]; batch.lodNear = currentLodNear; batch.lodFar = currentLodFar; batch.punch = currentPunch; @@ -897,6 +929,7 @@ struct Builder { // and leave it raw gray. Flag it so the ramp bake runs anyway (the // authentic look; matches L4VIDRND POVTranslocateRenderable). currentTSphere = (full.find("tsphere_mtl") != std::string::npos); + if (currentTSphere) meshIsTSphere = true; currentHasRamp = false; if (res) { MatInfo info = res->resolve(full); @@ -909,6 +942,30 @@ struct Builder { for (int i = 0; i < 3; ++i) currentEmissive[i] = info.emissive[i]; } else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; } + // TRANSLOCATION WARP: override the material's coarse "sky" ramp with the + // NARROW LAVENDER ramp that matched the original (capture.png). The swirl + // is the bintA cloud coloured lo->hi by luminance; a wide sky ramp + // ({0,0,0.6}->white) posterises to hard concentric rings, whereas this + // narrow lavender ramp (LO ~mid lavender-blue, HI ~near-white, lavender + // cast) keeps it a soft, low-contrast spiral vortex. Env-tunable. + if (currentTSphere) { + static float wlo[3] = {-1.0f,0,0}, whi[3] = {0,0,0}; + if (wlo[0] < 0.0f) { + const char *e; + // Wide lavender ramp (dark blue-violet -> near-white lavender): + // full luminance range matches the original's contrast + the + // bright central bloom (cmp_final2.png). Narrower ramps washed + // the swirl to a flat lavender. + e = getenv("BT_WARP_LO_R"); wlo[0] = e ? (float)atof(e) : 0.15f; + e = getenv("BT_WARP_LO_G"); wlo[1] = e ? (float)atof(e) : 0.12f; + e = getenv("BT_WARP_LO_B"); wlo[2] = e ? (float)atof(e) : 0.35f; + e = getenv("BT_WARP_HI_R"); whi[0] = e ? (float)atof(e) : 0.98f; + e = getenv("BT_WARP_HI_G"); whi[1] = e ? (float)atof(e) : 0.94f; + e = getenv("BT_WARP_HI_B"); whi[2] = e ? (float)atof(e) : 1.00f; + } + currentHasRamp = true; + for (int i = 0; i < 3; ++i) { currentRampLo[i] = wlo[i]; currentRampHi[i] = whi[i]; } + } break; } for (const Chunk& ch : c.children) collect(ch); @@ -930,7 +987,64 @@ struct Builder { } } + // SMOOTH the translocation warp cone. tsphere.bgf is a coarse 12-facet bicone; + // on the 1995 pod's low-res CRT (+ phosphor persistence + the fast spin) it read + // as a smooth tunnel, but on a modern high-res display the 12 flat facets stay + // crisp = the "12-sided funnel". Subdivide each triangle 4:1 and PROJECT the new + // vertices onto the true cone surface (revolution about the throat axis (0,8.25,z), + // ring radius 10.5 at z=0, apexes at z=+/-21), so the SHAPE becomes smooth while + // the UVs (hence the swirl) interpolate. BT_WARP_TESS = passes (default 3, 0=off). + void tessellateWarpCone() { + static int s_tess = -1; + if (s_tess < 0) { const char* e = getenv("BT_WARP_TESS"); s_tess = e ? atoi(e) : 3; if (s_tess < 0) s_tess = 0; if (s_tess > 5) s_tess = 5; } + const float AXIS_Y = 8.25f, RING_R = 10.5f, HALF_LEN = 21.0f; + for (int p = 0; p < s_tess; ++p) { + std::vector out2; + out2.reserve(mesh->indices.size() * 4); + for (size_t i = 0; i + 3 <= mesh->indices.size(); i += 3) { + uint32_t tri[3] = { mesh->indices[i], mesh->indices[i+1], mesh->indices[i+2] }; + uint32_t m[3]; + for (int e = 0; e < 3; ++e) { + uint32_t a = tri[e], b = tri[(e + 1) % 3]; + float mx = (px[a]+px[b])*0.5f, my = (py[a]+py[b])*0.5f, mz = (pz[a]+pz[b])*0.5f; + float t = std::fabs(mz) / HALF_LEN; if (t > 1.0f) t = 1.0f; + float rIdeal = RING_R * (1.0f - t); + float dy = my - AXIS_Y, r = std::sqrt(mx*mx + dy*dy); + if (r > 1e-5f) { float sc = rIdeal / r; mx *= sc; my = AXIS_Y + dy*sc; } + m[e] = (uint32_t)px.size(); + px.push_back(mx); py.push_back(my); pz.push_back(mz); + nx.push_back(0); ny.push_back(0); nz.push_back(0); + uu.push_back((uu[a]+uu[b])*0.5f); vv.push_back((vv[a]+vv[b])*0.5f); + col.push_back(col[a]); + } + const uint32_t sub[12] = { tri[0],m[0],m[2], m[0],tri[1],m[1], m[2],m[1],tri[2], m[0],m[1],m[2] }; + for (int k = 0; k < 12; ++k) out2.push_back(sub[k]); + } + mesh->indices.swap(out2); + } + mesh->tris = (int)(mesh->indices.size() / 3); + } + void finish() { + if (meshIsTSphere) { + tessellateWarpCone(); + // tessellateWarpCone REBUILT mesh->indices -> the per-batch indexStart/ + // indexCount set back in buildPmesh now point at the STALE pre-tessellation + // range, so the draw would render only that fragment (the "sliver"). + // tsphere is a single ramped batch covering the whole cone: re-point it at + // the full new index list. + if (!mesh->batches.empty()) { + mesh->batches[0].indexStart = 0; + mesh->batches[0].indexCount = (uint32_t)mesh->indices.size(); + } + // THE SWIRL (decomp FUN_00453dc4 does only spin+submit -> the pattern is the + // MATERIAL, i.e. the bintA cloud through the ramp, NOT geometry). Earlier we + // wrongly painted per-vertex "bands" by |z|/halfLen -> sterile concentric + // rings. The real look is the cloud in the bicone's log-polar UVs (angle, + // log-radius) with a helical twist + narrow lavender ramp at low contrast, + // all set up on the UVs/ramp above; the mesh just carries it and spins. So + // finish() only smooths the facets -- no vertex-colour override here. + } size_t nv = px.size(); mesh->verts.resize(nv); for (size_t i = 0; i < nv; ++i) { diff --git a/engine/MUNGA_L4/bgfload.h b/engine/MUNGA_L4/bgfload.h index 4383f5f..b58818c 100644 --- a/engine/MUNGA_L4/bgfload.h +++ b/engine/MUNGA_L4/bgfload.h @@ -38,6 +38,13 @@ struct BgfDrawBatch { bool hasRamp = false; float rampLo[3] = {0,0,0}; float rampHi[3] = {1,1,1}; + // TRANSLOCATION WARP band count: the IG board realised a ramp as a QUANTISED + // shade colour-map (~16 discrete levels; the material 'dither' field exists to + // soften the band seams), which turns a smooth intensity into clean concentric + // CONTOUR BANDS. Our generic ramp bake is a continuous lerp (right for terrain/ + // sky). For tsphere_mtl only, snap the ramp index to this many levels so the + // warp reads as bands, not a blotchy cloud. 0 = continuous (all other ramps). + int warpBands = 0; // EMISSIVE (tag 0x26): self-lit material colour. Pure-emissive materials // (diffuse black + emissive set, e.g. btpolar:pintBIceEmit_mtl -- the polar // ice mounds) render as an UNLIT glow: tex x emissive, no sun/ambient. diff --git a/game/reconstructed/btl4vid.cpp b/game/reconstructed/btl4vid.cpp index 0db4b8c..ea61dfe 100644 --- a/game/reconstructed/btl4vid.cpp +++ b/game/reconstructed/btl4vid.cpp @@ -2226,6 +2226,7 @@ namespace { int gWarpPhase = 0; float gWarpT = 0.0f; // phase clock (collapse/expand) float gWaitClock = 0.0f; // WaitForReincarnate elapsed (wobble + stuck-black failsafe) + float gWarpSpin = 0.0f; // accumulated throat-axis (Z) spin -- the SPIRAL (decomp FUN_00453dc4) float gWarpX = 0.0f, gWarpY = 0.0f, gWarpZ = 0.0f; int gWarpPOV = 0; // 1 = centre on the local eye (own death/respawn); 0 = world-anchored (peer) int gWarpMasked = 0; // 1 while we have raised the SetIsDead world mask @@ -2335,6 +2336,19 @@ void BTDrawTranslocationSpheres(LPDIRECT3DDEVICE9 device, const D3DXMATRIX *view, float dt, Time frame_time) { + // DIAG (off by default): BT_WARP_SELFTEST=1 forces a steady POV warp + // (held in WaitForReincarnate, world masked) so the swirl can be frame-captured and + // compared against the original (capture.png) in a solo game -- no death/respawn + // needed. Remove once the visual is signed off. + static int s_selftest = -1; + if (s_selftest < 0) s_selftest = getenv("BT_WARP_SELFTEST") ? 1 : 0; + if (s_selftest && gWarpPhase == 0) + { + gWarpPhase = 3; gWarpPOV = 1; gWaitClock = 0.0f; + gWarpX = gWarpY = gWarpZ = 0.0f; + if (!gWarpMasked) { BTSetWorldDead(1); gWarpMasked = 1; } + } + if (gWarpPhase == 0) return; @@ -2354,7 +2368,7 @@ void // so our swirl was FROZEN. Set the authored scroll rates here so // SetTextureScrolling animates it (u -0.1/s, v +0.5/s -> the churning // swirl that "spins around"), REPEAT wrap so the scroll tiles. - dop->texture.doScroll = true; + dop->texture.doScroll = getenv("BT_WARP_NOSCROLL") ? false : true; // DIAG toggle dop->texture.scrollUDelta = 0.1f; dop->texture.scrollVDelta = 0.5f; dop->texture.wrap_u = L4TEXOP::REPEAT; @@ -2403,7 +2417,7 @@ void // FAILSAFE: the respawn kicks Wait->Expand (BTStartWarpExpandPOV). If it never // arrives (mission end / out of lives / dropped DropZoneReply) the SetIsDead // world would stay BLACK forever -- so time out and un-mask after 12s. - if (gWaitClock > 12.0f) + if (!s_selftest && gWaitClock > 12.0f) { if (gWarpMasked) { BTSetWorldDead(0); gWarpMasked = 0; } gWarpPhase = 0; @@ -2457,30 +2471,84 @@ void else D3DXMatrixScaling(&local, s, s, s); + // THE SPIRAL (the piece I wrongly dropped): a continuous per-frame SPIN about the + // throat axis (mesh local Z). The 1995 binary's translocate Execute (decomp + // FUN_00453dc4) accumulates an angle and writes a Z-rotation into the sphere every + // frame; the WinTesla port STUBBED it out and I followed the stub, calling + // myRotateY "dead". Spin + the axial V texture-scroll = a HELIX = the smooth + // spiralling vortex, and the rotation SWEEPS the coarse 12 facet edges so the mesh + // stops reading as a faceted "sphincter". BT_WARP_SPIN = rad/s (default 4). + static float s_spinRate = -1.0e9f; + if (s_spinRate == -1.0e9f) + { + const char *sv = getenv("BT_WARP_SPIN"); + s_spinRate = sv ? (float)atof(sv) : 4.0f; + } + gWarpSpin += s_spinRate * dt; + if (gWarpSpin > 6.2831853f) gWarpSpin -= 6.2831853f; + else if (gWarpSpin < 0.0f) gWarpSpin += 6.2831853f; + D3DXMATRIX spin; + D3DXMatrixRotationZ(&spin, gWarpSpin); + + // tsphere is a 12-facet BICONE tunnel; its long axis (the vortex throat) is the + // mesh LOCAL Z, which Scale*inverse(view) already points down the view forward -- + // so NO reorientation is needed (default tilt 0). BT_WARP_TILT (deg, pitch about + // X) is kept only to NUDGE the off-axis convergence toward screen-centre if wanted + // (the eye sits 8.25 below the throat axis, authentically, so it reads a touch + // high). + static float s_tiltDeg = -1.0e9f; + if (s_tiltDeg == -1.0e9f) + { + const char *tv = getenv("BT_WARP_TILT"); + s_tiltDeg = tv ? (float)atof(tv) : 0.0f; + } + D3DXMATRIX tilt; + D3DXMatrixRotationX(&tilt, s_tiltDeg * (float)(3.14159265358979 / 180.0)); + + // EYE-ON-AXIS: bintA is smooth CLOUD noise (no rings of its own); the vortex rings + // come from that cloud mapped in POLAR (U=angle around the Z throat, V=radius) -- + // which only reads as CONCENTRIC rings when you look straight DOWN the throat axis. + // The eye is authentically 8.25 below the axis, which skews the rings to diagonal + // bands (the "sphincter"). BT_WARP_EYE_UP shifts the eye ONTO the axis (mesh local + // +Y) so the rings go concentric; default 8.25 (the throat-axis offset), =0 for the + // raw authentic off-axis view. + static float s_eyeUp = -1.0e9f; + if (s_eyeUp == -1.0e9f) + { + const char *ev = getenv("BT_WARP_EYE_UP"); + s_eyeUp = ev ? (float)atof(ev) : 8.25f; + } + D3DXMATRIX eyeUp; + D3DXMatrixTranslation(&eyeUp, 0.0f, -s_eyeUp, 0.0f); + D3DXMATRIX world; if (gWarpPOV && view != 0) { D3DXMATRIX invView; D3DXMatrixInverse(&invView, 0, view); - world = local * invView; // scale/throb about the eye, then eye->world + // throat-axis -> origin (eyeUp), SPIN about Z, scale/throb, tilt, eye->world. + // eyeUp before spin so the rotation is about the THROAT, not the off-axis eye. + world = eyeUp * spin * local * tilt * invView; } else { D3DXMATRIX anchorM; D3DXMatrixTranslation(&anchorM, gWarpX, gWarpY, gWarpZ); - world = local * anchorM; // peer overlay: scale about the world anchor + world = local * anchorM; // peer overlay: external view, keep upright } gTLocSphere->SetLocalToWorld(world); - // The swirl COLOUR is the material's "sky" ramp remapping the grayscale bintA - // cloud per-texel (un-gated for tsphere_mtl in bgfload.cpp; L4D3D.cpp:480), so the - // bound texture is ALREADY blue-white -- MODULATE by WHITE = the ramp unchanged. + // The swirl COLOUR: the "sky" ramp already bakes the bintA cloud blue->white + // (bgfload.cpp un-gate; L4D3D.cpp:480), and we MODULATE that by the material's + // authored EMISSIVE {0.7,0.5,1} = 0xB380FF (the lavender-blue energy cast) which + // the LIGHTING-off path would otherwise drop -- that lavender is the original's + // signature colour. BT_WARP_COLOR=AARRGGBB overrides. static DWORD s_warpColor = 0; if (s_warpColor == 0) { const char *wc = getenv("BT_WARP_COLOR"); - s_warpColor = wc ? (DWORD)strtoul(wc, 0, 16) : 0xFFFFFFFF; - if (s_warpColor == 0) s_warpColor = 0xFFFFFFFF; + s_warpColor = wc ? (DWORD)strtoul(wc, 0, 16) : 0xFFB380FF; + if (s_warpColor == 0) s_warpColor = 0xFFB380FF; } if (gWarpPOV) @@ -2499,11 +2567,17 @@ void dop->drawAsSky = true; dop->alphaTest = false; dop->drawAsDecal = false; } DWORD sZW, sAB, sCull, sLight, sTF, sCOp, sCA1, sCA2, sAOp, sAA1; + DWORD sMin, sMag, sMip, sAniso, sFog; device->GetRenderState(D3DRS_ZWRITEENABLE, &sZW); device->GetRenderState(D3DRS_ALPHABLENDENABLE, &sAB); device->GetRenderState(D3DRS_CULLMODE, &sCull); device->GetRenderState(D3DRS_LIGHTING, &sLight); + device->GetRenderState(D3DRS_FOGENABLE, &sFog); device->GetRenderState(D3DRS_TEXTUREFACTOR, &sTF); + device->GetSamplerState(0, D3DSAMP_MINFILTER, &sMin); + device->GetSamplerState(0, D3DSAMP_MAGFILTER, &sMag); + device->GetSamplerState(0, D3DSAMP_MIPFILTER, &sMip); + device->GetSamplerState(0, D3DSAMP_MAXANISOTROPY, &sAniso); device->GetTextureStageState(0, D3DTSS_COLOROP, &sCOp); device->GetTextureStageState(0, D3DTSS_COLORARG1, &sCA1); device->GetTextureStageState(0, D3DTSS_COLORARG2, &sCA2); @@ -2512,21 +2586,82 @@ void device->SetRenderState(D3DRS_ZWRITEENABLE, TRUE); device->SetRenderState(D3DRS_ALPHABLENDENABLE, FALSE); // OPAQUE -> single write/pixel - device->SetRenderState(D3DRS_CULLMODE, D3DCULL_CW); // one winding -> no z-fight + { // DIAG: BT_WARP_CULL = none|cw|ccw (default cw). The mesh is double-sided + // (every tri emitted fwd+rev); if CW doesn't cleanly cull one winding the two + // coincident surfaces z-fight into radial sparkle "spokes". + static DWORD s_cull = 0xffffffff; + if (s_cull == 0xffffffff) { + const char *cv = getenv("BT_WARP_CULL"); + s_cull = (cv && cv[0]=='n') ? D3DCULL_NONE : (cv && cv[1]=='c') ? D3DCULL_CCW : D3DCULL_CW; + } + device->SetRenderState(D3DRS_CULLMODE, s_cull); + } device->SetRenderState(D3DRS_LIGHTING, FALSE); + device->SetRenderState(D3DRS_FOGENABLE, FALSE); // tsphere_mtl is "IMMUNE 1" (fog-immune) -- world fog was washing the swirl device->SetRenderState(D3DRS_TEXTUREFACTOR, s_warpColor); - device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_MODULATE); - device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE); // ramped blue-white swirl - device->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TFACTOR); + // FILTERING: era-authentic ISOTROPIC linear+mip (default). My earlier + // anisotropic "fix" was the SPOKE driver: on the grazing funnel wall the texel + // footprint is stretched RADIALLY (down the throat/V), so anisotropy averages + // ALONG that (smears radially) while keeping azimuthal sharpness -> the soft + // concentric rings collapse into radial STREAKS/spokes. Plain isotropic linear + // keeps the rings soft + concentric. BT_WARP_ANISO=N re-enables it (diag only). + { + static int s_aniso = -2; + if (s_aniso == -2) { const char *a = getenv("BT_WARP_ANISO"); s_aniso = a ? atoi(a) : 0; } + if (s_aniso > 0) + { + device->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_ANISOTROPIC); + device->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR); + device->SetSamplerState(0, D3DSAMP_MIPFILTER, D3DTEXF_LINEAR); + device->SetSamplerState(0, D3DSAMP_MAXANISOTROPY, (DWORD)s_aniso); + } + else // force isotropic linear (undo any anisotropy a prior draw left set) + { + device->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_LINEAR); + device->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR); + // Trilinear mips. (The grainy radial "spokes" were NOT a mip problem -- they were + // the texture-scroll precision collapse, fixed in L4D3D::SetTextureScrolling via fmod. + // BT_WARP_MIP=0 forces base-level-only (diag).) + { static int s_mip=-1; if(s_mip<0){const char*mv=getenv("BT_WARP_MIP"); s_mip=(mv&&mv[0]=='0')?0:1;} + device->SetSamplerState(0, D3DSAMP_MIPFILTER, s_mip?D3DTEXF_LINEAR:D3DTEXF_NONE); } + } + } + // The BANDS are the per-VERTEX Gouraud colour (concentric shade-ramp contours + // baked from the geometry in bgfload::finish); the bintA texture (lifted to a + // gentle range) MODULATES churn within them. COLOROP = TEXTURE x DIFFUSE(bands). + // BT_WARP_TEXMOD=0 shows the bands ALONE (diagnostic: is the geometry shade right). + device->SetRenderState(D3DRS_DIFFUSEMATERIALSOURCE, D3DMCS_COLOR1); // vertex colour = diffuse + { + static int s_texmod = -1; + // Ramp is BAKED into the texture now -> default SELECTARG1(TEXTURE) (else branch). + // BT_WARP_TEXMOD=1 re-enables the TEXTURExDIFFUSE modulate (diag: double-tints). + if (s_texmod < 0) { const char *t = getenv("BT_WARP_TEXMOD"); s_texmod = (t && t[0] == '1') ? 1 : 0; } + if (s_texmod) + { + device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_MODULATE); + device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE); // bintA cloud churn + device->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE); // the band colour + } + else + { + device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1); + device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE); // baked lavender ramp -- direct + } + } device->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1); device->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_TFACTOR); gTLocSphere->Draw(PASS_SKY, view, frame_time); + device->SetSamplerState(0, D3DSAMP_MINFILTER, sMin); + device->SetSamplerState(0, D3DSAMP_MAGFILTER, sMag); + device->SetSamplerState(0, D3DSAMP_MIPFILTER, sMip); + device->SetSamplerState(0, D3DSAMP_MAXANISOTROPY, sAniso); device->SetRenderState(D3DRS_ZWRITEENABLE, sZW); device->SetRenderState(D3DRS_ALPHABLENDENABLE, sAB); device->SetRenderState(D3DRS_CULLMODE, sCull); device->SetRenderState(D3DRS_LIGHTING, sLight); + device->SetRenderState(D3DRS_FOGENABLE, sFog); device->SetRenderState(D3DRS_TEXTUREFACTOR, sTF); device->SetTextureStageState(0, D3DTSS_COLOROP, sCOp); device->SetTextureStageState(0, D3DTSS_COLORARG1, sCA1); @@ -2578,6 +2713,36 @@ void device->SetTextureStageState(0, D3DTSS_ALPHAOP, sAOp); device->SetTextureStageState(0, D3DTSS_ALPHAARG1, sAA1); } + + // DIAG (off by default): BT_WARP_SELFSHOT= dumps a backbuffer FRAME + // SEQUENCE (_00.png ..) once the warp has settled, so the swirl can be turned + // into a GIF / compared to capture.png WITHOUT bringing the window to the foreground. + // Retained as the warp's visual-verification harness (needed for the open peer/collapse work). + { + const char *shotPath = getenv("BT_WARP_SELFSHOT"); + if (shotPath && gWarpPhase != 0) + { + static int s_shotN = 0; + ++s_shotN; + // 40 frames, every 3rd, starting at frame 45 (~0.75s in) -> ~2s of the + // spinning/churning swirl at 60fps. + if (s_shotN >= 45 && ((s_shotN - 45) % 3 == 0)) + { + int idx = (s_shotN - 45) / 3; + if (idx < 40) + { + char fn[600]; + _snprintf(fn, sizeof fn, "%s_%02d.png", shotPath, idx); + IDirect3DSurface9 *bb = 0; + if (SUCCEEDED(device->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, &bb)) && bb) + { + D3DXSaveSurfaceToFileA(fn, D3DXIFF_PNG, bb, 0, 0); + bb->Release(); + } + } + } + } + } } //===========================================================================//