Reworks the flat per-row shear into the authentic sync-loss-and-relock
transition the playtesters described, on both render paths (surround
DrawDevSurface + glass ExpandPlaneToBGRA, native + rotated radar):
1. COLLAPSE -- the image squeezes to a thin centred vertical line.
2. HOLD (~0.8s, the window the card held the detuned CRTC) -- the line's
content scrolls WILDLY fast and decelerates, plus a violent per-frame
SHAKE (LCG jitter: vertical row bounce + horizontal scroll jitter).
3. RECOVERY (~0.5s) -- the line broadens back to full while the scroll,
tear and shake settle to zero.
4. LOCK -- clean full display.
Mechanism: FunkyVideo(on, dur) records start+hold; ScrambleParams is a
two-phase envelope (hold: deep collapse + decelerating scroll = integral of
v(t)=rollMax*(1-t/hold); recovery: smoothstep broaden + settle). The read
loops map the source through it -- `scale` sets a black-bordered collapse
band, `rollOff` SCROLLS (wraps) within it, `shear` is a per-row diagonal,
`shakeRow` bounces the source row. The SVGA16 owns the full hold+recovery
clock, so FunkyVideo(False) is a no-op (the card restoring sync is where the
recovery begins) -- the recovery isn't cut off when the 0.8s latch clears.
Tunable by eye (pod-monitor PLL look isn't recoverable from the binary):
BT_SCRAMBLE_COLLAPSE (0.03), _ROLL (9000 px/s), _SHAKE (10 px), _SHEAR (3),
_DUR (hold), _RECOVER (0.5s). BT_SCRAMBLE_TEST loops the transition for
tuning; BT_SCRAMBLE_CYCLE loops it stepping the roll speed.
Verified: Release links clean; surround loops the effect at slowed + true
full speed, all secondaries + radar collapse/roll/shake/recover together,
main view clean, no crash. SHIPPING with these defaults for playtester
feedback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
253 lines
13 KiB
Markdown
253 lines
13 KiB
Markdown
# Phase 14 — PPC hit = `scrambleVideo`, the cockpit-CRT sync detune
|
|
|
|
**Goal:** restore the PPC's authentic secondary effect — a PPC strike scrambles
|
|
**every secondary cockpit display for 0.8 s**, leaving the main view untouched.
|
|
|
|
**Status:** ✅ IMPLEMENTED 2026-08-06 (branch `ppc-sync-distortion`) [T2 —
|
|
visual screenshot-verified, trigger by construction]. Discovered 2026-08-06 by
|
|
disassembly of the shipped `BTL4OPT.EXE` (md5 `a97075bcb5634d13263e9ad5a2b96fd0`)
|
|
after playtesters reported *"being hit by a PPC makes it look like all of the
|
|
secondary CRTs were being degaussed."* Full findings: `context/gauges-hud.md`
|
|
§"PPC HIT = a deliberate CRTC horizontal-sync DETUNE"; cross-ref in
|
|
`context/combat-damage.md`.
|
|
|
|
**What shipped (all three work items):**
|
|
- **A — trigger:** `game/reconstructed/mech.cpp`, `Mech::TakeDamageMessageHandler`
|
|
@0x4a03f3 position (after the cylinder resolve, before the burst loop) — fires
|
|
`GetGaugeRenderer()->SpecialEffect(scrambleVideo, damageType*0.2f)` once per
|
|
EnergyDamageType message. `BT_DMG_LOG` prints `[ppc-scramble]`.
|
|
- **B — visual (animated recovery):** `SVGA16::FunkyVideo(on, dur)` (was the 2007
|
|
stub) records the start + hold; `SVGA16::ScrambleParams` (new) is a two-phase
|
|
envelope read by BOTH `DrawDevSurface` (surround/dock) and `ExpandPlaneToBGRA`
|
|
(glass windows, native + rotated radar), so all secondary surfaces recover
|
|
together and the main 3D view (separate timing chain) is untouched. The sequence
|
|
per hit: **collapse** to a thin centred line → **HOLD** for the ~0.8 s the card
|
|
held bad sync (content scrolls wildly fast + decelerating, plus a violent
|
|
**shake**) → **RECOVERY** (~0.5 s: the line broadens back to full while
|
|
scroll/tear/shake settle) → **LOCK** (clean). The read loops map the source
|
|
through the envelope: horizontal `scale` sets a black-bordered collapse band,
|
|
`rollOff` scrolls (wraps) within it, `shear` is a per-row diagonal, and a
|
|
per-frame LCG `shake` bounces the row + jitters the scroll. Tunables (by eye —
|
|
the pod-monitor PLL look is not recoverable): `BT_SCRAMBLE_COLLAPSE` (min band
|
|
fraction, 0.03), `BT_SCRAMBLE_ROLL` (initial scroll px/s, 9000),
|
|
`BT_SCRAMBLE_SHAKE` (px, 10), `BT_SCRAMBLE_SHEAR` (px/row, 3), `BT_SCRAMBLE_DUR`
|
|
(hold s), `BT_SCRAMBLE_RECOVER` (recovery s, 0.5). **`BT_SCRAMBLE_TEST=1` loops
|
|
the whole transition** for tuning without a hit; `BT_SCRAMBLE_CYCLE=1` loops it
|
|
stepping the roll speed.
|
|
- **C — non-stacking latch:** `L4GaugeRenderer::SpecialEffect` now ignores the
|
|
re-arm while `scrambleVideoFlag` is set (matches the binary's `modified` latch);
|
|
a second PPC during the window no longer extends it. NB the SVGA16 animation runs
|
|
the full hold+recovery on its own clock (`FunkyVideo(False)` is a no-op — the
|
|
card restoring sync is where the recovery *begins*), so the recovery isn't cut
|
|
off when the 0.8 s latch clears.
|
|
- Verified [T2]: Release links clean; surround boots + runs with the effect looped
|
|
(`BT_SCRAMBLE_TEST`) at both slowed and true full speed — every secondary MFD +
|
|
the radar collapse/roll/shake/recover together while the out-the-window view
|
|
stays clean, no crash (screenshots). **SHIPPING with the current defaults for
|
|
playtester feedback.** Open: live PPC-fire confirmation (one arm per hit,
|
|
non-stacking across two hits) + by-eye tuning of the envelope constants — none
|
|
recoverable from the binary, so the testers who filed the report are the ground
|
|
truth.
|
|
|
|
**Good news up front:** the engine half already exists in our tree under the
|
|
**original VWE names** (`SpecialEffect` / `scrambleVideo` / `FunkyVideo`). Only
|
|
two things are missing: the **trigger** (never ported) and the **visual**
|
|
(stubbed out in 2007). This is a small, well-bounded job.
|
|
|
|
---
|
|
|
|
## 1. What the original did [T1 — disasm-verified]
|
|
|
|
On the **victim's** machine, `Mech::TakeDamageMessageHandler` @`0x4a0230` tests
|
|
the damage type between the collision divert and the burst loop:
|
|
|
|
```
|
|
004a03f3 mov ecx,[esi+0x2c] ; damage.damageType
|
|
004a03f6 cmp ecx,4 ; EnergyDamageType
|
|
004a03f9 jne 0x4a0423 ; everything else -> burst loop
|
|
004a03fb mov eax,[0x4efc94] ; global `application`
|
|
004a0400 mov eax,[eax+0x4c] ; -> gauge renderer
|
|
004a0405 je 0x4a0423 ; null-guarded
|
|
004a0407 fild dword [esi+0x2c] ; (float)damageType == 4.0
|
|
004a040a fld xword [0x4a0c08] ; long double 0.2
|
|
004a0410 fmulp st(1) ; => 0.8
|
|
004a041d call dword [edx+0x4c] ; vtable slot 19 == SpecialEffect(0, 0.8f)
|
|
```
|
|
|
|
→ `L4GaugeRenderer::SpecialEffect(scrambleVideo, 0.8f)` @`0x46ffcc`
|
|
→ `SVGA16::FunkyVideo(True)` @`0x47d76d`, which reprograms the **VGA CRT
|
|
controller**:
|
|
|
|
```
|
|
out(0x3D4,0x11); v=in(0x3D5); out(0x3D5, v & 0x7F) ; unlock CRTC regs 0-7
|
|
out(0x3D4,0x00) ; CRTC 0 = HORIZONTAL TOTAL
|
|
saved = in(0x3D5); out(0x3D5, saved - 9) ; shorten every scanline
|
|
out(0x3D4,0x11); out(0x3D5, v) ; restore write-protect
|
|
```
|
|
|
|
A per-frame timer @`0x47003c` writes `saved` back 0.8 s later.
|
|
|
|
**Why only the PPC:** a `BTL4.RES` census gives `damageType` 4 (`Energy`) =
|
|
**14 subsystem records, every one PPC or ERPPC**. Everything else is Ballistic
|
|
(16), Explosive (30), Laser (78). The branch is structurally PPC-exclusive —
|
|
no extra gating needed.
|
|
|
|
**Why only the secondaries:** all six secondary displays are derived by the VDB
|
|
from that one VGA's timing, so they break together. The main out-the-window
|
|
view comes off the Division VPX card on an independent timing chain and is
|
|
unaffected. Playtesters confirm both halves.
|
|
|
|
---
|
|
|
|
## 2. What our tree already has
|
|
|
|
| Piece | Where | State |
|
|
|---|---|---|
|
|
| `enum VideoEffectType { scrambleVideo }` (value **0**) | `engine/MUNGA/GAUGREND.h:482` | ✅ present |
|
|
| `virtual void GaugeRenderer::SpecialEffect(VideoEffectType, Scalar) {}` | `engine/MUNGA/GAUGREND.h:488` | ✅ base no-op |
|
|
| `L4GaugeRenderer::SpecialEffect` — sets `scrambleVideoFlag`, `scrambleVideoTimeout = Now()+duration`, calls `FunkyVideo(True)` | `engine/MUNGA_L4/L4GREND.cpp:806` | ✅ implemented |
|
|
| `L4GaugeRenderer::ProcessVideoEffects()` — on timeout, `FunkyVideo(False)` | `engine/MUNGA_L4/L4GREND.cpp:832` | ✅ implemented |
|
|
| …called every frame from `ExecuteForeground` | `engine/MUNGA_L4/L4GREND.cpp:370` | ✅ live |
|
|
| `SVGA16::FunkyVideo(Logical)` | `engine/MUNGA_L4/L4VB16.cpp:6358` | ❌ **STUBBED** (`//STUBBED: VIDEO RB 1/15/07`, body commented out) |
|
|
| Any caller of `SpecialEffect` | — | ❌ **NONE** |
|
|
|
|
The base-class declaration carries its original comment:
|
|
|
|
> `// Quick and dirty hack to allow calling L4GaugeRenderer::SpecialEffect`
|
|
> `// from non L4 level. GDU 2/28/96`
|
|
|
|
That hack exists **because the damage handler (non-L4 level) had to call it** —
|
|
independent corroboration that the trigger belonged in `mech.cpp`, and the
|
|
reason you can call it through the base pointer without dragging L4 headers
|
|
into a game-layer TU.
|
|
|
|
---
|
|
|
|
## 3. Work item A — the trigger (`game/reconstructed/mech.cpp`)
|
|
|
|
In `Mech::TakeDamageMessageHandler`, **after** the `damageType==0` collision
|
|
divert and **before** the burst loop, add the type-4 branch.
|
|
|
|
```cpp
|
|
// @0x4a03f3 [T1] -- PPC/ERPPC only: EnergyDamageType is authored on exactly
|
|
// the 14 PPC/ERPPC subsystem records and nothing else. Duration is DERIVED
|
|
// from the type ordinal, not a constant: (float)damageType * 0.2 == 0.8f.
|
|
if (damage.damageType == Damage::EnergyDamageType) // == 4
|
|
{
|
|
GaugeRenderer *gauges = (application != 0)
|
|
? application->GetGaugeRenderer() : 0; // APP.h:355
|
|
if (gauges != 0) // binary null-guards too
|
|
{
|
|
gauges->SpecialEffect(
|
|
GaugeRenderer::scrambleVideo,
|
|
(Scalar)damage.damageType * 0.2f); // long double 0.2 @0x4a0c08
|
|
}
|
|
}
|
|
```
|
|
|
|
**Placement matters.** The binary's branch is *outside* the burst loop, so it
|
|
fires **once per damage message**, not once per burst. Putting it inside the
|
|
loop would re-arm it `burstCount` times.
|
|
|
|
Use the named accessor — do not raw-read `application+0x4c` (databinding rule,
|
|
`context/reconstruction-gotchas.md`).
|
|
|
|
---
|
|
|
|
## 4. Work item B — the visual (`SVGA16::FunkyVideo`)
|
|
|
|
There is no CRTC to detune, so reproduce the **look**, applied to the gauge
|
|
composite only.
|
|
|
|
What the original did physically: Horizontal Total sets character clocks per
|
|
scanline. Subtracting 9 shortens every line by roughly **9%**, far outside any
|
|
monitor's sync lock range, so the picture breaks into a rolling diagonal tear
|
|
until the value is restored. VWE's own name for it — `scrambleVideo` — is the
|
|
best description of the intended result.
|
|
|
|
Suggested model (per-scanline horizontal displacement of the gauge buffer):
|
|
|
|
```
|
|
shift(y, t) = ( y * k + roll(t) ) mod width
|
|
```
|
|
|
|
- `k` — per-line shear, the fraction of a line lost. ~9% of width is the
|
|
physically-derived starting point; **tune by eye** against the playtester
|
|
description rather than treating it as exact, because the on-screen result
|
|
depended on how each pod monitor's H-sync PLL misbehaved — that is not
|
|
recoverable from the binary.
|
|
- `roll(t)` — a time-varying offset so the tear drifts rather than sitting
|
|
static. The original rolled because the monitor never re-locked.
|
|
|
|
Constraints:
|
|
|
|
- **Gauge composite only.** Apply to the `SVGA16` `pixelBuffer`
|
|
(`engine/MUNGA_L4/l4vb16.h:243`) or at the point the strip/surfaces are
|
|
presented — *never* the main 3D view. The main view being clean is a
|
|
confirmed observation, not an assumption.
|
|
- **All secondary surfaces together.** They are bit-planes of one shared
|
|
buffer, so a single buffer-level effect is authentic by construction; do not
|
|
implement it per-MFD.
|
|
- **Keep the existing timing path.** `SpecialEffect` / `ProcessVideoEffects`
|
|
already own the flag and the 0.8 s timeout and are already called per frame.
|
|
`FunkyVideo` should only set/clear state — no timing logic of its own.
|
|
|
|
---
|
|
|
|
## 5. Fidelity constraints (do not "improve" these)
|
|
|
|
1. Duration is `damageType * 0.2f`, **not** a literal `0.8f`.
|
|
2. Fires on `EnergyDamageType` — never add an explicit PPC class check; the
|
|
data authorship *is* the gate.
|
|
3. **Idempotent, non-stacking.** The binary latches on `modified` @`0x4fe0fe`:
|
|
a second PPC hit while the effect is live does **not** re-save the (already
|
|
detuned) value and does **not** extend or double the effect. Our
|
|
`SpecialEffect` currently *does* overwrite `scrambleVideoTimeout`, which
|
|
extends the effect on a second hit — **that is a divergence.** Match the
|
|
binary: ignore re-arm while `scrambleVideoFlag` is set. (The original's
|
|
latch was in `FunkyVideo`; ours must go in `SpecialEffect` or `FunkyVideo`,
|
|
but it must exist.)
|
|
4. Victim-side only. The shooter sees nothing; this runs in the victim's
|
|
damage handler.
|
|
5. Null-guard the gauge renderer — the binary does, and headless/bench runs
|
|
have none.
|
|
|
|
---
|
|
|
|
## 6. Verification
|
|
|
|
- **Headless:** add a one-line log in the new branch; fire a PPC at a dummy
|
|
with `BT_DMG_LOG`. Expect exactly one arm per PPC message, zero for laser /
|
|
autocannon / missile / Gauss.
|
|
- **Non-stacking:** two PPC hits ~0.2 s apart must produce one 0.8 s effect
|
|
measured from the *first* hit, not 1.0 s or two effects.
|
|
- **Live:** confirm every secondary MFD scrambles together and the main view
|
|
stays clean. Have the playtesters who filed the report compare — they are
|
|
the only ground truth for `k` and `roll`.
|
|
- **Against the original (optional, decisive):** in the DOSBox-X fork, log
|
|
writes to CRTC index 0 via `0x3D4`/`0x3D5` during a real BT mission.
|
|
Prediction: exactly one write of `saved-9` per PPC strike, one restore 0.8 s
|
|
later, zero for every other weapon.
|
|
|
|
---
|
|
|
|
## 7. Gotchas
|
|
|
|
- Do **not** confuse this with `SVGA16::FlashPalette` (the pixel-mask cycler on
|
|
ports `0x302/0x30A/0x312`). That machinery is linked and its per-frame cycler
|
|
runs, but `FlashPalette` @`0x46d5f4` has **zero call sites and zero
|
|
address-of references** in `BTL4OPT.EXE` — BT never arms it. **RP does**
|
|
(`RPL4OPT.EXE` @`0x4addce`, palette 1, rate 2.0, masks `{FF,BF,7F,3F}`) for
|
|
alarm blinking. Wrong mechanism, wrong game.
|
|
- The `LampTesla1/2/3` "solid-state relays" in `L4CTRL.HPP` are **not**
|
|
involved and are driven by nothing in the surviving tree.
|
|
- Scope note: callers of gauge-renderer vtable slot 19 were not exhaustively
|
|
enumerated (virtual dispatch), so another arming site may exist. The
|
|
low-level path *is* exhaustive — @`0x47d76d` has one caller, @`0x46d840` two
|
|
(set @`0x47002b`, restore @`0x470076`).
|
|
|
|
## References
|
|
- `context/gauges-hud.md` §"PPC HIT = a deliberate CRTC horizontal-sync DETUNE"
|
|
- `context/combat-damage.md` §`Mech::TakeDamageMessageHandler` (Energy branch)
|
|
- `HISTORY.md` (TeslaRel410) §"Anatomy of a surviving weapon — the PPC"
|