From 7d7bf24cd075426ee2092d35c928e1461068141d Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:27:30 +1000 Subject: [PATCH] Keep muzzle flashes and attached effects playing past the first minutes spawn_effect takes one of 255 bolt records for every effect played on a tag, and nothing gave them back: run_garbage_collection cleared the effect slot and dropped the record. A minute or two into a match with bots, the muzzle flashes and shell ejects of every shot had used them all, and from then on every bolted effect failed with EffectLimit and was dropped without a word. That took later muzzle flashes, the jets' afterburners and the precision airstrike's cluster bomb, which is played on its tag_origin model and spawns the bomblets' ground explosions, so the airstrike did its damage with no fire or smoke. Garbage collection now puts a dead effect's record back on the free list, and the ring-full check runs before a record is taken, so that refusal no longer leaks one either. Seen on an iPhone build on mp_terminal before the fix: Uzi muzzle flashes at 0:30, none under steady fire at 2:40, and no fire or smoke where a precision airstrike landed about 3 minutes in. With the fix, in another match on the same map, the stealth bomber's fire and smoke showed and the player's SCAR still flashed 4 minutes in. Co-Authored-By: Claude Opus 5.5 --- crates/fx/src/system.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/fx/src/system.rs b/crates/fx/src/system.rs index 34d360e..a9b30db 100644 --- a/crates/fx/src/system.rs +++ b/crates/fx/src/system.rs @@ -860,6 +860,14 @@ impl FxSystemHost { Some(idx as u8) } + fn free_bolt_record(&mut self, index: u8) { + let Some(next) = self.bolt_next.get_mut(usize::from(index)) else { + return; + }; + *next = self.bolt_first_free; + self.bolt_first_free = i32::from(index); + } + pub fn spawn_effect( &mut self, def_name: &str, @@ -873,6 +881,9 @@ impl FxSystemHost { if wants_spotlight && self.spotlight_count >= FX_SPOT_LIGHT_LIMIT { return Err(SpawnFail::TooManySpotlights); } + if self.ring_occupancy() >= FX_EFFECT_HANDLE_RING_SIZE as i32 { + return Err(SpawnFail::RingFull); + } let bolt_index = if bolt == FX_SPAWN_BOLT_NONE { 0xff } else { @@ -883,9 +894,6 @@ impl FxSystemHost { }; let alloc = self.first_free_effect; - if self.ring_occupancy() >= FX_EFFECT_HANDLE_RING_SIZE as i32 { - return Err(SpawnFail::RingFull); - } self.first_free_effect = alloc.wrapping_add(1); let ring_i = (alloc as u32 & (FX_EFFECT_HANDLE_RING_SIZE - 1)) as usize; @@ -986,6 +994,10 @@ impl FxSystemHost { self.set_handle_at_ring(active_index as u32, handle); active_index = active_index.wrapping_add(1); if let Some(slot) = handle_to_slot(handle) { + let bolt = self.effects[slot].bolt; + if bolt != 0xff { + self.free_bolt_record(bolt); + } self.effects[slot] = FxEffectSlot::default(); } }