Commit Graph
11 Commits
Author SHA1 Message Date
Joe DiPrimaandClaude Opus 5 5a1a407ed1 pre-ship: resolve the new audio diagnostic gates ONCE instead of per frame
RunSequence and AudioIdleWatcher::Execute run per sequence per frame, so the
BT_ATTRBIND_LOG probes added while chasing #99 were calling getenv ~2700x/sec on
a 15-sequence mech.  Small, but this is the same path the source-pooling fix
(#32) just bought 10% of frame time in, and an unset diagnostic should cost
nothing.  Cached in function-static flags.

Pre-ship smoke (combat, 2 kills, a respawn, clean teardown):
  frame time 7.18 / 8.27 / 8.71 ms  -- matches the post-pooling 7.79ms baseline
  0 asserts, 0 access violations, 0 audio acquireFails

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:11:06 -05:00
Joe DiPrimaandClaude Opus 5 5a425320b7 #92: map the direct-fire hit->zone path; the FOOT is pickable over only 3% of a mech's height
Oracle, night 7: "Unable to damage the foot panels on Loki via direct fire from
front or side.  Was able to damage on Thor."

FIRST, THE MECHANISM -- it is not what the issue assumed (missing collision
geometry).  A direct-fire hit gets its zone one of two ways, decided at
Mech::TakeDamageMessageHandler by `invalidDamageZone`:

  AIMED   (invalidDamageZone=0): the zone rides in on the message from the
          per-part aim pick, MechSegmentPick (#73, btl4vid.cpp).
  UNAIMED (invalidDamageZone=1): DamageLookupTable::ResolveHit runs the authored
          cylinder lottery -- impact HEIGHT picks a layer, ANGLE picks a pie
          slice, and a weighted random roll picks the zone from that slice.

Measured live, both paths are in use: the Thor logged 16 unaimed hits
(invalidZone=1 zone=-1 -> ResolveHit) alongside aimed ones carrying real zones.

THE FINDING.  MechSegmentPick is a BOUNDING-SPHERE test whose primary key is
SMALLEST RADIUS WINS -- a larger sphere can never beat a smaller one the ray also
grazes.  The leg spheres, dumped in world space and IDENTICAL on both chassis:

    knee  centre y=1.868  r=1.505     spans y 0.364 .. 3.373
    toe   centre y=0.302  r=1.689     spans y -1.387 .. 1.991

The toe sphere is BIGGER than the knee's and they overlap heavily, so the toe can
only win where the ray misses the knee sphere outright -- i.e. below y=0.364.
Against a reference height of 11.16 that is a 0.36-unit window, **3.3% of the
mech's height**, and it sits right on the ground.  Everywhere else a shot at the
foot is credited to the LEG.  That is the reported symptom.

This is a PORT ARTIFACT, not authentic: btl4vid.cpp's own comment concedes the
sphere test approximates "the per-part semantic the 1995 mesh intersection
produced".  Real mesh intersection has no such interference -- aiming at the foot
mesh hits the foot.

NOT EXPLAINED, and stated plainly: the per-CHASSIS asymmetry.  Loki and Thor have
identical leg spheres, identical foot geometry (LOK_LFOT.BGF and THR_LFOT.BGF are
both 3082 bytes with the same token layout), and identical foot layers in their
damage tables (only the upper/cockpit layers differ).  So nothing found here says
the Loki should behave differently from the Thor.  The bench could not settle it
because BT_AIM moves the drawn RETICLE, not the pick ray -- there is currently no
harness to aim the pick at a chosen height.  That harness is the next step.

Diagnostics added:
  [pickgeom]   one-shot dump of every pick sphere in WORLD space (zone, r, centre)
  [pickcand]   which spheres a ray actually threaded, their perpendicular d, and
               which won -- the probe that makes "smallest wins" visible
  [dmgtable]   the whole authored DamageLookupTable: layers, slices, zone weights
  [dmgresolve] per hit: localY, heightRef, layer, theta, resolved zone
  [cylgate]    invalidDamageZone / table pointer / incoming zone at the gate
(the last three under BT_DMGTABLE_LOG, the first two under BT_PICK_LOG)
plus scratchpad/night8/footpick.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:47:30 -05:00
Joe DiPrimaandClaude Opus 5 e058b6acb7 #99: trace the coolant-leak alarm end to end -- it IS implemented; found the real defect
Oracle: "alarm is not sounding despite an active leak" ... "was sounding,
resolved a leak, but there was another".

RETRACTION FIRST.  I previously told the user the coolant alarm was "genuinely
unbuilt -- there is no alarm implementation to starve".  That was wrong, and it
was asserted without checking.  The alarm is fully implemented and the entire
chain works.  Traced live, in order:

  Condenser6.ReportLeak (= HeatSink::coolantActive) changes 0 -> 1
    -> [watchpoll] CHANGE on that exact address
    -> [matchfire] val=1 -> ctl 1  (StartAudioControlID)
    -> [seqstart] the alarm AudioControlSequence, events=25 looped=1
    -> [seqsend] ctl 8/16 (select), 6/1 (volume), 1/0 (START), 2/0 (STOP), ...
       a three-part looping alarm: two chirps then an 8s sustained tone.

The authored design, read from BTL4.RES: 19 subsystems per mech each bind TWO
AudioLogicalTriggers to their ReportLeak flag -- match 1 -> Start, match 0 ->
Stop -- and ALL of them drive ONE shared alarm sequence.

SO WHY WAS IT SILENT IN .674?  Almost certainly the audio source pool, fixed
after that build in ad9dfad.  Every player log from .674 is saturated with
ACQUIRE FAILED (3k-6.5k lines each, starting ~10% in and never recovering); an
alarm that cannot acquire an OpenAL source is silent.  The bench here shows 0
acquire failures on the current build.  ⚠ NOT PROVEN: this bench has no audio
device (pool census reads live=0 pooled=0), so the control chain is verified but
final playback is not.  Field confirmation needed.

THE SECOND SYMPTOM IS A REAL, REPRODUCED DEFECT AND IT IS IN THE AUTHORED DATA.
All 19 subsystems -- Condenser1-6, GeneratorA-D, Myomers, PPC_1/2, ERMLaser_1-3,
SRM6_1/2, Avionics -- share the single alarm sequence, and EACH one's leak-clear
sends an UNCONDITIONAL Stop.  So the moment any one of them stops leaking the
alarm goes silent, even while others are still leaking.  That is exactly what was
reported.  Since both the authoring and the MUNGA watcher code are authentic,
this is 1995 behaviour, and changing it is a deliberate divergence -- flagged for
a fidelity call rather than "fixed" unilaterally.

Diagnostics added (all under the existing BT_ATTRBIND_LOG gate):
  [seqcfg]  extended: the sequence's authored event list
  [seqstart] extended: events / looped / tempo / divisionsPerBeat
  [seqrun]  RunSequence entry state (isRunning / iterator / current event)
  [seqwait] an event exists but is not yet ready, with the time delta
  [seqsend] the sequence actually emitting a control
  [idlewatch] which components receive the idle tick that advances sequences
  [hsparm] extended with &ReportLeak so audio bindings can be correlated
plus scratchpad/night8/leakaudio.sh.

NOTE ON THE PROBES, because it cost real time twice today: [seqrun] first used a
single shared static counter, which the busy sequences consumed so the alarm
sequence never printed -- reading as "RunSequence is never called".  It is now
throttled PER SEQUENCE (runProbeCount).  A capped diagnostic that is silent is
not evidence of absence; the same trap produced a false "seqsend x0" and a false
"the trigger never fires" earlier in this investigation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:07:40 -05:00
Joe DiPrimaandClaude Opus 5 9dd7d48b41 #96: instrument the myomer heat model -- per-mech profile CONFIRMED, overheat cause LOCATED
Two player claims, both now answered from the decomp + measurement rather than
inference.

THE EQUATION (FUN_004b8d18, constants read from the image: _DAT_004b8ee4=0.5,
_DAT_004b8ee8=0.0 (the fabs), _DAT_004b8eec=1.0):

  heat += ratio^2 * (1+damageLevel) *
          [ (1-accEff)*|vy|*m*g*dt        climb   POWER
          + (1-velEff)*(0.5*m*|v|^2)      kinetic ENERGY -- NO dt
          + (1-accEff)*|v|*|a|*m*dt ]     accel   POWER

Our implementation already reproduces this verbatim, dt-less term included.

CLAIM 1 -- "each mech has a unique heating profile".  TRUE, and working, but NOT
by the mechanism the player described.  Measured across thr1/own1/mad1/vul1:
  * the myomer record is IDENTICAL on every chassis
    (velEff 0.995, accEff 0.8, gears 3000/5000/7000/9999, rec 2,
     degradeT 1000, failT 2000, thermalMass 250000)
  * the heat-family COUNT is identical too -- 6 Condensers, 1 HeatSinkBank,
    1 Reservoir, 4 Generators on all four
  * every cooling parameter is byte-identical Thor vs Owens (condenser
    conductance 315000 / mass 420000, bank 231000 / 1.39e6, reservoir
    190000 / 3.42e6)
So there is NO authored per-chassis cooling variation.  The profile emerges from
the equation instead: heat ~ m*v^2, and light mechs are faster.  Measured at
seek 4, flat out:
    thr1  mass 70000  |v| 11.34  kinetic/tick 49026
    own1  mass 35000  |v| 17.22  kinetic/tick 57350
The Owens is HALF the mass and generates 17% MORE drive heat, because v^2 beats
m.  That reproduces the player's OUTCOME (a Thor sustains seek 4, a light
chicken-walker cannot) via speed, not heatsink count.

CLAIM 2 -- "it runs too hot".  The kinetic term carries NO dt: it adds an ENERGY
every TICK, so its contribution per SECOND scales with the tick rate.  Measured
dt here is ~0.017 (~59Hz) and variable.  The other two terms are power terms and
are rate-independent.  On flat ground the climb term is additionally dead --
gravity reads 0 (the carried "environment gravity unwired" open), so hills do not
heat at all right now.
NOT yet established: the 1995 tick rate the dt-less term was calibrated against.
Until that is pinned the OVERHEAT FACTOR is unquantified -- flagged, not guessed.

Adds three diagnostics, all under BT_MYO_LOG:
  [myoheat] now splits climb/kinetic/accel + dt + the kinetic share
  [myoparm] one line per myomer: efficiencies, gears, thermal thresholds
  [hsparm]  one line per heat subsystem: conductance + thermal mass
and three benches (myoheat/myoparm/myocmp) that produced the tables above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:14:55 -05:00
Joe DiPrimaandClaude Opus 5 56f15b569a #98: prove the lamp fix -- table pinned to the image, three condensers verified live
Follows the request to prove the whole fix rather than the one case I had.

1. TABLE, all indices: scratchpad/night8/lamptable_check.py asserts the
   reconstructed arrays against BTL4OPT.EXE and exits nonzero on drift.
       kBTCondenserLamp @0051d058  MATCH  07 2F 2E 2D 2B 2A 29
       kBTPlacementLamp @0051d070  MATCH  29 1A 1B 1C 1D
       kFixed (FUN_004cc148 switch) MATCH  2F 2E 2D 2C 2B 2A
   This covers condensers 2/3/5 without hunting zones for them: there is no
   per-index code path, only the array contents, and those are now pinned.

2. LOOKUP PATH, live: condensers 1, 4 and 6 resolve 0x2F, 0x2B and 0x29 --
   exactly the table.  Condenser 4 is the meaningful control: my reverted "fix"
   would have given 0x2C.  Myomers (eng-page path, 0x25/0x21) and SRM6_1 (quad
   button, 0xd) still annunciate, so other subsystem classes are unregressed.

3. GUARD: condenserNumber is parsed from the name's trailing digit
   (NameTrailingNumber, mirroring the binary's atoi), and every condenser in
   BTL4.RES is Condenser1..Condenser6 -- no Condenser0, no bare name.  So the
   1..6 guard covers every shipped case and slot 0 (0x7) is unreachable.  This
   was the real risk in changing `n >= 0` to `n >= 1`; it is closed.

4. PIXELS: leaklamp_pixel.{sh,py} capture a leaking run and an undamaged control
   run from the cockpit and difference their per-pixel temporal variance.  The
   leak is visibly real -- the COOLANT reservoir drains on screen (S 331->330
   while the control sits at 329).

⚠ WHAT THE PIXELS DID NOT SETTLE, and it is not a testing gap.  Lamp 0x29 is
ALSO kBTPlacementLamp[0]: DAT_0051d058[6] and DAT_0051d070[0] are the SAME int32
-- the two tables abut.  So condenser 6's lamp may not be its own loop button at
all, and slot 6 may be an overrun in the BINARY too (its read is unchecked).
Reproducing it is the faithful choice either way, and we now do exactly what the
binary computes -- but whether a pilot sees loop 6's own button light is a
question only someone who played the original can answer.  Asked on the issue.

CORRECTION: 0b98370 claimed @0051d058 "holds gauge-type name strings, so the
provenance is questionable".  That was MY bug -- a PE section-header field-order
mistake (unpacking VirtualSize/VirtualAddress/SizeOfRawData/PointerToRawData
then destructuring in a different order).  The original reconstruction's
provenance was accurate.  The checker in (1) uses the corrected reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:26:47 -05:00
Joe DiPrimaandClaude Opus 5 c50236a5e6 bench: shorten the leak-lamp run; verified condensers 1/4/6 resolve 0x2F/0x2B/0x29 (the binary's table), plus Myomers + SRM6 eng/quad lamps unregressed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:41:26 -05:00
Joe DiPrimaandClaude Opus 5 0254d9ef34 #98: leaking condensers now flash the RIGHT button -- half of them flashed the wrong one, one flashed nothing
Field reports were all "intermittent": "the display buttons aren't always
flashing or lighting up on leaking components" (Oracle), "I'm getting leaks but
no indicators" (Lynx), "I am getting indicators sometimes" (Sauron).  It is not
intermittent -- it is per-condenser, and three of the six were wrong.

condenserNumber is 1-BASED (verified live: 'Condenser6' reports 6), which the
lamp table's own comment stated.  The guard was `n >= 0 && n < 6` -- a 0-based
bound -- and the table it indexed was missing 0x2C:

    condenser 1..3  -> 0x2F 0x2E 0x2D    correct
    condenser 4     -> 0x2B              WRONG (loop 5's button)
    condenser 5     -> 0x2A              WRONG (loop 6's button)
    condenser 6     -> rejected          NOTHING flashes

So a leak in loop 6 annunciated nowhere, loops 4-5 lit a neighbour's button, and
loops 1-3 were fine -- which from the cockpit reads exactly as "sometimes".

Condenser N drives cooling-loop N, whose six lamps are the same ones the
coolingLoop1..6 codes already resolve through, so the fix routes condensers
through that verified map (BTFixedLampOf(N-1)) and retires the duplicate,
partly-wrong table rather than patching it.

⚠ PROVENANCE: the retired table cited @0051d058 as byte-verified.  That address
holds gauge-type NAME STRINGS, not lamp ids, and a byte search for the six loop
ids as consecutive int32 finds nothing -- so neither the old table nor the new
mapping is byte-verified.  The correction rests on three checkable things: the
1-based numbering (live), the guard contradicting its own documented indexing,
and six condensers mapping onto the six cooling-loop lamps of the verified fixed
map.  [T2 -- behaviour verified, not byte-grounded.]

Also adds the missing diagnostic on the silent path: an alarm item that matched
its condition but resolved no lamp now names the subsystem and why, instead of
returning quietly.  That is what found this, and it immediately surfaced a
SECOND gap for someone to pick up: a destroyed HeatSink (condition 0) resolves
no lamp either, because it is neither Condenser nor Generator nor a
PoweredSubsystem with an aux screen.

Verified (scratchpad/night8/leaklamp.sh, BT_LAMP_LOG):
  before  [galarm] condition 2 ... sub 'Condenser6' -> NO LAMP RESOLVED
  after   [galarm] condition 2 ... -> lamp 0x2a FLASH

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:12:46 -05:00
Joe DiPrimaandClaude Opus 5 ad9dfade88 #32: POOL the OpenAL sources instead of creating and destroying one per sound
Every 4.11.674 player log is saturated with acquisition failures -- 3031, 4657,
5245, 6275, 6571 across five machines.  In Lynx's largest session the first
failure lands 9.3% in and they continue to 96.8%: once it starts it never
recovers for the rest of the match.

CAUSE: RequestAudioChannels called alGenSources() per sound event and
ReleaseSourceSet called alDeleteSources() on release -- create and destroy per
sound.  OpenAL sources are a scarce driver resource (OpenAL Soft caps a context
at 256) and a combat burst churned straight through the ceiling.

The two counters looked contradictory and were the tell: ACQUIRE FAILED always
printed live=256 while the 30s census printed live=6.  Same global, sampled at
different moments -- sources spike to the cap during a burst and drain back
between them.  Churn, not a steady leak.

FIX: generate sources once, up to a cap, and recycle them through a free list.
Release scrubs and parks instead of deleting.  Steady-state play performs no AL
allocation at all.

The scrub is load-bearing, not hygiene: a recycled source carries whatever the
previous owner set, and the engine sets AL_LOOPING per sound
(L4AUDLVL.cpp:327).  Hand a looping source to a one-shot and it plays forever --
which is the "sound stuck looping" family (#51, #5).  Every reusable property is
reset at the single point where a source changes owner.

VERIFIED (scratchpad/night8/audiopool.sh + the two-node mp_burst.sh):
  solo, sustained fire  : 0 failures, pooled 152, reuses 21998
  two nodes, 4 min      : 0 failures on both, pooled 148/149, reuses ~7000 each
⚠ HONEST LIMIT: the bench does NOT reproduce the field failure -- the PRE-fix
binary also scores 0 on it (peak 49 live), because solo/2-node combat is not
dense enough to reach 256.  So this verifies the pool works and allocates
nothing in steady state; it does NOT by itself prove the field failures are
gone.  The five field logs remain the "before".

Peak demand is set by how many audio COMPONENTS are alive (each reserves a
SourceSet of up to 25 voices and holds them), not by audible sounds: measured
high-water 138 solo, 149 two-node.  That scales with player count, so the cap is
set near the driver ceiling (240) and the pool now logs its high-water mark once
per 25-source band -- so the next playtest sizes this from field data instead of
a guess.  Growth also self-limits: if a driver offers fewer sources than the cap,
alGenSources simply fails, growth stops, and the pool recycles what it has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 10:29:04 -05:00
Joe DiPrimaandClaude Opus 5 3a22c334ac bench: two-node CROSS-POD damage verification for the #95 burst change
Projectile damage now Dispatches directly at the victim instead of going through
the shooter's SubsystemMessageManager, so cross-pod delivery had to be proven on
real nodes rather than assumed.

RESULT: cross-pod delivery intact, and the cluster count survives the wire.
Node A fired 38 missile rounds with bursts {1:5,2:6,3:6,4:7,5:7,6:7}; node B
received exactly the matching zone applications {2:12,3:18,4:28,5:35,6:42} --
i.e. rounds x burst, per burst band, exact.  135 of B's 142 explosive
applications carried burst > 1 (the manager path used to drop it to 1).
Energy stayed at burst 1 on both nodes; no crash on either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 05:28:45 -05:00
Joe DiPrimaandClaude Opus 5 3b7c19c232 #95: REVERT the salvo-damage hack; deliver the cluster the way the binary does
Supersedes the mislanch change in efc3e9f, which multiplied the lead round by
missileCount.  That produced roughly the right average total but as ONE lump on
ONE zone, with no scatter and no variance -- not what the arcade does.

WHAT THE BINARY DOES (all byte-verified):
  * MissileLauncher ctor @004bcff0:  burstCount = missileCount;
                                     damageAmount /= missileCount
  * FireWeapon @004bcc60 spawns exactly ONE Missile -- no loop, missileCount is
    never read there.  The salvo IS one cluster round.
  * Missile::Perform rolls how many of the cluster connect right before it
    dispatches (part_013.c:10082):  b = Random(n) + n/4, clamped to n
    -- i.e. between a quarter of the salvo and all of it.
  * It then dispatches DIRECTLY at the struck entity (FUN_004be078:
    param_2->Dispatch(&msg)) -- NOT through the shooter's message manager.
  * Mech::TakeDamageMessageHandler @0x4a0439-0x4a04d8 applies the hit
    burstCount times, RE-ROLLING the struck zone per burst.

Our handler already implements that loop faithfully (mech.cpp, task #80) -- I
wrongly believed it was missing, because the KB asserts as [T1] that "burstCount
is cosmetic for zone damage".  That is half right: DamageZone::TakeDamage really
does ignore burstCount (verified @0041e4e0), but the HANDLER honours it by
calling that function repeatedly.  KB corrected separately.

The actual defect was that the projectile impact path hardcoded burstCount = 1,
and -- worse -- routed damage through the SubsystemMessageManager, whose
consolidation rebuilds the record from a stream carrying only {damageType,
damageAmount, subsystemID}.  DamageInformation has no burstCount field, so the
cluster count was DROPPED in transit no matter what the round carried.

So: dispatch projectile damage directly, as the binary does, carrying the rolled
burst.  This also retires the #84 double explosion architecturally -- the second
blast came from the manager's bundled explosion, and projectiles no longer go
through the manager at all.  The MarkRoundDetonated suppression added in efc3e9f
is therefore dead and is removed.

ALL-WEAPON-CLASS AUDIT (scratchpad/night8/allweap.{sh,py}), one run, all classes
firing at once:
  EXPLOSIVE (missile)  38 zone applications, bursts spread 1x2,2x6,3x9,4x4,5x5,
                       6x12 across ELEVEN zones -- the [n/4..n] roll plus the
                       per-burst zone re-roll, i.e. the cluster scattering
  ENERGY    (beam)      8 applications, burst 1  -- unchanged, no regression
  type4                 8 applications, burst 1  -- unchanged
  COLLISION             0 applications reached armour -- divert intact
Explosions: 10 projectile impacts produce no bundled blast; direct-fire still
queues its own (11 made).

NOT yet verified: cross-pod delivery.  Dispatch reroutes to a replicant on its
own (and the binary relies on exactly that), but the manager routing is gone, so
MP damage should get a two-node run before this ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 04:42:15 -05:00
Joe DiPrimaandClaude Opus 5 efc3e9ff1a #95/#84: missile salvos deliver their FULL authored damage, and stop double-exploding
THE SALVO DAMAGE (#95).  Players measured an LRM 15 landing "3ish points".  The
logs agreed: [projectile] IMPACT damage=3.33333 (Oracle, LRM15) and 3.5 (Rajel,
LRM10).  Those are right PER MISSILE -- 50/15 and 35/10 -- and the bench shows
why the salvo still under-delivers: each launcher pushes N rounds of which
exactly ONE carries damage.

Two individually-correct changes composed into an N-fold shortfall:

  * The ctor does what the binary's MissileLauncher ctor does (@0x3ac/@0x3d4):
        damageData.burstCount    = missileCount;
        damageData.damageAmount /= missileCount;
    The record holds the PER-MISSILE amount plus the count; the arcade
    reconstitutes amount x burstCount when it applies the hit.
  * Task #62 then correctly stopped the port applying the hit once per visual
    round (that was ~missileCount-x too lethal) by damaging only the lead round
    -- but handed it the already-divided amount.

Our DamageZone::TakeDamage is `damageLevel += amount * scale` and drops
burstCount, so the salvo delivered amount/missileCount.  Since the port collapses
the cluster to one damaging round, multiply the count back in there.  Bench: an
SRM6 salvo now lands amt=35 (the authored total) taking a zone 0 -> 0.556, where
it previously landed 5.83.

THE DOUBLE EXPLOSION (#84).  Oracle: "missile appear to register hit explosions
twice, once where target was and again where the target is."  There are two
spawn sites: BTSpawnRoundDetonation at the round's own impact point, and the
message manager's bundled explosion at the CONSOLIDATED point a frame later.
The duplicate was known and thought harmless -- "among a rippled volley it is
invisible" -- which held only while a salvo landed N detonations.  A projectile
now marks its weapon (MarkRoundDetonated) and the consolidation skips queueing a
second blast for it; direct-fire weapons never mark, so lasers/AC keep the
bundled explosion they rely on.  Bench: 4 missile impacts -> 4 SKIPPED, while 11
direct-fire hits still queue normally.

SWEPT CONTACT (#84 tail).  Contact was a 10-unit sphere sampled only at the END
of each step.  With the authored thruster live (#84) field rounds arrive at
v=955 -- a ~16 unit step at 60fps, larger than the radius -- so samples can
straddle the target.  (Pre-#84 rounds flew ~100-300 = a 1.7-5 unit step and could
never skip it: the velocity fix exposed this, it did not cause it.)  Now tests
the whole segment travelled and bursts at the point of NEAREST APPROACH, which
also stops the detonation being flung past the target at speed.

RETRACTION: I posted tunnelling as the leading explanation for the lost salvo.
The bench disproves it -- zero fizzles, and the "missing" rounds are the dmg=0
visual rounds of the cluster, which never registered damage by design.  The
sweep is kept as speed-independent robustness, not as the #95 fix.

Bench: scratchpad/night8/salvo.sh (BT_PROJ_LOG + BT_FIRE_LOG).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:59:44 -05:00