diff --git a/context/combat-damage.md b/context/combat-damage.md index 10b7b85..d3002f6 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -637,6 +637,16 @@ Benches: `scratchpad/night12/scorekill.sh` (cross-node kill: killer `kills=1 awa victim respawns, death #1 single-cycle) + `scoreself.sh` (#134 negation). Collision-death tail fallthrough is inspection-tier [T3] — shares the benched tail code; field wall-deaths exercise it. +**The handler's OTHER damageType branch — `damageType==4` (Energy) = the PPC cockpit-sync glitch +[T1, 2026-08-06].** Between the collision divert and the burst loop sits a second type test +@`0x4a03f3`: `cmp [esi+0x2c],4 / jne 0x4a0423`. On a match it calls the gauge renderer's vtable +slot 19 with `((float)damageType × 0.2, 0)` = `(0.8f, 0)`, which detunes the **VGA CRTC Horizontal +Total by −9** for 0.8 s — every secondary cockpit display loses horizontal sync, the main VPX view +is untouched. `EnergyDamageType` is authored on **exactly the 14 PPC/ERPPC records and nothing +else**, so this is structurally PPC-exclusive. It fires **once per damage message** (outside the +burst loop). Full chain + addresses: [[gauges-hud]] §"PPC HIT = a deliberate CRTC horizontal-sync +DETUNE"; port spec: `phases/phase-14-ppc-sync-distortion.md`. **Not yet implemented.** + ## (HISTORICAL — the gap as found 2026-07-29, superseded above) [T1] The authored crit machinery exists and is reconstructed — `Mech__DamageZone::CriticalHit @0049ccc4` (half the damage to armour, half to ONE critical subsystem chosen by `criticalWeight`, capped by diff --git a/context/gauges-hud.md b/context/gauges-hud.md index 55d65a7..423afc0 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -13,6 +13,7 @@ open_questions: - "Upper-MFD PRESET pages RESOLVED 2026-07-19 (Gitea #9): SetPresetMode table @0051dbf0 re-decoded (little-endian -> ModeMFD bits 0-14), per-MFD pod button banks identified from the .CTL dump, desktop J/K/L cycle wired" - "Always-active msg-4 records IDENTIFIED 2026-07-20 (glass input audit): 0x2C = Reservoir InjectCoolant (the flush button), 0x2F/0x2E/0x2D/0x2B/0x2A/0x29 = Condenser1-6 MoveValve, 0x1A-0x1D = GeneratorA-D ToggleGeneratorOnOff (@0050fb90; wired 2026-07-25, powersub.cpp); plus 0x13 = Mech DuckRequest (CROUCH -- COMPLETE 2026-08-06, [[locomotion]]), 0x28 = Mech BalanceCoolant, 0x12/0x14 = ThermalSight/Searchlight toggles (searchlight visuals done 2026-08-05) -- see pod-hardware.md + docs/GLASS_COCKPIT.md; statuses re-swept 2026-08-06" - "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)" + - "PPC `scrambleVideo` NOT IMPLEMENTED (found 2026-08-06 [T1]): a PPC hit scrambles every secondary display for 0.8 s via a CRTC Horizontal-Total detune. Engine half already present under the original names (SpecialEffect/scrambleVideo, L4GREND.cpp:806+832, called per frame at :370); missing = the trigger in Mech::TakeDamageMessageHandler (damageType==4) and the visual (SVGA16::FunkyVideo is STUBBED, L4VB16.cpp:6358). Also note our SpecialEffect re-arms the timeout on a second hit — the binary latches and does NOT stack. Spec: phases/phase-14-ppc-sync-distortion.md" --- # Cockpit Gauges / MFD HUD @@ -755,6 +756,86 @@ pooling fix) — an alarm that cannot acquire a source is silent. ⚠ The bench cannot confirm audibility: it runs with no audio device (`live=0 pooled=0`), so the control chain is verified but final playback is not. +## PPC HIT = a deliberate CRTC horizontal-sync DETUNE on every secondary display (2026-08-06) [T1 disasm-verified] + +**NOT YET IMPLEMENTED IN THE PORT.** Hand-off spec: +`phases/phase-14-ppc-sync-distortion.md`. Reported by playtesters as "being hit +by a PPC makes it look like all of the secondary CRTs were being degaussed" — +main (VPX) view unaffected, PPC strikes only. Both observations are exactly +what the binary does. + +**The gate is the damage TYPE, and only the PPC has it.** A `BTL4.RES` +subsystem census gives `damageType` 4 (`EnergyDamageType`) = **14 records, +every one PPC or ERPPC**; everything else is Ballistic (16), Explosive (30), +Laser (78). So a branch keyed on type 4 is structurally PPC-exclusive. + +The chain, on the **VICTIM's** machine (all `@` from `BTL4OPT.EXE`, +md5 `a97075bcb5634d13263e9ad5a2b96fd0`): + +1. `Mech::TakeDamageMessageHandler` @`0x4a0230`, branch @**`0x4a03f3`** — sits + between the collision divert and the burst loop, so it runs **once per + damage message**, not per burst: + ``` + 004a03f3 mov ecx,[esi+0x2c] ; damage.damageType + 004a03f6 cmp ecx,4 ; EnergyDamageType + 004a03f9 jne 0x4a0423 ; everything else -> burst loop + 004a03fb mov eax,[0x4efc94] ; the 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, args (0.8f, 0) + ``` + ⚠ The duration is **derived, not constant**: `(float)damageType × 0.2`. +2. Gauge-renderer vtable @`0x51cebc`, slot 19 (`+0x4c`) = @**`0x46ffcc`** + (an `L4GaugeRenderer` method — the 0x46xxxx MUNGA_L4 range, so the + capability is shared-engine; BT is what wires it to Energy damage). + Second arg must be 0 (`sub eax,1; jae ret`). Body: `svga16 = this[+0x1c52c]` + (null-guarded — same member RP fetches for its `FlashPalette`), then + `this[+0x1c534] = 1` (active) and `this[+0x1c538] = now + 0.8 s` in ticks. +3. @`0x46d840` — thin wrapper, drops `this`, forwards the flag. +4. @**`0x47d76d`** — the payload, straight VGA CRTC I/O: + ``` + out(0x3D4,0x11); v=in(0x3D5); out(0x3D5, v & 0x7F) ; unlock CRTC regs 0-7 + out(0x3D4,0x00) ; CRTC 0 = HORIZONTAL TOTAL + if (arg==0) { out(0x3D5, saved); modified=0; } ; restore + else if (!modified) { modified=1; saved=in(0x3D5); + out(0x3D5, saved-9); } ; <<< shorten the scanline + out(0x3D4,0x11); out(0x3D5, v) ; restore write-protect + ``` + Globals: `modified` @`0x4fe0fe`, `saved` @`0x4fe0ff`. The `modified` latch + makes it **idempotent** — overlapping PPC hits do NOT stack, and a second + hit does not re-save an already-detuned value. +5. @**`0x47003c`** (per frame): `if (active && now >= expiry) { active = 0; + SVGADistortSync(svga16, 0); }` — restores the saved Horizontal Total. + +**Why it reads as a degauss, and why only the secondaries.** CRTC register 0 is +the character-clock count per scanline — it *is* the horizontal scan frequency. +−9 drives every attached monitor's horizontal oscillator off frequency: the +image shears/rolls/wobbles until it re-locks, then snaps back 0.8 s later. All +six secondary displays are derived by the VDB from that one VGA's timing, so +they glitch **together**; the main view comes off the Division VPX card on an +independent timing chain and is untouched. No relay, no VDB register, no +palette work — the VDB just propagates a deliberately corrupted sync. (The +`LampTesla1/2/3` "solid-state relays" in `L4CTRL.HPP` are NOT involved and are +driven by nothing in the surviving tree.) + +⚠ **Do not confuse this with the `SVGA16::FlashPalette` pixel-mask cycler** +(`flashRate`/`mask[4]`, ports `0x302/0x30A/0x312`). That machinery is linked and +its per-frame cycler runs in BT, but `FlashPalette` @`0x46d5f4` has **zero call +sites and zero address-of references** in `BTL4OPT.EXE` — BT never arms it. +**RP does**: `RPL4OPT.EXE` @`0x4addce` calls `FlashPalette(palette 1 = +SecondaryPalette, rate 2.0, masks {FF,BF,7F,3F})` from its gauge-renderer ctor — +hardware-assisted alarm blinking by masking off the top two pixel bits. Same +pods, so it is an easy source of cross-game misattribution. + +Scope note: callers of vtable slot 19 were not exhaustively enumerated (virtual +dispatch); the PPC site was found via the three `application+0x4c` uses +(`0x4a03fb` here, `0x4cc3be` / `0x4d1559` unrelated). The low-level path IS +exhaustive — @`0x47d76d` has exactly one caller, and @`0x46d840` exactly two +(set @`0x47002b`, restore @`0x470076`). + ## Key Relationships - Full history: `docs/GAUGE_COMPOSITE.md`; reticle recovery: `phases/phase-02-dpl2d-reticle.md`. - Uses: [[attribute-pointer]] + [[reconstruction-gotchas]]; reads [[subsystems]] state. diff --git a/phases/phase-14-ppc-sync-distortion.md b/phases/phase-14-ppc-sync-distortion.md new file mode 100644 index 0000000..acf5a1a --- /dev/null +++ b/phases/phase-14-ppc-sync-distortion.md @@ -0,0 +1,212 @@ +# 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. + +```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"