Files
BT411/phases/phase-14-ppc-sync-distortion.md
T
CydandClaude Opus 4.8 74a0db4edd PPC hit scrambles the secondary displays (phase-14, all three work items)
Restores the PPC's authentic secondary-display effect: an EnergyDamageType hit
scrambles every secondary cockpit display for 0.8s while the main out-the-window
view stays clean.  Was fully specced (phase-14) with the engine half already
present under the original VWE names; only the trigger and the visual were
missing (the visual STUBBED since 2007).

A -- TRIGGER (game/reconstructed/mech.cpp): in TakeDamageMessageHandler, at the
  binary's @0x4a03f3 position (after the cylinder resolve, before the burst
  loop, so ONCE per damage message) fire
  GetGaugeRenderer()->SpecialEffect(scrambleVideo, damageType*0.2f) on
  EnergyDamageType (==4).  That type is authored on exactly the 14 PPC/ERPPC
  records, so the branch is structurally PPC-exclusive -- no weapon-class check.
  Duration DERIVED from the ordinal (4*0.2==0.8s), not a literal.  BT_DMG_LOG
  prints [ppc-scramble].

B -- VISUAL (SVGA16::FunkyVideo, was the 2007 stub): FunkyVideo now arms
  scrambleActive; new SVGA16::ScrambleRowShift is a per-source-row horizontal
  shear+roll, read by BOTH DrawDevSurface (surround/dock) and ExpandPlaneToBGRA
  (glass windows, native + rotated radar), so all secondary surfaces shear
  together in source space and the main 3D view (separate timing chain) is
  untouched -- the modern stand-in for the VGA CRTC Horizontal-Total detune.
  Tunable: BT_SCRAMBLE_SHEAR (px/line, def 4), BT_SCRAMBLE_ROLL (px/sec, def
  220); BT_SCRAMBLE_TEST=1 forces it on for tuning by eye.

C -- NON-STACKING LATCH (L4GaugeRenderer::SpecialEffect): ignore the re-arm while
  scrambleVideoFlag is set (matches the binary's `modified` latch) -- a second
  PPC during the window no longer extends it.  Was a divergence.

Verified: Release links clean; surround boots + runs with the effect forced on,
every secondary MFD + the radar shear while the out-the-window view stays clean,
no crash (screenshot).  Open (for the playtesters who filed the report): live
PPC-fire confirmation + by-eye shear tuning -- k/roll are not recoverable from
the binary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 16:19:10 -05:00

11 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: SVGA16::FunkyVideo (was the 2007 stub) now arms scrambleActive; SVGA16::ScrambleRowShift (new) is the per-source-row horizontal shear, read by BOTH DrawDevSurface (surround/dock) and ExpandPlaneToBGRA (glass windows), so all secondary surfaces + the rotated radar shear together and the main 3D view (a separate timing chain) is untouched. Tunable: BT_SCRAMBLE_SHEAR (px/line, default 4), BT_SCRAMBLE_ROLL (px/sec, default 220). BT_SCRAMBLE_TEST=1 forces it always-on for tuning by eye without waiting for a PPC hit.
  • 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.
  • Verified: Release links clean; surround boots + runs with the effect forced on, every secondary MFD + the radar shear while the out-the-window view stays clean, no crash. Open: live PPC-fire confirmation (one arm per hit, non-stacking across two hits) + the by-eye shear tuning belong to the playtesters who filed the report — k/roll are not recoverable from the binary.

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"