Weapon reload and bolt cycle logic for SWB.Base. Handles starting, finishing and cancelling reloads, per-shell (tube) reload flows, timing, animation and sound rate fitting, perk interactions (Speed Cola, Elemental Pop, Time augments), bolt cycling and boltback animation asyncs.
// ⚠️ NZombies is imported into a SWB.Base file on purpose. The swb weapon base
// is third-party code in its own namespace, and the perk layer reaches into it
// in exactly one place — the reload-speed multiplier below. Importing beats
// fully-qualifying it twice, and beats moving perk logic into the base.
using NZombies;
namespace SWB.Base;
public partial class Weapon
{
public virtual void Reload()
{
// ⚠️ SNAP RELOAD, AND THE RELOAD-START HOOK (2026-10-04, `Weapon.ClassTech.cs`): an empty magazine may refill at once,
// and a reload that does begin spins the cylinder again and ends Momentum's climb. Asked HERE, at the entry, because
// `StartReload` also runs once per shell.
// ⚠️ AND CLOSE CALL (handgun tier 3, 2026-10-04): below 30% health, ANY reload is instant, Snap Reload's way.
if ( TrySnapReload() || TryCloseCall() ) return;
var was = IsReloading;
StartReload();
if ( !was && IsReloading ) ClassTechReloadStarted();
}
/// <summary>
/// Magazine plus whatever the weapon chambers on top.
///
/// ⚠️ ChamberSize supersedes the `BulletCocking` BOOL but does not break it:
/// when ChamberSize is 0 the old bool still means +1, so weapons authored
/// before this keep working untouched.
/// </summary>
int MaxClipSize( bool requireRoundInChamber = false )
{
var chamber = Primary.ChamberSize > 0 ? Primary.ChamberSize : (BulletCocking ? 1 : 0);
if ( requireRoundInChamber && Primary.Ammo <= 0 ) chamber = 0;
// ⚠️ SPIN THE CYLINDER'S x3 CYLINDER (2026-10-04): this cylinder holds three times the rounds.
return ClassTechClip( Primary.ClipSize ) + chamber;
}
/// <summary>
/// Did Speed Cola's m1 "Lucky Hands" proc on the reload in progress.
///
/// ⚠️ LATCHED FOR REPORTING ONLY. The multiplier it caused is already folded into
/// `TimeSinceReload` and the animation length by the time this is readable, so nothing
/// downstream may re-apply it — reading this to scale anything else would double the
/// proc.
/// </summary>
public bool LuckyReload => _luckyReload;
bool _luckyReload;
void StartReload( float? reloadTimeOverride = null, bool pastFull = false )
{
if ( IsReloading || InBoltBack || IsShooting() )
return;
var maxClipSize = MaxClipSize();
// ⚠️ `pastFull`: OVERFILL'S ROUND-AT-A-TIME RELOAD GOING ON PAST A FULL TUBE (9–20 rounds, tier 3, 2026-10-04,
// `OnShellReloadFinish`). Only its next insert asks; every reload still STARTS below full.
if ( (Primary.Ammo >= maxClipSize && !pastFull) || Primary.ClipSize == -1 )
return;
if ( Owner.AmmoCount( Primary.AmmoType ) <= 0 && Primary.InfiniteAmmo != InfiniteAmmoType.reserve )
{
// ⚠️ THE REFUSED RELOAD IS THE MOMENT, not the empty magazine. A player with a full clip
// and no reserve is not out of ammo yet; the one pressing R on nothing is. This is also
// the only spot that distinguishes them.
NZombies.CharacterVoice.Say( "noammo", Owner as NZombies.NZPlayer );
return;
}
if ( IsScoping )
OnScopeEnd();
IsReloading = true;
// Time & Speed
var isEmptyReload = ReloadEmptyTime > 0 && Primary.Ammo == 0;
// ⚠️ Recorded for the sound events — ARC9 gives the empty reload an extra
// slide-forward cue that the normal one does not have.
IsReloadingEmpty = isEmptyReload;
// ⛔ SPEED COLA'S m4 "EVEN KEEL" CHOOSES THE TIME, IT DOES NOT SCALE IT. The augment
// is "an empty reload costs no extra time", which is a choice between two authored
// durations rather than a multiplier — expressing it as a factor would need a
// different factor per weapon and would still be wrong on the prefabs whose empty
// reload is already the faster of the two.
//
// ⚠️ The override still wins. Shell reloads come back through here with an explicit
// time and must not be re-derived from the magazine pair.
var nzReload = Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled );
// ⚠️ THE BODY IS TOLD FURTHER DOWN, ONCE THE RELOAD HAS A LENGTH (2026-10-05, `BodyReloadStep`). A bare `b_reload` used to
// go out from here, before the timing below, so the body's clip could only ever play at its authored speed.
var reloadTime = reloadTimeOverride
?? NZombies.SpeedColaAugments.ReloadTimeFor(
nzReload, ReloadTime, ReloadEmptyTime, isEmptyReload );
var reloadSpeed = ReloadSpeed > 0 ? ReloadSpeed : 1f;
// ⚠️ SPEED COLA APPLIED HERE, on the SPEED rather than the time. This one
// figure divides the timer AND scales the animation (see the note below),
// so a perk that multiplied the time instead would leave the animation
// running at its old rate — the exact desync this method was fixed for.
//
// ⛔ FOUND THROUGH THE HIERARCHY, NOT CAST FROM `Owner`. Owner is
// IPlayerBase and NZPlayer is a sealed Component that does NOT implement
// it, so `Owner as NZPlayer` is not a legal reference conversion. The
// weapon lives under the player, so an ancestor search is both valid and
// independent of what the swb base's interface happens to expose.
//
// ⚠️ `InAncestors | Enabled` rather than an `EverythingIn...` composite.
// Those composites exist but are NOT in Sandbox.Engine.xml, so their exact
// names cannot be checked without a compiler — and there is none available
// right now. These two ARE documented fields, and a flags combination of
// documented members is the version that cannot be wrong about a name.
var nz = nzReload;
// ⛔ M1 FAST HANDS AND m1 LUCKY HANDS, AND THE ROLL HAPPENS EXACTLY ONCE — HERE.
// `RollLuckyHands` is called on this line and nowhere else: rolling inside the
// multiplier would re-roll every time the animation length was recomputed and the
// reload would flicker between speeds. `_luckyReload` is latched only so the report
// and the sound path can see what happened.
_luckyReload = NZombies.SpeedColaAugments.RollLuckyHands( nz );
// ⚠️ m5 FLUID MOTION'S WINDOW OPENS HERE, on the same line as the lucky roll and
// for the same reason: this is the one place a reload is known to be STARTING. Stamping
// it anywhere the reload is merely in progress would re-open the window every frame and
// the burst would last the whole reload again.
NZombies.SpeedColaAugments.OnReloadStarted( this );
// ⛔ ONE ASK, NOT TWO MULTIPLIES. This used to be
// `reloadSpeed *= PerkEffects.ReloadMultiplier(nz)` followed by the augment
// multiplier, so the base x1.35 and M1's x2.86 COMPOUNDED to x3.86. M1 Fast Hands
// is meant to REPLACE the base, not add to it, and a call site that multiplies two
// sources cannot express "replace" — only the perk can know which of its own
// effects wins. `ReloadSpeedFor` returns the settled figure.
reloadSpeed *= NZombies.SpeedColaAugments.ReloadSpeedFor( nz, _luckyReload );
if ( _luckyReload )
Log.Info( $"[nz-aug] speed m1 Lucky Hands — {DisplayName} reloading"
+ $" x{NZombies.SpeedColaAugments.LuckyHandsSpeed:0.#}" );
// ⛔ ELEMENTAL POP FIRES HERE, ON THE ONE LOOKUP ABOVE. This is after every early
// return in this method — already full, no reserve, mid-reload, mid-shot — so the
// burst cannot go off on a reload that never happens. A hook on the input instead
// would have fired every frame the key was held; the note further down this file
// records that `Reload()` is called from `Input.Down`.
//
// ⚠️ `Primary.Ammo` IS STILL THE PRE-RELOAD COUNT at this point — the magazine is
// refilled when the reload FINISHES, not here. That is exactly the number the perk
// scales on, and reading it after the refill would make every burst a zero.
//
// ⚠️ `DamageFor( 0, null )` rather than `Primary.Damage`, so Pack-a-Punch and
// rarity are included. See the perk's own note.
PerkEffects.ElementalPop( nz, Primary.DamageFor( 0f, null ), Primary.Ammo, maxClipSize );
// ⚠ TIMESLIP m5 TIME DILATION, beside Elemental Pop's shock because both fire on the
// START of a reload and both read nothing but the player. Reusing the same site means
// the two cannot disagree about when a reload has begun.
TimeAugments.OnReloadStarted( nz );
// ⛔ FAST HANDS IS STORED AS 1.111, NOT 0.9, AND MUST NOT BE INVERTED HERE.
// "-10% reload time" is a 1.111x SPEED because this figure DIVIDES reloadTime,
// and WeaponTech.cs already holds it in that form (its own note says so). An
// inversion at this call site would make the node slow the reload down —
// Deadshot's ADS multiplier cost this project exactly that bug.
reloadSpeed *= TechEffects.Factor( this, "t1_reload" );
// ⚠️ TOP-OFF (9-20 rounds, tier 4, 2026-10-04): a reload with rounds still in the magazine is 50% faster. The catalogue
// holds the TIME (x0.667) and this figure is a speed, so it divides.
if ( Primary.Ammo > 0 )
reloadSpeed /= System.MathF.Max( 0.01f, TechEffects.Mag( this, "t4_mag_topoff", "partial" ) );
// ⚠️ QUICK BELT (LMG tier 3, 2026-10-04), TOP-OFF'S MIRROR: a reload begun on an EMPTY magazine is 40% faster, the TIME
// (x0.714) divided the same way. It multiplies with Box Magazine's and Speed Loader's `s.reload`, already in the authored
// time (`NZPlayer.ApplyStoredUpgrades`). A round-at-a-time gun takes it on its first insert only.
if ( Primary.Ammo <= 0 )
reloadSpeed /= System.MathF.Max( 0.01f, TechEffects.Mag( this, "t3_lmg_quickbelt", "empty" ) );
// ⚠️ THIS ALSO COVERS THE PER-SHELL GUNS (HS10/KS23/SPAS12) — deliberately.
// OnShellReload and OnShellReloadFinish both come back through StartReload with
// a time override, so every insert gets the multiplier and the node is worth the
// same on a shotgun as on a magazine. The two ShellReload*Anim asyncs below still
// await their raw authored times; that predates this and applies to Speed Cola
// too, so it is not fixed here.
TimeSinceReload = -(reloadTime / reloadSpeed);
// ⛔ THE BODY'S RELOAD GOES OUT HERE, THE FIRST LINE THAT KNOWS HOW LONG THIS STEP TAKES (2026-10-05, `Weapon.BodyReload.cs`).
// Everything above re-times the VIEWMODEL; the third-person body is a separate object on every machine, told through
// `NZNet.PlayerReload`, and it fits its own clip to the same length. Still once per StartReload, so a round-at-a-time
// reload is a step per round, as it always was. The GameObject id travels because that is what other machines resolve.
BodyReloadStep( nzReload, reloadTime / reloadSpeed, reloadSpeed );
// Anim
var reloadAnim = ReloadAnim;
if ( isEmptyReload && !string.IsNullOrEmpty( ReloadEmptyAnim ) )
{
reloadAnim = ReloadEmptyAnim;
}
PlayAnim( reloadAnim, true );
// ⛔ THE CLIP IS FITTED TO THE RELOAD, AFTER THE CLIP IS CHOSEN.
//
// This used to be `PlaybackRate = reloadSpeed`, set ten lines earlier. That
// scaled the animation by the multiplier but against its own AUTHORED length,
// which has no relationship to `ReloadTime` — so a gun told to reload in 1s
// still played a 2.4s animation, and editing ReloadTime moved the moment you
// could fire without moving the hands at all.
//
// Fitting to `reloadTime / reloadSpeed` makes the animation exactly as long as
// the reload, and `ReloadSpeed` then scales BOTH together — which is what the
// multiplier reads as on screen. Same rule as the draw, the holster and the
// knife's swipe.
//
// ⚠️ AFTER `PlayAnim`, never before: `Duration` describes whichever clip is
// currently selected, so reading it first measures the outgoing animation —
// and setting the rate first is what the old code did wrong.
var animTime = reloadTime / reloadSpeed;
// ⚠️ THE SOUND CUES TAKE THE SAME SCALE AS THE HANDS (`_reloadCueScale`), stretched exactly as the clip is:
// `animTime / authored length` when the clip length is known, the speed multiplier alone when it is not (no
// renderer, or not ready).
//
// ⛔ AND THE AUTHORED LENGTH IS NOT `Duration`. Cue times are seconds at 30 fps -- the port's convention since
// the M1911 (`ReloadTime = frames / 30`, ARC9's `t`, the fallback fractions of that) -- while every clip plays
// at Blender's default 24, because `smd_to_fbx.py` never sets the scene's rate (INSTRUCTIONS "A CLIP'S LENGTH
// IS WHAT THE ENGINE PLAYS"). `Duration` is therefore frames / 24, and dividing by it put every cue 20% early.
_reloadCueScale = 1f / reloadSpeed;
// ⚠️ EACH CALL IS ONE TIMED SEGMENT -- the whole magazine reload, or ONE shell (`OnShellReload` and
// `OnShellReloadFinish` come back through here per shell) -- so the per-shell insert cue restarts with it.
_shellCueElapsed = 0f;
_shellCuePlayed = false;
if ( ViewModelRenderer.IsValid() && animTime > 0f )
{
try
{
var d = ViewModelRenderer.Sequence.Duration;
ViewModelRenderer.PlaybackRate = d > 0f ? d / animTime : reloadSpeed;
if ( d > 0f ) _reloadCueScale = animTime / (d * ClipToCueSeconds);
}
catch ( System.Exception )
{
// Renderer not ready — fall back to the multiplier alone.
ViewModelRenderer.PlaybackRate = reloadSpeed;
}
}
// Player anim
HandleReloadEffects();
//Boltback
if ( !isEmptyReload && Primary.Ammo == 0 && BoltBack )
{
TimeSinceReload -= BoltBackTime;
AsyncBoltBack( ReloadTime );
}
}
public virtual void OnReloadFinish()
{
IsReloading = false;
ViewModelRenderer?.PlaybackRate = 1;
// ⛔ BACK TO IDLE ON SUCCESS TOO, NOT ONLY ON CANCEL. CancelReload and
// CancelShellReload have always cleared these; this path never did, so a completed
// reload left its clip set and holding its final frame until some other animation
// played. Reported as three bolt-actions staying raised after reloading until fired:
// their ACT_VM_RELOAD is a 32-frame stub that only lifts the rifle, so its last frame
// is mid-motion. Invisible on the rest of the roster only because most reload clips
// happen to end near neutral.
//
// ⚠️ HERE, ABOVE THE EARLY RETURNS. The InfiniteAmmo branch below returns
// without reaching the end of this method, so cleanup placed at the bottom would run
// for some reloads and not others.
PlayAnim( ReloadAnim, false );
PlayAnim( ReloadEmptyAnim, false );
var maxClipSize = MaxClipSize( requireRoundInChamber: true );
// ⚠️ THE MAGAZINE SETS' "A RELOAD" LANDS HERE (2026-10-04, `Weapon.MagTech.cs`): their round count starts over and Reload
// Shield's 2 s begin. Above the early returns, for the reason the ⚠️ above gives: each of them is a reload finishing.
MagTechReloaded();
// ⚠️ OVERFILL (9–20 rounds, tier 3): a full magazine ON TOP of what is left, never less than a reload to full. -1 without it,
// and every line below is what it always was.
var overfill = OverfillRounds();
if ( Primary.InfiniteAmmo == InfiniteAmmoType.reserve )
{
Primary.Ammo = overfill >= 0 ? Primary.Ammo + System.Math.Max( overfill, maxClipSize - Primary.Ammo ) : maxClipSize;
return;
}
// ⛔ SPEED COLA'S M4 "CONSERVATION" TAKES NOTHING RATHER THAN REFUNDING AFTERWARDS.
// The original watched clip and reserve every tick, spotted a completed reload, and
// gave the ammo back — which needs a per-weapon tracking table and leaves a window
// where the displayed count is wrong. Asking for the rounds and not spending them is
// the same outcome with neither.
//
// ⚠️ THE AMOUNT STILL COMES FROM `TakeAmmo`'s ANSWER on the paying path, because
// reserve may be short of a full magazine. On the free path the request is granted
// in full — that is the augment — but it is still clamped by what the magazine can
// hold, which `maxClipSize - Primary.Ammo` already is.
// ⚠️ ITS OWN OWNER LOOKUP. `OnReloadFinish` is a DIFFERENT METHOD from
// `StartReload`, so that one's `nz` local is out of scope here — reaching for it is
// what broke the first two builds of this augment. Two methods, two lookups.
var nzFinish = Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled );
// ⚠️ A CYLINDER SPUN DOWN FROM ×3 (Spin the Cylinder, 2026-10-04): the rounds it no longer holds go back to the reserve,
// rather than staying in a magazine bigger than its gun.
if ( Primary.Ammo > maxClipSize )
{
var spare = Primary.Ammo - maxClipSize;
Primary.Ammo = maxClipSize;
var reserve = Components.Get<NZAmmo>( FindMode.EverythingInSelf );
if ( reserve.IsValid() ) reserve.Reserve += spare;
return;
}
// ⚠️ OVERFILL'S MAGAZINE ON TOP, taken from the reserve like any reload (`overfill` is -1 without it, below this figure).
var wanted = System.Math.Max( overfill, maxClipSize - Primary.Ammo );
var freeReload = NZombies.SpeedColaAugments.RollConservation( nzFinish );
var ammo = freeReload
? wanted
: Owner.TakeAmmo( Primary.AmmoType, wanted );
if ( freeReload )
Log.Info( $"[nz-aug] speed M4 Conservation — {DisplayName} reloaded free ({ammo})" );
if ( ammo == 0 )
return;
Primary.Ammo += ammo;
}
/// <summary>
/// Stop a reload in progress, keeping whatever was in the magazine.
/// ⚠️ ADDED FOR THIS PROJECT — upstream only has CancelShellReload, which is
/// for the per-shell path. A magazine reload had no way to be interrupted.
/// </summary>
public virtual void CancelReload()
{
if ( !IsReloading ) return;
IsReloading = false;
// ⚠️ AND THE BODY'S CLIP STOPS WITH IT (2026-10-05, `Weapon.BodyReload.cs`): fitted to the whole reload, it would play on.
BodyReloadEnded( 0f, cancelled: true );
ViewModelRenderer?.PlaybackRate = 1;
// ⚠️ Back to idle explicitly. Leaving the reload clip set means it keeps
// playing to the end with IsReloading already false — the gun looks like
// it reloaded and did not.
PlayAnim( ReloadAnim, false );
PlayAnim( ReloadEmptyAnim, false );
}
/// <summary>
/// Stop whichever kind of reload is running, if any.
///
/// ⛔ TWO CANCELS EXIST AND A CALLER OUTSIDE THIS FILE CANNOT KNOW WHICH TO USE.
/// `CancelReload` early-returns unless `IsReloading`; `CancelShellReload` does not check at
/// all, and a magazine weapon put through it would have its `ReloadAnim` stopped while
/// `ReloadEmptyAnim` kept playing. The knife just wants the reload to stop.
///
/// ⚠️ `ShellReloading` IS THE DISCRIMINATOR, not "is the weapon a shotgun" — a tube-fed
/// rifle shell-reloads and a shotgun with a single authored clip does not.
/// </summary>
public virtual void CancelAnyReload()
{
if ( !IsReloading ) return;
if ( ShellReloading ) CancelShellReload();
else CancelReload();
}
public virtual void CancelShellReload()
{
IsReloading = false;
ViewModelRenderer?.PlaybackRate = 1;
PlayAnim( ReloadAnim, false );
// ⚠️ THE BODY CLOSES ITS ROUND LOOP (2026-10-05, `Weapon.BodyReload.cs`) — the end of a reload with no closing clip, or one
// stopped part way. Nothing when no loop is open: this also runs at the end of `AsyncShellReloadEnd`, and unasked.
BodyReloadEnded( 0f, cancelled: false );
}
public virtual void OnShellReload()
{
// ⛔ THIS IS CALLED FROM Input.Down — EVERY FRAME THE KEY IS HELD. StartReload
// early-returns while already reloading, so it looked safe, but the async
// below was being spawned dozens of times per press: each one restarted the
// insert clip after its delay, so you saw several insert motions and only
// one round go in.
if ( IsReloading ) return;
// ⚠️ SNAP RELOAD fills an empty tube at once (2026-10-04), and CLOSE CALL any tube while you are below 30% health.
if ( TrySnapReload() || TryCloseCall() ) return;
// ⚠️ A NEW ROUND-AT-A-TIME RELOAD: its first StartReload below is the body's OPENING, not another round (2026-10-05,
// `Weapon.BodyReload.cs`), whatever the last one left behind.
_bodyShellOpen = false;
// ⛔ THE START CLIP LOADS A SHELL TOO, so it gets its OWN period rather than
// being bundled with the first insert. Bundled as start+insert, the opening
// motion and the first insert shared a single +1 — two animations, one
// round, which reads as "the first shell does not count".
//
// ⚠️ OnShellReloadFinish fires at the end of this period and adds the round;
// the async below hands the animation over to the insert at the same moment,
// so clip and count stay in step from there on.
StartReload( ShellReloadStartTime > 0 ? ShellReloadStartTime : ShellReloadInsertTime );
// ⚠️ THE RELOAD BEGAN: the cylinder spins again, Momentum ends (2026-10-04).
if ( IsReloading ) ClassTechReloadStarted();
// ⛔ THE TWO CLIPS RUN IN SEQUENCE, NOT ON TOP OF EACH OTHER. StartReload has
// just set the INSERT playing for start+insert seconds — so a 0.8s insert
// was being stretched over 2.6s, holding on its last frame and then snapping
// when the next shell restarted it. That snap is the "position resets after
// the first shell".
//
// ⚠️ Play the opening motion FIRST and hand over to the insert once it is
// done, so each clip runs at its authored length.
if ( !string.IsNullOrEmpty( ShellReloadStartAnim ) && ShellReloadStartTime > 0 )
AsyncShellReloadStart();
}
/// <summary>Opening motion, then hand over to the per-shell insert.</summary>
async void AsyncShellReloadStart()
{
PlayAnim( ShellReloadStartAnim, true );
FitClipTo( ShellReloadStartTime );
await GameTask.DelaySeconds( ShellReloadStartTime );
if ( !IsValid || !IsReloading ) return;
// ⚠️ Stop the start clip explicitly — leaving it set means it keeps playing
// underneath and fights the insert for the rest of the reload.
PlayAnim( ShellReloadStartAnim, false );
PlayAnim( ReloadAnim, true );
}
public virtual void OnShellReloadFinish()
{
IsReloading = false;
// ⛔ CONSERVATION APPLIES PER SHELL ON THE SHOTGUNS, AND THAT IS A REAL DIFFERENCE
// FROM THE MAGAZINE PATH. `OnShellReloadFinish` runs once per inserted shell, so a
// 20% chance rolls once per shell rather than once per reload — an 8-shell SPAS
// expects to save about 1.6 shells instead of a 20% chance at all 8. That is
// arguably the fairer reading of "20% chance a reload does not consume reserve" on a
// weapon that reloads a round at a time, and it is stated because the two paths
// genuinely behave differently.
//
// ⚠️ ITS OWN OWNER LOOKUP. This is a separate method from StartReload — the `nz`
// local there is out of scope, and reaching for it is what broke the first build.
var nzShell = Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled );
var hasInfiniteReserve = Primary.InfiniteAmmo == InfiniteAmmoType.reserve
|| NZombies.SpeedColaAugments.RollConservation( nzShell );
// ⚠️ OVERFILL (9–20 rounds, tier 3, 2026-10-04, `Weapon.MagTech.cs`): "a gun that loads one round at a time keeps loading
// until a full magazine's worth has gone in" — counted from this reload's start, past a full tube. -1 without it.
var overfill = OverfillRounds();
// ⚠️ MOON CLIPS, DRUM FEED AND STRIPPER CLIP (2026-10-04): one insert loads every round the tube is missing. One
// round otherwise, exactly as before. With Overfill, every round of the worth still to go.
var rounds = ClassTechWholeLoad
? System.Math.Max( 1, overfill >= 0 ? overfill - _overfillLoaded : ClassTechClip( Primary.ClipSize ) - Primary.Ammo )
: 1;
var ammo = hasInfiniteReserve ? rounds : Owner.TakeAmmo( Primary.AmmoType, rounds );
Primary.Ammo += rounds == 1 ? 1 : ammo;
_overfillLoaded += rounds == 1 ? 1 : ammo;
// ⚠️ EACH INSERT IS A RELOAD LANDING for the magazine sets (`Weapon.MagTech.cs`): the count starts over from the last one.
// ⛔ EXCEPT FOR FRESH MAG (review, 2026-10-04), which counts the whole reload once, on the insert that ends it (below).
MagTechReloaded( fresh: false );
var more = overfill >= 0 ? _overfillLoaded < overfill : Primary.Ammo < ClassTechClip( Primary.ClipSize );
if ( ammo != 0 && more && Owner.AmmoCount( Primary.AmmoType ) > 0 )
{
StartReload( ShellReloadInsertTime, pastFull: overfill >= 0 );
}
else
{
// ⚠️ THE RELOAD ENDS HERE, so this is where Fresh Mag counts it — when it loaded enough (`MagTechTubeReloaded`).
MagTechTubeReloaded();
// ⚠️ Closing motion plays ONCE, and the reload must stay "active" for its
// duration — ending immediately would let the player shoot through it and
// snap the gun back to idle mid-animation.
if ( !string.IsNullOrEmpty( ShellReloadEndAnim ) && ShellReloadEndTime > 0 )
AsyncShellReloadEnd();
else
CancelShellReload();
}
}
/// <summary>
/// Fit the clip that is playing NOW to `seconds`, the way StartReload fits the reload clip to the reload.
///
/// ⛔ THE OPENING AND CLOSING CLIPS RAN AT THE INSERT CLIP'S RATE. StartReload fits the INSERT clip to the start
/// segment, and the start and end clips were then swapped in at that same PlaybackRate -- so an opening clip
/// longer than the insert was cut off when the insert took over (the BO3 Dragoon showed under half of its
/// 2.0 s open against a 0.93 s insert), and a closing clip ran at whatever the last insert happened to need.
/// ShellReloadStartTime / ShellReloadEndTime now mean what they say: that clip, over that time.
/// </summary>
void FitClipTo( float seconds )
{
var r = ViewModelRenderer;
if ( !r.IsValid() || seconds <= 0f ) return;
try
{
var d = r.Sequence.Duration;
if ( d > 0f ) r.PlaybackRate = d / seconds;
}
catch ( System.Exception )
{
// Renderer not ready: leave the rate as it is.
}
}
/// <summary>Play the closing clip, then drop out of the reload.</summary>
async void AsyncShellReloadEnd()
{
PlayAnim( ShellReloadEndAnim, true );
FitClipTo( ShellReloadEndTime );
TimeSinceReload = -ShellReloadEndTime;
// ⚠️ THE BODY CLOSES ITS ROUND LOOP OVER THE SAME TIME (2026-10-05, `Weapon.BodyReload.cs`).
BodyReloadEnded( ShellReloadEndTime, cancelled: false );
// ⚠️ THE CLOSING SOUND LIVES HERE because no cue slot can reach it: `IsReloading` is already false by the time
// this clip plays, and the whole-reload clock cannot know when it starts -- that depends on how many rounds
// went in. So the pump / bolt / gate close of a round-by-round reload was silent on every such gun.
var cue = ShellReloadEndSound is null ? -1f : System.Math.Clamp( ShellReloadEndSoundTime, 0f, ShellReloadEndTime );
if ( cue >= 0f )
{
if ( cue > 0f ) await GameTask.DelaySeconds( cue );
if ( !IsValid ) return;
PlayCue( ShellReloadEndSound, ShellReloadEndSoundCue );
}
await GameTask.DelaySeconds( ShellReloadEndTime - System.Math.Max( cue, 0f ) );
if ( !IsValid ) return;
PlayAnim( ShellReloadEndAnim, false );
CancelShellReload();
}
/// <summary>
/// Work the bolt after a shot: lock firing, play the cycle, unlock.
///
/// ⚠️ InBoltBack is what actually gates the next shot (see Weapon.Shoot) — the
/// animation alone would look right while still letting the player click through
/// it at the weapon's RPM.
/// </summary>
async void AsyncBoltCycle()
{
if ( InBoltBack ) return;
var anim = !string.IsNullOrEmpty( BoltCycleAnim ) ? BoltCycleAnim : BoltBackAnim;
if ( string.IsNullOrEmpty( anim ) || BoltBackTime <= 0 ) return;
InBoltBack = true;
// ⚠️ Let the muzzle flash and shoot anim read before the bolt moves —
// cycling on the same frame as the shot looks like a stutter, not a cycle.
await GameTask.DelaySeconds( 0.05f );
if ( !IsValid ) { InBoltBack = false; return; }
PlayAnim( anim, true );
TimeSince cycled = 0f;
// ⚠️ THE CYCLE'S OWN SOUNDS, EACH AT ITS SECOND, then the rest of the cycle — see BoltCycleCues. Sorted here rather
// than trusted, and a cue past the cycle's end is dropped rather than played after the gun is ready again.
var waited = 0f;
if ( BoltCycleCues is { Count: > 0 } )
{
var cues = new System.Collections.Generic.List<ReloadCue>( BoltCycleCues );
cues.RemoveAll( c => c?.Sound is null || c.Time >= BoltBackTime );
cues.Sort( ( x, y ) => x.Time.CompareTo( y.Time ) );
foreach ( var cue in cues )
{
// ⚠️ AND PAST A CYCLE RECHAMBER RUSH OR SMOOTH ACTION HAS HURRIED (below), the same rule.
if ( cue.Time >= BoltBackTime / CycleRate() ) break;
await GameTask.DelaySeconds( System.MathF.Max( 0f, cue.Time - waited ) );
if ( !IsValid ) { InBoltBack = false; return; }
waited = System.MathF.Max( waited, cue.Time );
PlayCue( cue.Sound, cue.Cue );
}
}
// ⚠️ RECHAMBER RUSH (sniper and shotgun tier 3, 2026-10-04): the rest of the cycle runs at the rate a kill bought,
// ASKED AGAIN WHILE IT WAITS. The kill that buys it lands after this cycle began — the killing shot cycles before its
// bullets — at once on the host and a round trip later on a client. Without the node `RechamberRate` is 1 and the
// cycle waits its authored `BoltBackTime`, as it always did.
// ⚠️ AND SMOOTH ACTION'S ×1.15 (manual action, tier 1, 2026-10-04) on every cycle, multiplied with it (`CycleRate`).
// ⛔ EVERY WAIT IS AT LEAST 20 ms (2026-10-05) — WITHOUT THE FLOOR THIS LOOP FROZE THE GAME FOR GOOD. `cycled` is a
// `TimeSince`, FRAME time, which does not move inside a frame, and each wait was the time left. Once that fell under the
// delay's resolution (about a millisecond) the await came back in the same frame, `cycled` had not moved, and the loop
// spun forever on the main thread: a few shots into any bolt, pump or lever gun (Mosin 1891/30, Bryson 800, .410
// Ironhide). Four host freezes and two frozen clients in one night, every one right after firing such a gun. With the
// floor each turn lands in a later frame; the cycle can end at most 20 ms late.
while ( cycled < BoltBackTime / CycleRate() )
{
await GameTask.DelaySeconds( System.MathF.Max( 0.02f, System.MathF.Min( 0.05f, BoltBackTime / CycleRate() - cycled ) ) );
if ( !IsValid ) { InBoltBack = false; return; }
}
PlayAnim( anim, false );
InBoltBack = false;
}
async void AsyncBoltBack( float boltBackDelay )
{
InBoltBack = true;
// Start boltback
await GameTask.DelaySeconds( boltBackDelay );
if ( !IsValid ) return;
if ( !IsProxy )
PlayAnim( BoltBackAnim, true );
// Eject shell
await GameTask.DelaySeconds( BoltBackEjectDelay );
if ( !IsValid ) return;
CreateBulletEjectParticle( Primary.BulletEjectParticle, "ejection_point" );
// Finished
await GameTask.DelaySeconds( BoltBackTime - BoltBackEjectDelay );
if ( !IsValid ) return;
InBoltBack = false;
}
// ⛔ NOT AN RPC SINCE 2026-10-05: no other machine has a weapon object. See `HandleShootEffects`.
public virtual void HandleReloadEffects()
{
// Player
Owner?.TriggerAnimation( Shared.Animations.Reload );
}
}