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>
13 KiB
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) — firesGetGaugeRenderer()->SpecialEffect(scrambleVideo, damageType*0.2f)once per EnergyDamageType message.BT_DMG_LOGprints[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 BOTHDrawDevSurface(surround/dock) andExpandPlaneToBGRA(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: horizontalscalesets a black-bordered collapse band,rollOffscrolls (wraps) within it,shearis a per-row diagonal, and a per-frame LCGshakebounces 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=1loops the whole transition for tuning without a hit;BT_SCRAMBLE_CYCLE=1loops it stepping the roll speed. - C — non-stacking latch:
L4GaugeRenderer::SpecialEffectnow ignores the re-arm whilescrambleVideoFlagis set (matches the binary'smodifiedlatch); 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.
// @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
SVGA16pixelBuffer(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/ProcessVideoEffectsalready own the flag and the 0.8 s timeout and are already called per frame.FunkyVideoshould only set/clear state — no timing logic of its own.
5. Fidelity constraints (do not "improve" these)
- Duration is
damageType * 0.2f, not a literal0.8f. - Fires on
EnergyDamageType— never add an explicit PPC class check; the data authorship is the gate. - 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. OurSpecialEffectcurrently does overwritescrambleVideoTimeout, which extends the effect on a second hit — that is a divergence. Match the binary: ignore re-arm whilescrambleVideoFlagis set. (The original's latch was inFunkyVideo; ours must go inSpecialEffectorFunkyVideo, but it must exist.) - Victim-side only. The shooter sees nothing; this runs in the victim's damage handler.
- 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
kandroll. - Against the original (optional, decisive): in the DOSBox-X fork, log
writes to CRTC index 0 via
0x3D4/0x3D5during a real BT mission. Prediction: exactly one write ofsaved-9per 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 ports0x302/0x30A/0x312). That machinery is linked and its per-frame cycler runs, butFlashPalette@0x46d5f4has zero call sites and zero address-of references inBTL4OPT.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" inL4CTRL.HPPare 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 — @
0x47d76dhas one caller, @0x46d840two (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"