Files
BT411/phases/phase-14-ppc-sync-distortion.md
T
CydandClaude Opus 4.8 ee84e14f25 PPC scramble: animate it as a collapse -> hold-roll+shake -> recover -> lock
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>
2026-08-06 17:19:19 -05:00

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) — 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) @0x46ffccSVGA16::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 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"