Disassembly of BTL4OPT.EXE (2026-08-06) recovered the PPC's authentic
secondary-display effect: an EnergyDamageType (==4) hit calls the gauge
renderer's SpecialEffect(scrambleVideo, damageType*0.2f), which detunes the
VGA CRTC Horizontal Total by -9 for 0.8s -- every secondary cockpit display
loses horizontal sync ("looks like the CRTs are being degaussed"), the main
VPX view is untouched. damageType 4 is authored on exactly the 14 PPC/ERPPC
records, so the branch is structurally PPC-exclusive.
- phases/phase-14-ppc-sync-distortion.md: the port spec (trigger + visual +
fidelity constraints + verification).
- context/gauges-hud.md: full disasm chain + the FlashPalette non-confusion.
- context/combat-damage.md: the damageType==4 branch in the damage handler.
NOT YET IMPLEMENTED -- this commit is the spec; the effect is the next work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.8 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: NOT STARTED. Spec only. 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.
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"