From b499c88cb7166f2de88f904ccfef7ec155714cd8 Mon Sep 17 00:00:00 2001 From: Cyd Date: Wed, 12 Aug 2026 23:27:09 -0500 Subject: [PATCH] BT410 5.3.137: the ammo cooks off -- a mech can finally be gutted by its own magazine The blocking identity is settled: DamageZoneClassID == 78 == 0x4e, computed from our own VDATA enum, so the binary's collector filter selects DAMAGE ZONES and the blast is shared across the mech's zones. Landed: AmmoBin::AmmoBinSimulation -- a Performance the class never had, installed under the same master+dynamic gate the binary's ctor uses -- and AmmoBin::CookOff. A bin whose fed weapon reaches heat failure latches a randomised fuse; when it burns down the bin is destroyed and its remaining rounds go off inside the mech, damage scaled by rounds remaining and SHARED across the zones, every victim announced. Correcting my own 5.3.136 write-up: heat clearing does NOT cancel the countdown. Both the arm gate and the cancel read the same cell -- the ammo alarm settled on spent -- so once armed, only spending or destroying the bin calls it off. Letting the gun cool does not save you. Live on the rig: three bins arm, the fuse spread sends them off on different frames rather than together, 20 rounds x 25 = 500 damage over 22 zones from one bin and 16 x 50 = 800 from another, feeding straight into the existing destruction cascade -- Searchlight and ThermalSight destroyed as a consequence. [T1] recorded: the per-round charge is a stand-in. The binary reads it from the bin's streamed explosion record, in the block our cookOffState reserves and nothing parses yet; we use the fed weapon's authored per-shot damage until that record is read. And a mistake worth keeping in the notes: the first attempt sourced the charge from heatPerRound, a heat figure of about 3e-9, so the detonation computed 4.8e-08 of damage and did precisely nothing -- a mechanic that fires, announces itself, and is silently inert. Only printing the arithmetic caught it. Log the numbers, not just the event. Co-Authored-By: Claude Fable 5 --- .../render-bridge/pod_render_cookoff.conf | 89 +++++++ restoration/source410/BT/AMMOBIN.CPP | 235 ++++++++++++++++++ restoration/source410/BT/AMMOBIN.HPP | 20 ++ restoration/source410/BT/MECH4.NOTES.md | 47 ++++ restoration/source410/BT/MECHWEAP.HPP | 6 + 5 files changed, 397 insertions(+) create mode 100644 emulator/render-bridge/pod_render_cookoff.conf diff --git a/emulator/render-bridge/pod_render_cookoff.conf b/emulator/render-bridge/pod_render_cookoff.conf new file mode 100644 index 00000000..fd9855f4 --- /dev/null +++ b/emulator/render-bridge/pod_render_cookoff.conf @@ -0,0 +1,89 @@ +# pod_render_norio.conf -- pod_render_rec with the RIO serial port OFF. +# +# The emulator log shows a steady stream of serial1 RX OVERRUN errors on +# the RIO pipe, and controls post their events at HighEventPriority +# UNCONDITIONALLY (CONTROLS.HPP:250) -- so a chattering RIO port would +# flood a priority the background pump always serves first, starving the +# priority-0 renderer events the load gate waits on. This conf tests that +# by removing the port entirely. Everything else is identical to the rec +# conf, so a launch here and a hang there isolates the RIO. +# +[sdl] +output=opengl +# higher,higher not highest: HIGH_PRIORITY_CLASS starved the host desktop; +# with the retry patches a rare dropout self-recovers (see gauge_rio.conf). +priority=higher,higher +[dosbox] +memsize=32 +machine=svga_s3 +[cpu] +core=dynamic +cputype=pentium +cycles=max +[sblaster] +sbtype=sb16 +sbbase=220 +irq=5 +dma=1 +hdma=5 +[mixer] +# match the EMU8000s' native rate (no resample) and buffer ~60ms so brief +# emulation-thread stalls (RIO retry recovery) don't audibly chop +rate=44100 +blocksize=1024 +prebuffer=60 +[serial] +# RIO on COM1 with the low-latency options (rxpollus/rxburst) so the board's +# few-ms ACK deadline is met; plasma display on COM2 (real pod has both). +# VWE fork namedpipe backend (com0com/realport retired -- COM1/COM2 gone): +# DOSBox = pipe client (retry), vRIO/vPLASMA apps = servers; an unconnected +# pipe behaves as an unplugged cable so the mission still runs. serialnamedpipe.h +serial1=disabled +serial2=namedpipe pipe:vplasma +# live UNBUFFERED game output: DOS char devices are not buffered, so +# redirecting stdout to COM3 lands every line immediately. A normal +# '> file' redirect stays 0 bytes until the process exits, which hides +# all progress on a run that does NOT crash. +serial3=file file:C:\VWE\TeslaRel410\emulator\render-bridge\podlog.txt +[autoexec] +mount c "C:\VWE\TeslaRel410\ALPHA_1" +c: +cd \REL410\BT +set VIDEOFORMAT=svga +rem production pod card init (PARAMETR.BAT:181-186): DIAGNOSE + AWEUTIL per +rem card -- AWEUTIL /S does the EMU8000 bring-up and DRAM detect the HMI SOS +rem driver relies on; skipping it left the cards uninitialized (silent). +rem aweutil /s SKIPPED for now: it verifies the AWE32 GM ROM, which the +rem emulated cards lack (hangs in a retry loop) -- restore once the ROM is +rem dumped from a real card. diagnose /s kept (passes, sets mixer config). +set BLASTER=A220 I5 D1 H5 P330 T6 +c:\sb16\diagnose /s +set BLASTER=A240 I7 D3 H6 P300 T6 +c:\sb16\diagnose /s +set BLASTER=A220 I5 D1 H5 P330 T6 +set TEMP=c:\ +rem arena1 city mission (TESTARN.EGG: map=arena1, time=day) with the RIO +rem attached; stdout redirected so mission-load progress survives kills. +set BT_JOINTS=1 +set L4VIEWEXT=1 +set BT_STACK_LOG=1 +set BT_MECH_LOG=1 + +set BT_FORCE_COOKOFF=1 + + +set BT_GAUGE_LOG=1 + +set BT_FORCE_TURN=0 + +set HEAPSIZE=15000000 +set L4GAUGE=640x480x16 +call setenv.bat r s n p +32rtm.exe -x +BTL4REC.EXE -egg testarn.egg > COM3 + +echo GAME-RC=%errorlevel% >> RC.TXT +32rtm.exe -u +echo ALPHA1-RUN-DONE +pause + diff --git a/restoration/source410/BT/AMMOBIN.CPP b/restoration/source410/BT/AMMOBIN.CPP index a9e63fcf..93f87b0e 100644 --- a/restoration/source410/BT/AMMOBIN.CPP +++ b/restoration/source410/BT/AMMOBIN.CPP @@ -19,6 +19,14 @@ # include #endif +#if !defined(MECHWEAP_HPP) +# include +#endif + +#if !defined(RANDOM_HPP) +# include +#endif + Derivation AmmoBin::ClassDerivations( HeatWatcher::ClassDerivations, @@ -79,6 +87,19 @@ AmmoBin::AmmoBin( cookOffState[i] = 0; } + // + // The per-frame cook-off watch, on the master instance only (the + // binary ctor @004bd5c4 installs its performance triple under the + // same MasterInstance + DynamicFlag gate every sibling uses). + // + if ( + (owner->simulationFlags & Entity::InstanceMask) == Entity::MasterInstance && + (owner->simulationFlags & Entity::DynamicFlag) != 0 + ) + { + SetPerformance(&AmmoBin::AmmoBinSimulation); + } + Check_Fpu(); } @@ -119,3 +140,217 @@ void // ammoCount = initialAmmoCount; } + +// +//############################################################################# +// AmmoBinSimulation -- binary @004bd394. The bin watches the GUN IT FEEDS +// (the watch link bound in 5.3.132): once that weapon reaches heat FAILURE +// the bin latches a randomised countdown, and when the countdown expires +// the rounds go off inside the mech. +// +// A SPENT bin neither arms nor keeps a countdown running -- the binary's +// gate is a cell adjacent to the ammo alarm, read as the alarm having +// SETTLED on spent (level 2); ours reads the alarm directly, which is the +// same condition in our simpler alarm ([T1] noted in AMMOBIN.NOTES.md). +//############################################################################# +// +void + AmmoBin::AmmoBinSimulation(Scalar time_slice) +{ + Check(this); + + // + // The mirrored heat watch first -- this is what reads the gun. + // + WatchSimulation(time_slice); + + if (GetSimulationState() == 1) + { + ammoAlarm.SetLevel(2); + } + + Logical + spent = (ammoAlarm.GetLevel() == 2); + + // + // ARM: the fed weapon is cooking and this bin still has rounds. + // + // + // BT_FORCE_COOKOFF=1: treat the fed weapon as cooking, so the whole + // mechanic can be exercised headlessly without first driving a gun + // into real heat failure. + // + Logical + gun_cooking = (heatAlarm.GetLevel() == HeatSink::FailureHeat); + if (getenv("BT_FORCE_COOKOFF") != NULL) + { + gun_cooking = True; + } + + if ( + gun_cooking && + !spent && + fireCountdownStarted == 0 + ) + { + fireCountdownStarted = 1; + cookOffArmed = 1; + // + // The binary stores an absolute deadline (now + a randomised + // delay); we hold the same delay as a COUNTDOWN in milliseconds, + // which needs no clock source and is frame-rate independent. The + // spread is what stops a whole rack going off on one frame. + // + cookOffTime = 1000 + (int)((Scalar)Random * 4000.0f); + + if (getenv("BT_MECH_LOG")) + { + DEBUG_STREAM << "[cookoff] '" << GetName() + << "' ARMED -- fed weapon in heat failure, " + << ammoCount << " rounds aboard" << endl << flush; + } + } + + // + // RUN or CANCEL. + // + if (fireCountdownStarted != 0) + { + if (spent) + { + fireCountdownStarted = 0; + cookOffArmed = 0; + } + else if (cookOffTime > 0) + { + cookOffTime -= (int)(time_slice * 1000.0f); + } + else + { + fireCountdownStarted = 0; + cookOffArmed = 0; + SetSimulationState(1); // the bin is gone + CookOff(); + } + } + + if (ammoCount < 1) + { + ammoAlarm.SetLevel(2); + } + + Check_Fpu(); +} + +// +//############################################################################# +// CookOff -- binary @004bd300 -> @004ac274. The stored rounds go off: +// +// * the damage is authored PER ROUND and scaled by the rounds remaining, +// so a full bin guts the mech and a nearly empty one barely coughs; +// * the bin's own private damage zone is pinned destroyed; +// * the blast is SHARED -- divided by the number of damage zones, not +// applied whole to each (DamageZoneClassID == 78 == the binary's 0x4e +// collector filter, computed from our own VDATA enum); +// * every victim is announced with the authentic +// "ammo explosion damaging " (string @0x50df61). +//############################################################################# +// +void + AmmoBin::CookOff() +{ + Check(this); + + Mech + *mech = (Mech *)GetEntity(); + if (mech == NULL || mech->damageZoneCount < 1) + { + ammoCount = 0; + ammoAlarm.SetLevel(2); + return; + } + + // + // The charge: rounds x the per-round damage. + // + // [T1] THE PER-ROUND FIGURE IS NOT SOURCED YET. The binary reads it + // from the bin's streamed EXPLOSION RECORD (+0x1f4, the second field + // of the block our `cookOffState[12]` reserves and nothing parses), + // then writes the product back into a copy of that record. Until + // that record is parsed we stand in the FED WEAPON's authored + // per-shot damage -- the right order of magnitude and the physically + // sensible reading, but a stand-in; the first pass over the ammo + // resource block should replace it. + // + // (The first attempt used `heatPerRound`, a HEAT figure of about + // 3e-9, which made the whole detonation a silent no-op -- caught only + // because the soak printed the arithmetic.) + // + Scalar + perRound = 0.0f; + { + MechWeapon + *fed = (MechWeapon *)watchedLink.Resolve(); + if (fed != NULL && fed->IsDerivedFrom(MechWeapon::ClassDerivations)) + { + perRound = fed->DamageAmountOf(); + } + } + Scalar + charge = (Scalar)ammoCount * perRound; + + // + // Our own zone goes first, before the blast is shared out. + // + if (damageZone != NULL) + { + ((::DamageZone *)damageZone)->damageLevel = 1.0f; + } + + Scalar + share = charge / (Scalar)mech->damageZoneCount; + + if (getenv("BT_MECH_LOG")) + { + DEBUG_STREAM << "[cookoff] '" << GetName() << "' DETONATES: " + << ammoCount << " rounds x " << perRound + << " = " << charge << " shared over " + << mech->damageZoneCount << " zones (" << share + << " each)" << endl << flush; + } + + for (int z = 0; z < mech->damageZoneCount; ++z) + { + if (mech->damageZones[z] == NULL) + { + continue; + } + + Damage + blast; + blast.damageType = Damage::ExplosiveDamageType; + blast.damageAmount = share; + blast.damageForce = Vector3D(0.0f, 0.0f, 0.0f); + blast.impactPoint = mech->localOrigin.linearPosition; + + Entity::TakeDamageMessage + hit( + Entity::TakeDamageMessageID, + sizeof(Entity::TakeDamageMessage), + mech->GetEntityID(), + z, + blast + ); + mech->Dispatch(&hit); + + if (getenv("BT_MECH_LOG")) + { + DEBUG_STREAM << "ammo explosion damaging zone " << z + << endl << flush; + } + } + + ammoCount = 0; + ammoAlarm.SetLevel(2); + Check_Fpu(); +} diff --git a/restoration/source410/BT/AMMOBIN.HPP b/restoration/source410/BT/AMMOBIN.HPP index e4132789..7d40c1f9 100644 --- a/restoration/source410/BT/AMMOBIN.HPP +++ b/restoration/source410/BT/AMMOBIN.HPP @@ -44,6 +44,26 @@ static Derivation ClassDerivations; static SharedData DefaultData; + typedef void + (AmmoBin::*Performance)(Scalar time_slice); + void + SetPerformance(Performance performance) + { + Check(this); + activePerformance = (Simulation::Performance)performance; + } + + // + // THE COOK-OFF (binary performance @004bd394, detonation @004bd300 + // -> @004ac274). A bin whose GUN has gone into heat failure starts + // a randomised countdown; when it expires the bin is destroyed and + // its remaining rounds go off inside the mech. + // + void + AmmoBinSimulation(Scalar time_slice); + void + CookOff(); + static Logical TestClass(Mech &); Logical diff --git a/restoration/source410/BT/MECH4.NOTES.md b/restoration/source410/BT/MECH4.NOTES.md index ef797ead..58008464 100644 --- a/restoration/source410/BT/MECH4.NOTES.md +++ b/restoration/source410/BT/MECH4.NOTES.md @@ -705,3 +705,50 @@ its list) first, then land the whole thing in one piece. Note the shape of the find: two dead systems in series hid a third. The watcher binding did not just fix an alarm -- it exposed that the mech has never been able to suffer an ammo explosion. + +## 5.3.137 -- THE AMMO COOK-OFF LANDS + +The blocking identity from 5.3.136 is settled: **DamageZoneClassID == 78 +== 0x4e**, computed from our own VDATA enum, so the collector's filter +selects DAMAGE ZONES and the blast is shared across the mech's zones. + +Also CORRECTING my 5.3.136 write-up: I said heat clearing cancels the +countdown. It does not. Re-reading @004bd394, both the arm gate and the +cancel test the SAME cell (+0x1a8, adjacent to the ammo alarm and read as +the alarm having settled on SPENT). Once armed, a cook-off is only called +off by the bin being spent or destroyed -- letting the gun cool does NOT +save you. + +Landed: `AmmoBin::AmmoBinSimulation` (a Performance the class never had, +installed master+dynamic gated as the binary's ctor does) plus +`AmmoBin::CookOff`. Our fuse is a millisecond COUNTDOWN where the binary +stores an absolute deadline -- same behaviour, no clock-source dependency. + +LIVE (BT_FORCE_COOKOFF=1, pod_render_cookoff.conf): + + [cookoff] 'AmmoBinAFC100' ARMED -- 20 rounds aboard + [cookoff] 'AmmoBinLRM15_1' ARMED -- 16 rounds aboard + [cookoff] 'AmmoBinLRM15_2' ARMED -- 16 rounds aboard + [cookoff] 'AmmoBinAFC100' DETONATES: 20 x 25 = 500 over 22 zones + [cookoff] 'AmmoBinLRM15_2' DETONATES: 16 x 50 = 800 over 22 zones + [critfail] 'Searchlight' DESTROYED ... 'ThermalSight' DESTROYED + +The randomised fuse spreads the rack (the three bins go off at different +times, not on one frame), and the blast feeds straight into the existing +destruction cascade. + +### [T1] THE PER-ROUND CHARGE IS A STAND-IN + +The binary reads it from the bin's streamed EXPLOSION RECORD (+0x1f4 -- +the second field of the block our `cookOffState[12]` reserves and nothing +parses), then writes the product back into a copy of that record. Until +that record is parsed we use the FED WEAPON's authored per-shot damage: +right order of magnitude, physically sensible, but a stand-in to be +replaced by the first pass over the ammo resource block. + +**A caught mistake worth keeping:** the first landing used `heatPerRound` +for the charge. That is a HEAT figure of about 3e-9, so the detonation +computed 4.8e-08 of damage and did nothing at all -- a mechanic that +fires, announces itself, and is silently inert. Only printing the +arithmetic in the soak log exposed it. Log the numbers, not just the +event. diff --git a/restoration/source410/BT/MECHWEAP.HPP b/restoration/source410/BT/MECHWEAP.HPP index 09d744b0..9b5842d9 100644 --- a/restoration/source410/BT/MECHWEAP.HPP +++ b/restoration/source410/BT/MECHWEAP.HPP @@ -212,6 +212,12 @@ unsigned GetWeaponState() { Check(this); return weaponAlarm.GetLevel(); } + // + // The authored per-shot damage. Read by the ammo cook-off as its + // [T1] stand-in for the per-round charge (see AMMOBIN.CPP). + // + Scalar + DamageAmountOf() const { Check(this); return damageAmount; } Logical CheckFireEdge(); virtual void