swb_base/Weapon.cs

Weapon component for the SWB-based weapon system. Manages view/world models, attachments, deploy/holster/draw timings, animation playback, reload sound cue scheduling, input handling for shooting/aiming/customization, and various fixes/patches for ported models and project-specific techs.

NetworkingFile AccessExternal Download
using System;
using SWB.Base.Attachments;
using SWB.Shared;
using System.Collections.Generic;
using System.Linq;

namespace SWB.Base;

[Group( "SWB" )]
[Title( "Weapon" )]
public partial class Weapon : Component, IInventoryItem
{
	public IPlayerBase Owner { get; private set; }
	public ViewModelHandler ViewModelHandler { get; private set; }
	public PlayerCameraHandler CameraHandler { get; private set; }
	public SkinnedModelRenderer ViewModelRenderer { get; private set; }
	public SkinnedModelRenderer ViewModelHandsRenderer { get; private set; }
	public SkinnedModelRenderer WorldModelRenderer { get; private set; }
	public WeaponSettings Settings { get; private set; }
	public List<Attachment> Attachments = new();

	protected override void OnAwake()
	{
		Tags.Add( TagsHelper.Weapon );

		Attachments = Components.GetAll<Attachment>( FindMode.EverythingInSelf ).OrderBy( att => att.Name ).ToList();
		Settings = WeaponSettings.Instance;
		InitialPrimaryStats = StatsModifier.FromShootInfo( this, Primary );

		// Default BulletType
		if ( Primary is not null && Primary.BulletType is null )
			Primary.BulletType = Components.Create<HitScanBulletInfo>();
		if ( Secondary is not null && Secondary.BulletType is null )
			Secondary.BulletType = Components.Create<HitScanBulletInfo>();

		// Stats
		if ( Secondary is not null )
			InitialSecondaryStats = StatsModifier.FromShootInfo( this, Secondary );
		else
			InitialSecondaryStats = StatsModifier.Zero;

		// Hack: Hide weapon object until position is set when creating world model
		if ( !IsProxy )
		{
			WorldPosition = new( 0, 0, -999999 );
			Network.ClearInterpolation();
		}

		Owner = Components.GetInAncestors<IPlayerBase>( true );
		if ( !Owner.IsValid() )
		{
			Log.Error( $"{ClassName} cannot find owner, destroying!" );
			Destroy();
		}
	}

	protected override void OnDestroy()
	{
		ViewModelRenderer?.GameObject?.Destroy();
	}

	protected override void OnEnabled()
	{
		if ( IsProxy ) return;
		if ( ViewModelRenderer?.GameObject is not null )
			ViewModelRenderer.GameObject.Enabled = true;

		ClearState();

		// ⛔ ON EVERY DEPLOY, NOT ONCE AT SPAWN. There are five separate paths that
		// bring a weapon into existence in this project — give, wall buy, box roll,
		// Pack-a-Punch return, save restore — and `WeaponSettings` is the standing
		// proof that hooking "the" spawn path means missing four of them. Deploy is
		// the one gate they all pass through, and re-applying is idempotent.
		//
		// ⚠️ AFTER `ClearState()`, which resets per-life values; applying first would
		// have the tuning wiped a line later.
		NZombies.WeaponTuning.Apply( this );

		// ⚠️ AFTER the tuning, which may have set a per-weapon tracer chance — this
		// only fills in values the tuning left at zero.
		NZombies.BulletTracers.Apply( this );

		if ( !Owner.IsBot )
			CreateUI();
	}

	protected override void OnDisabled()
	{
		if ( IsProxy ) return;
		if ( ViewModelRenderer?.GameObject is not null )
			ViewModelRenderer.GameObject.Enabled = false;

		if ( ViewModelHandler is not null )
			ViewModelHandler.ShouldDraw = false;

		// Attachments (VM + HUD)
		Attachments.ForEach( ( att ) =>
		{
			if ( att.IsValid() && att.Equipped )
			{
				if ( att.ViewModelRenderer.IsValid() )
					att.ViewModelRenderer.Enabled = false;

				if ( att.CreatedUI )
					att.DestroyHudElements();
			}
		} );

		ClearState();

		if ( Owner.IsValid() )
			Owner.HoldType = HoldTypes.None;

		DestroyUI();
	}

	protected virtual void ClearState()
	{
		// ⚠️ POCKET RELOAD (SMG tier 3, 2026-10-04) ASKS FIRST, while `IsReloading` still says a reload is being cut short
		// (`Weapon.ClassTech.cs`).
		ClassTechPocketReload();

		IsReloading = false;
		IsScoping = false;
		IsAiming = false;
		IsCustomizing = false;

		// ⛔ THE BURST COUNTER IS STATE AND THIS IS WHERE STATE IS CLEARED. It was omitted
		// while the only burst was cancellable on trigger release, which hid the bug:
		// holster at round 4 of an uninterruptible ten-round burst, come back, and the
		// weapon resumes the remaining six rounds from a trigger press it never received —
		// because the latch in CanShoot deliberately does not need the trigger to be down.
		//
		// ⚠️ Called from BOTH OnEnabled and OnDisabled (lines 68 and 110), so one line
		// covers holstering, re-deploying, the Pack-a-Punch destroy-and-respawn and death
		// — the same reason WeaponTuning.Apply is hooked to deploy rather than to spawn.
		burstCount = 0;

		// ⚠️ AND THE PER-CLASS AUGMENTS' IN-HAND STATE (2026-10-04): the aim timers, Single Action's hammer, Momentum, the
		// double tap, High Noon's outlines. Without this a sight held through a swap came back already charged.
		ClassTechClear();

		SetScopeLensCenter( DefaultScopeLensCenter );
	}

	// ⛔ NOT AN RPC SINCE 2026-10-05: no other machine has a weapon object. See `HandleShootEffects`.
	public virtual void OnCarryStart()
	{
		if ( !GameObject.IsValid() || !this.IsValid() ) return;
		GameObject.Enabled = true;
		TimeSinceDeployed = -999f;
	}

	// ⛔ NOT AN RPC SINCE 2026-10-05: no other machine has a weapon object. See `HandleShootEffects`.
	public virtual void OnCarryStop()
	{
		if ( !GameObject.IsValid() || !this.IsValid() ) return;
		GameObject.Enabled = false;
	}

	public virtual bool CanCarryStop()
	{
		return Owner.IsBot || TimeSinceDeployed > 0;
	}

	public virtual (float delay, string anim) GetDrawInfo()
	{
		var delay = 0f;
		var anim = "";

		if ( Primary.Ammo == 0 && !string.IsNullOrEmpty( DrawEmptyAnim ) )
		{
			anim = DrawEmptyAnim;
			delay = DrawEmptyTime;
		}
		else if ( !string.IsNullOrEmpty( DrawAnim ) )
		{
			anim = DrawAnim;
			delay = DrawTime;
		}

		// ── project edit: tech node "t3_deploy" (Fast Deploy) ────────────────
		//
		// ⚠️ AFTER BOTH BRANCHES ON PURPOSE, so it covers the empty-weapon draw
		// (`DrawEmptyTime`) as well as the normal one (`DrawTime`). The node is sold
		// as "this weapon draws instantly"; a gun that came up slowly whenever its
		// clip happened to be empty would read as the node being broken, and the
		// empty draw is exactly the moment you are most likely to be swapping.
		//
		// ⚠️ WRITTEN AS A LITERAL ZERO RATHER THAN READ FROM THE CATALOGUE, and that
		// is not a hardcoded magnitude. The node's Lever in `WeaponTech.cs` is
		// `Weapon.DrawTime = 0` and its stored factor is 0 — a set-to-zero, which
		// `TechEffects.KindOf` classifies `Absolute` so `nz_tech_amp` refuses to
		// exaggerate it. There is no number here to tune, hence `Has` and not
		// `Factor`: a factor of 0 fed into a multiply would look like a magnitude.
		//
		// ⛔ IT CANNOT MAKE THE WHOLE SWAP INSTANT, ONLY THIS HALF OF IT.
		// `NZInventory.HolsterTime` is a static (0.3s) shared by every weapon and
		// player, so a per-weapon node must not touch it. The put-away of whatever
		// you were holding remains; only the bring-up of THIS gun goes to zero.
		if ( NZombies.TechEffects.Has( this, "t3_deploy" ) )
			delay = 0f;

		// ── SPEED COLA m2 "SWIFT DRAW" ───────────────────────────────────────
		//
		// ⚠️ A DIVISOR ON WHATEVER SURVIVED THE NODE, so a Fast Deploy weapon stays at zero
		// (0/2 is 0) rather than the augment reintroducing a delay. Order matters only in
		// that direction; below the node is the safe side.
		//
		// ⚠️ THIS IS HALF A SWAP. `NZInventory.HolsterTime` is the put-away and is a shared
		// static — the augment divides that too, at ITS read site, because a per-weapon
		// write would hand the change to every player. See the note there.
		delay /= NZombies.SpeedColaAugments.SwapSpeedFor( this );

		// ── THE PER-CLASS AUGMENTS' SWAP (2026-10-04) ──
		// `s.swap` is a SPEED (Scout x1.7, Quickscope Stock x1.8, Sawed-Off x1.5), so it divides like Swift Draw's;
		// Quickdraw Holster's "instant" bring-up is a zero like Fast Deploy's. The put-away half is in
		// `NZInventory.SetActive`, which reads the gun being put away — though the player's own keys skip the put-away.
		if ( NZombies.TechStats.Flag( this, "f.instantswap" ) )
			delay = 0f;
		else
		{
			var swap = NZombies.TechStats.Mul( this, "s.swap" );
			if ( swap > 0f ) delay /= swap;
		}

		// ⛔ A GLOBAL OVERRIDE, BECAUSE `DrawTime` IS PER-WEAPON AND THERE ARE 31 OF
		// THEM. Tuning the feel of a weapon switch means comparing values across
		// guns, and editing 31 prefabs per attempt makes that impossible — the
		// override is one number that moves them all, and -1 hands each weapon back
		// its own authored timing.
		//
		// ⛔ AND IT STAYS LAST, BELOW THE TECH CHECK ABOVE. If the node ran after
		// this line it would pin every tech-owning weapon to zero and silently
		// defeat the one instrument that retunes all 31 for comparison.
		if ( DrawTimeOverride >= 0f )
			delay = DrawTimeOverride;

		return (delay, anim);
	}

	/// <summary>
	/// Global draw duration, -1 to use each weapon's own `DrawTime`.
	///
	/// ⚠️ 0 IS A REAL VALUE, NOT JUST A SMALL ONE: it skips the draw animation and
	/// the deploy delay entirely, so the weapon is in hand and firable on the frame
	/// you switch.
	/// </summary>
	public static float DrawTimeOverride { get; set; } = -1f;

	/// <summary>
	/// Tune the draw: `nz_draw_time [seconds]`, 0 for instant, -1 for per-weapon.
	///
	/// ⚠️ THIS IS THE ONE THAT WAS MISSING. `nz_holster_time 0` did make the swap
	/// commit immediately, and the switch still was not instant — because the
	/// INCOMING weapon then played a full-length draw that nothing was scaling.
	/// Reported exactly: *"even at 0 its not instant... we are not being able to
	/// change the speed of the animation."*
	/// </summary>
	[ConCmd( "nz_draw_time" )]
	public static void SetDrawTime( float seconds = -1f )
	{
		DrawTimeOverride = seconds < 0f ? -1f : MathX.Clamp( seconds, 0f, 5f );

		Log.Info( DrawTimeOverride < 0f
			? "[nz] draw time: per-weapon (M1911 0.5s, MPL 0.87s)"
			: DrawTimeOverride == 0f
				? "[nz] draw OFF — weapons appear instantly and fire immediately"
				: $"[nz] draw time: {DrawTimeOverride:0.##}s for every weapon" );
	}

	/// <summary>
	/// Play the put-away, compressed to fit <paramref name="seconds"/>.
	///
	/// ⛔ THE CLIP IS FITTED TO THE WINDOW, NOT THE WINDOW TO THE CLIP. The authored
	/// holster runs longer than the switch is allowed to take, and the switch time is
	/// the designed value — a weapon swap that outlives its own animation is just
	/// input lag with a picture on it. Same rule the knife's swipe follows.
	///
	/// ⚠️ Rate is set AFTER `PlayAnim`, because `Duration` describes whichever clip
	/// is currently selected — reading it first measures the OUTGOING animation.
	/// </summary>
	public void PlayHolster( float seconds )
	{
		var r = ViewModelRenderer;
		if ( !r.IsValid() || string.IsNullOrEmpty( HolsterAnim ) ) return;

		PlayAnim( HolsterAnim, true );

		if ( seconds <= 0f ) return;

		try
		{
			var d = r.Sequence.Duration;
			if ( d > 0f ) r.PlaybackRate = d / seconds;
		}
		catch ( Exception )
		{
			// Renderer not ready — the weapon is going away regardless.
		}
	}

	public virtual void OnDeploy()
	{
		var drawInfo = GetDrawInfo();
		TimeSinceDeployed = -drawInfo.delay;

		// Sound
		if ( !IsProxy )
			PlayCue( DeploySound, DeploySoundCue );

		// Boltback
		if ( InBoltBack )
			AsyncBoltBack( drawInfo.delay );
	}

	public virtual void OnViewModelDeploy()
	{
		var drawInfo = GetDrawInfo();

		// Reset playback rate
		ViewModelRenderer?.PlaybackRate = 1;

		// ⛔ `PlayAnim`, NOT `ViewModelRenderer.Set`. This was the LAST surviving call
		// of the animgraph-parameter route, and it is why weapons appeared to snap
		// into existence on a switch instead of being drawn. `Set` writes an ANIMGRAPH
		// PARAMETER; our ported vmdls carry 41 raw `AnimFile` clips and no graph, so
		// the call set a value nothing was listening to and returned happily. The
		// reload and fire paths were converted to `PlayAnim` when this was first
		// diagnosed; deploy was missed, because a draw that does not play looks like
		// a fast switch rather than like a broken animation.
		//
		// ⚠️ `draw` is compiled into every weapon — `holster` and `holster_a` are in
		// there too, unused, because SWB has no holster path at all.
		// ⛔ NO ANIMATION AT ALL WHEN THE WINDOW IS ZERO. Playing a clip and then
		// scaling it to 0 seconds is a division by zero dressed up as a feature; the
		// honest reading of "instant" is that the draw does not happen.
		if ( drawInfo.delay > 0f && !string.IsNullOrEmpty( drawInfo.anim ) )
		{
			PlayAnim( drawInfo.anim, true );

			// ⛔ THE CLIP IS SCALED TO THE WINDOW. Without this the draw runs at its
			// authored length whatever `DrawTime` says — the two numbers were never
			// connected, so the delay before you could FIRE was configurable while
			// the animation you watched was not. That is the whole of *"we are not
			// being able to change the speed of the animation"*.
			//
			// ⚠️ AFTER `PlayAnim` — `Duration` describes whichever clip is currently
			// selected, so reading it first measures the outgoing one.
			try
			{
				var d = ViewModelRenderer.Sequence.Duration;
				if ( d > 0f ) ViewModelRenderer.PlaybackRate = d / drawInfo.delay;
			}
			catch ( Exception )
			{
				// Renderer not ready; it plays at natural speed this once.
			}
		}

		// Start drawing (We delay by 1 frame to allow the animation to start first)
		async void ShouldDrawDelayed()
		{
			// ⛔ THE 100ms IS NOT FREE, AND IT IS NOT ONE FRAME. Upstream's comment
			// says "we delay by 1 frame"; the code waits a tenth of a second with the
			// viewmodel HIDDEN, which is a visible hitch on every switch and a floor
			// under any "instant" setting. It exists so the first drawn frame is
			// frame 0 of the draw rather than the previous pose — with no draw
			// animation there is nothing to wait for.
			if ( drawInfo.delay > 0f ) await GameTask.Delay( 100 );

			if ( ViewModelHandler.IsValid() )
			{
				ViewModelHandler.ShouldDraw = true;
				OnViewModelDrawn();
			}
		}
		ShouldDrawDelayed();
	}

	/// <summary>Called when the view model starts being drawn</summary>
	public virtual void OnViewModelDrawn() { }

	/// <summary>
	/// Tear the viewmodel down and build it again.
	///
	/// ⛔ ADDED FOR CHARACTER HAND SWAPS, because `CreateModels` runs from `OnStart` and nowhere
	/// else — the hands model is chosen ONCE, when the weapon component starts. Without this,
	/// changing character with a gun already out does nothing at all until the next weapon swap,
	/// which reads as a broken command rather than a deferred one.
	///
	/// ⚠️ IT DESTROYS THE WHOLE VIEWMODEL OBJECT rather than reassigning the hands renderer's model.
	/// The hands are bone-merged to the viewmodel and created alongside it; swapping just the mesh
	/// leaves the merge bound to a skeleton picked for the old one. Rebuilding cannot half-apply.
	/// </summary>
	public void RebuildViewModel()
	{
		if ( IsProxy ) return;

		if ( ViewModelRenderer.IsValid() )
			ViewModelRenderer.GameObject?.Destroy();

		ViewModelRenderer = null;
		ViewModelHandsRenderer = null;
		ViewModelHandler = null;

		CreateModels();
	}

	protected override void OnStart()
	{
		// ⚠️ FIRST: this gun's packed recordings start being cut on a worker thread now (step 4), so they are ready
		// before the first shot. Its draw sound, if it plays sooner, is cut on the spot (GunAudioPacks.Get).
		PrepareGunSounds();

		if ( !IsProxy && Owner.Camera is not null )
		{
			CameraHandler = Components.GetOrCreate<PlayerCameraHandler>();
			CameraHandler.Weapon = this;
		}

		CreateModels();

		// Attachments (enabled via property)
		if ( !IsProxy )
		{
			Attachments.ForEach( att =>
			{
				if ( att.Enable && !att.Equipped )
					att.EquipBroadCast();
			} );
		}

		// Attachments (load for clients joining late)
		if ( IsProxy )
		{
			// Log.Info( "Checking -> " + Network.Owner.DisplayName + "'s " + DisplayName + " for attachments" );
			Attachments.ForEach( att =>
			{
				// Log.Info( "[" + att.Name + "] equipped ->" + att.Equipped );
				if ( att is not null && att.Equipped )
					att.Equip();
			} );
		}
	}

	protected override void OnFixedUpdate()
	{
		// ⛔ BOTH HANDS ARE BUSY WHILE YOU PICK SOMEBODY UP. Reviving is a four-second hold with
		// your back to a horde — that is the risk the interaction exists to create — and being
		// able to keep firing through it removes the whole cost.
		//
		// ⚠️ THE TUCK, NOT A NEW GATE, AND THAT IS WHY IT IS ONE TERM. `ShouldTuckVar` already
		// lowers the viewmodel (`ViewModelHandler` reads it for the tuck pose) AND already blocks
		// firing (`if ( CanPrimaryShoot() && !ShouldTuckVar )`). Inventing a second "cannot shoot"
		// flag would be a second thing for every future gate to remember.
		//
		// ⛔ AND IT IS ONE ASSIGNMENT, WHICH IS THE BUG THIS FIXES. It used to be a second `if`
		// AFTER the tuck block, writing `true` and nothing else. `ShouldTuckVar` is a plain field
		// and the tuck block is its ONLY author of `false` — so on any weapon that block skips
		// (`TuckRange == -1`, mid-deploy, a bot) the flag latched on at the first revive and never
		// came back down. The gun was lowered and unable to fire for the rest of the life of that
		// weapon. User: *"after reviving someone the weapon the reviver was holding stops working
		// permanently."*
		//
		// The previous note here spotted the very hazard it then fell into: it says the assignment
		// sits outside the block "because that block is skipped entirely on a weapon with
		// TuckRange == -1" — correctly identifying that the tuck block cannot be relied on to run,
		// and then relying on it to clear the flag.
		if ( !IsProxy && Owner.IsValid() )
		{
			// ⚠️ SHORT-CIRCUITS PAST `ShouldTuck` WHEN THE GUN DOES NOT TUCK, which is safe because
			// `TuckDist` is a FIELD rather than a local — an unassigned `out` would not compile.
			var walls = !IsDeploying && !Owner.IsBot && TuckRange != -1
				&& ShouldTuck( out TuckDist );

			var reviving = Owner.GameObject.Components.Get<NZombies.NZPlayer>(
				FindMode.EverythingInSelfAndAncestors ) is { RevivingWho.IsValid: true };

			ShouldTuckVar = walls || reviving;
		}
	}


	/// <summary>
	/// Play a viewmodel animation by NAME.
	///
	/// ⛔ SWB DRIVES ANIMATIONS THROUGH AN ANIMGRAPH, AND A PORTED MODEL HAS NONE.
	/// Upstream calls `ViewModelRenderer.Set( "reload", true )`, which sets an
	/// ANIMGRAPH PARAMETER — SWB's own weapons ship a `.vanmgrph` beside the model
	/// that listens for it. Our ported `.vmdl` has 41 `AnimFile` clips and no
	/// graph, so every one of those calls set a parameter nothing was listening to
	/// and the weapon simply never animated. Nothing errored: a parameter that
	/// does not exist is not a failure, it is a no-op.
	///
	/// So: if the renderer has no animgraph, play the SEQUENCE directly — which is
	/// the same route the zombies already use (see INSTRUCTIONS.md, "Sequence
	/// name is not an animation name").
	///
	/// ⚠️ Rewinds `Time` when re-playing the SAME clip. Setting Sequence.Name to
	/// what it already is does nothing, so reloading twice in a row would leave
	/// the second one frozen on the last frame of the first.
	/// </summary>
	public void PlayAnim( string name, bool state )
	{
		var r = ViewModelRenderer;
		if ( !r.IsValid() || string.IsNullOrEmpty( name ) ) return;

		if ( r.UseAnimGraph )
		{
			r.Set( name, state );
			return;
		}

		// ⛔ WITHOUT A GRAPH, NOTHING ENDS A CLIP OR CHOOSES THE NEXT ONE.
		//
		// An animgraph does two jobs SWB never has to ask for: it stops a one-shot
		// at its last frame, and it returns to idle afterwards. Playing sequences
		// raw gives you neither — a sequence LOOPS by default, so `reload` ran
		// forever, and because the reload clip does not start where idle ends the
		// gun jumped position on every loop. Reported as "loops infinitely and the
		// position changes its weird", which is one bug wearing two symptoms.
		// ⚠️ `state == false` does NOTHING, as it did in the version that worked.
		// Playing idle here was part of the same reverted experiment.
		if ( !state ) return;

		PlaySequence( name, loop: name == IdleAnim );
	}

	/// <summary>Names already complained about — a missing clip would otherwise
	/// warn every frame the fallback runs.</summary>
	readonly System.Collections.Generic.HashSet<string> _warnedAnims = new();


	/// <summary>Sprint clips. ⚠️ ADDED FOR THIS PROJECT — ARC9 poses sprinting
	/// with ANIMATIONS (`sprint_in` / `sprint_loop` / `sprint_out`, all ported),
	/// while SWB has only `RunAnimData`, an offset that shoves the gun to a pose.
	/// That is why the run looked wrong in a way no offset could fix: the pose
	/// lives somewhere SWB was not looking.</summary>
	[Property, Group( "General" ), Feature( "Animations" )] public string SprintInAnim { get; set; } = "sprint_in";
	[Property, Group( "General" ), Feature( "Animations" )] public string SprintLoopAnim { get; set; } = "sprint_loop";
	[Property, Group( "General" ), Feature( "Animations" )] public string SprintOutAnim { get; set; } = "sprint_out";

	bool _wasRunning;

	/// <summary>
	/// Choose what the viewmodel should be playing, every frame.
	///
	/// ⛔ ONE PLACE DECIDES, because an animgraph would have been that place. With
	/// raw sequences the transitions are ours: nothing ends a clip, nothing picks
	/// the next one, and two callers both "helpfully" starting idle is how the
	/// reload ended up restarting forever earlier tonight.
	///
	/// Priority: a one-shot in progress is never interrupted, then sprint, then
	/// idle. A reload therefore plays through a sprint rather than being cut off,
	/// and `!IsReloading` below is what keeps the sprint loop from stealing the
	/// pose out from under it.
	/// </summary>
	void TickViewModelState( SkinnedModelRenderer vm )
	{
		var seq = vm.Sequence;
		bool running = Owner.IsValid() && IsRunning && !IsReloading;
		bool finished = !seq.Looping && seq.IsFinished;
		bool nothing = string.IsNullOrEmpty( seq.Name );

		// entering / leaving the sprint — one-shots either side of the loop
		if ( running != _wasRunning )
		{
			_wasRunning = running;

			// ⛔ A RELOAD STARTED MID-SPRINT MUST NOT PLAY `sprint_out`.
			//
			// `running` is `IsRunning && !IsReloading`, so beginning a reload while
			// sprinting flips it false — and this branch then fired `sprint_out`
			// OVER the reload clip that `StartReload` set one frame earlier. The
			// reload ran to completion on its timer with the gun sitting in idle,
			// reported as "the gun reloads but with no reload animation".
			//
			// ⚠️ The state is still recorded, so leaving the reload does not
			// re-fire a stale transition. Only the ANIMATION is suppressed — and it
			// is the right one to drop, because the reload IS the way out of the
			// sprint pose here.
			if ( !IsReloading )
			{
				PlayAnim( running ? SprintInAnim : SprintOutAnim, true );

				// ⚠️ THE WAY IN AND OUT AT THEIR OWN SPEED: the loop before it may have run slowed (MW guns, below)
				vm.PlaybackRate = 1f;
			}

			return;
		}

		if ( running )
		{
			// ⚠️ Only after sprint_in has finished, or the loop would cut the
			// entry clip off on its second frame and the gun would snap.
			if ( nothing || finished )
			{
				PlayAnim( SprintLoopAnim, true );

				// ⚠️ AN MW GUN'S LOOP AT HALF SPEED (`NZombies.MwRunBob.SprintLoopRate`, 2026-10-02, the user: "mw weapons
				// have double" the bounce speed). 1 on every other gun.
				vm.PlaybackRate = NZombies.MwRunBob.LoopRate( this );
			}
			return;
		}

		// ⚠️ `seq.Name != IdleAnim` so a looping idle is not restarted every
		// frame — restarting a clip that is already running is what made the
		// reload appear to loop forever.
		if ( nothing || (finished && seq.Name != IdleAnim) )
			PlayAnim( IdleAnim, true );

		FreezeIdleWhileAiming( seq );
	}

	float _idleHold = -1f;

	/// <summary>
	/// Hold the looping idle clip still while the player is aiming.
	///
	/// ⛔ THE CLIP IS A SECOND, SEPARATE SOURCE OF IDLE MOTION. SWB's procedural breathing already
	/// stops the moment you aim — `HandleIdleAnimation` returns outright on `IsAiming` — but the
	/// MODEL's own idle animation keeps looping underneath it, and on a pack whose idle carries real
	/// movement the gun drifts around the sight while the player is stood perfectly still. The base
	/// clearly intends a still gun in ADS; this makes the clip agree with the procedural half.
	///
	/// ⚠️ FROZEN WHERE IT STANDS, NOT REWOUND TO ZERO. Snapping to the first frame is visible as a
	/// jolt at the exact moment you bring the sights up, which reads worse than the drift being
	/// fixed. Holding the current time is invisible.
	/// </summary>
	void FreezeIdleWhileAiming( SkinnedModelRenderer.SequenceAccessor seq )
	{
		var r = ViewModelRenderer;
		if ( !r.IsValid() || seq is null ) return;

		var freeze = !UseSway && IsAiming && seq.Name == IdleAnim;
		if ( !freeze ) { _idleHold = -1f; return; }

		if ( _idleHold < 0f ) _idleHold = seq.Time;
		seq.Time = _idleHold;
	}

	void PlaySequence( string name, bool loop )
	{
		var r = ViewModelRenderer;
		if ( !r.IsValid() || string.IsNullOrEmpty( name ) ) return;

		// ⛔ VALIDATE THE NAME AGAINST THE MODEL FIRST. Assigning `Sequence.Name`
		// a clip the model does not have does NOT error and does NOT keep the
		// current pose — it drops the renderer to the BIND POSE, which on a `c_`
		// viewmodel authored for GMod is the gun lying on its side. Reported as
		// "if i shoot just once the weapon becomes permanently sideways":
		// `ShootAnim` resolved, `fire` finished, and the fallback then asked for a
		// name that did not, leaving the bind pose stuck there forever.
		//
		// ⚠️ `Sequence.SequenceNames` is the authoritative list — NOT the model's
		// animation list, which is a different set and answers "yes" often enough
		// to look right (INSTRUCTIONS: animations and sequences are different
		// lists).
		// ⚠️ `SequenceNames` THROWS, it does not return null, before the renderer
		// has a scene object — which is the case on the frame the weapon is
		// created, exactly when the idle fallback first fires. An NRE in OnUpdate
		// is a `return`, so this took the whole weapon down with it: no aiming, no
		// firing, one error per frame.
		//
		// ⚠️ Validation is a NICETY — it exists to turn a silent bind pose into a
		// named warning, and a name it cannot vouch for is still played.
		//
		// ⛔ BUT A THROW HERE IS NOT A FAILED CHECK, IT IS A RENDERER WITH NO SCENE
		// OBJECT. This comment used to promise that a failure "skips the check rather
		// than the animation", and the catch fell through on that basis — into writes
		// that need the very thing whose absence made the probe throw. See the catch.
		try
		{
			var names = r.Sequence.SequenceNames;
			if ( names is not null && !names.Contains( name ) )
			{
				if ( _warnedAnims.Add( name ) )
					Log.Warning( $"[swb] '{DisplayName}' has no sequence '{name}' — "
						+ $"leaving the current pose. Model has: {string.Join( ", ", names )}" );
				return;
			}
		}
		catch ( Exception )
		{
			// ⛔ NOT READY MEANS NOT READY FOR THE WRITES EITHER — this used to say
			// "setting the name below is harmless either way" and fall through, which
			// is wrong for the same reason the probe threw: `Sequence` needs a scene
			// object, and WITHOUT ONE `Sequence.Time = 0` throws an NRE from inside
			// `SequenceAccessor.set_Time`.
			//
			// ⚠️ IT TOOK 5.6 HOURS OF PLAY TO FIRE ONCE, because it needs a reload to
			// begin on the exact frame the viewmodel is swapped — pressing R as the
			// mystery box hands over a new gun. Caught by s&box, so the only symptom
			// was one silent reload played with the weapon held still:
			//
			//     NullReferenceException at SkinnedModelRenderer.SequenceAccessor.set_Time
			//        Weapon.PlaySequence -> Weapon.PlayAnim -> Weapon.StartReload
			//
			// ⚠️ RETURNING IS THE WHOLE FIX AND COSTS NOTHING. The next frame the
			// renderer has its scene object and the caller's own idle/anim upkeep asks
			// again — there is no state to unwind and nothing to retry by hand.
			return;
		}

		// ⚠️ Rewind when it is already the current clip. Assigning Sequence.Name
		// the value it already holds does nothing, so a second reload in a row
		// would sit frozen on the last frame of the first.
		if ( r.Sequence.Name == name )
			r.Sequence.Time = 0f;
		else
			r.Sequence.Name = name;

		// ⚠️ AFTER the name is set, not before — the accessor describes whatever
		// clip is currently selected, so setting Looping first configures the
		// OUTGOING animation.
		r.Sequence.Looping = loop;
	}

	/// <summary>
	/// The resting animation. ⚠️ ADDED FOR THIS PROJECT — SWB has no idle name
	/// because its animgraph owns the idle state; with raw sequences something
	/// has to be played when a one-shot ends, or the gun freezes on the last
	/// frame of whatever it just did.
	/// </summary>
	[Property, Group( "General" ), Feature( "Animations" )] public string IdleAnim { get; set; } = "idle";

	protected override void OnUpdate()
	{
		if ( !Owner.IsValid() ) return;

		UpdateModels();
		Owner.HoldType = HoldType;

		TickReloadSounds();
		TickRecoilRecovery();
		TickTriggerHold();

		// ⛔ RETURN A FINISHED ONE-SHOT TO IDLE. Only the reload path tells us it
		// is over (`Set( anim, false )`); firing and drawing never do, so without
		// this the gun would freeze on the last frame of `fire` after a single
		// shot and stay there. The animgraph SWB expects would have handled every
		// one of these transitions.
		//
		// ⚠️ `IsFinished` is only ever true for a NON-looping sequence — which is
		// exactly why PlaySequence sets Looping explicitly rather than leaving the
		// clip's own flag to decide (INSTRUCTIONS records this trap).
		// ⛔ IDLE ALWAYS PLAYS — and now we know WHY it must.
		//
		// Measured on the compiled model (nz_wep_bones, two snapshots):
		//
		//   bind pose      bone0 'j_gun' local = (0, 0, 0)      <- contributes nothing
		//   during reload  bone0 'j_gun' local = (-21.7, -77.1, 33.8)
		//
		// `j_gun` is the bone that PLACES the weapon, and every clip drives it —
		// idle included. The animations carry GMod's viewmodel placement, which
		// is not where SWB puts the object, so the two stack.
		//
		// The bind pose was never the "correct" pose; it is the one state where
		// `j_gun` is identity, i.e. a pose that never occurs on a properly
		// animated weapon. Dialling the offsets against it was calibrating
		// against an artefact, which is why every clip then looked wrong.
		//
		// So: idle runs continuously (as SWB's animgraph would have done), every
		// clip shares that same base, and the offsets are dialled against a gun
		// that is actually animating.
		var vm = ViewModelRenderer;
		if ( vm.IsValid() && !vm.UseAnimGraph )
		{
			try { TickViewModelState( vm ); }
			catch ( Exception )
			{
				// SequenceAccessor throws before the renderer has a scene object.
			}
		}

		if ( !IsProxy && !Owner.IsBot && !IsDeploying )
		{
			// Customization
			// ⚠️ NULL-CONDITIONAL, BECAUSE THE SINGLETON CAN BE ONE FRAME LATE. `NZPlayer`
			// creates it in `EnsureWeaponSettings` on the same frame a weapon is handed over,
			// and if the weapon's update runs first this line throws — which aborts `OnUpdate`
			// and takes every input branch below it with it: aiming, firing, the fire animation.
			// The client's log showed exactly that, `Exception when calling 'Update' on
			// SWB.Base.Weapon`, at the instant it was armed. The `Ensure` call is still the fix;
			// this is so a lost race costs nothing instead of a frame of a dead gun.
			if ( (WeaponSettings.Instance?.Customization ?? false) && !IsScoping && !IsAiming && Input.Pressed( InputButtonHelper.Menu ) && Attachments.Count > 0 )
			{
				if ( !IsCustomizing )
					OpenCustomizationMenu();
				else
					CloseCustomizationMenu();

				IsCustomizing = !IsCustomizing;
			}

			// Don't cancel reload when customizing
			if ( IsCustomizing && !IsReloading ) return;

			if ( IsRunning )
				TimeSinceRunning = 0;

			var wasAiming = IsAiming;
			// ⚠️ `IsRunning`, NOT `Owner.IsRunning`. Owner's is the sprint KEY,
			// which stays down through a jump — so the raw check kept ADS shut
			// in mid-air. SWB's own is the sprint STATE (grounded + up to speed),
			// which is what "running" is supposed to mean here.
			//
			// ⚠️ `&& !IsReloading` ADDED FOR THIS PROJECT. Upstream lets you aim
			// through a reload, which puts the gun in the ADS pose while the
			// reload animation plays from the hip pose — two poses fighting, and
			// the reason it looked broken rather than permissive.
			// ⛔ BULL BARREL'S "CANNOT ADS AT ALL" IS THIS ONE CLAUSE AND NOWHERE ELSE.
			// `IsAiming` is written in exactly two places — here and `ClearState`, which only
			// ever writes false — so this assignment is the single gate on aiming, and
			// everything downstream falls out of it for free: `IsScoping` is derived from
			// `IsAiming` forty lines below, `OnAimStart`/`OnAimStop`, the ADS sensitivity
			// branch, the viewmodel pose, and `GetRealSpread`'s `!IsAiming` hipfire addend —
			// which is what makes the node's x3 spread land on a cone the player can no
			// longer close. `AimAnimData != AngPos.Zero` is the existing precedent for
			// "this weapon does not aim"; this is the same statement made by a node.
			//
			// ⚠️ LAST IN THE CHAIN, AFTER `Input.Down`, ON PURPOSE. `&&` short-circuits left
			// to right and the tech lookup is an ancestor component get plus a prefab resolve
			// plus a dictionary lookup, so putting it last means it runs only on frames the
			// player is actually asking to aim rather than on every frame of every weapon.
			//
			// ⚠️ THREE CONSEQUENCES THAT ARE NOT IN THE CATALOGUE, all flagged to design and
			// none of them softened here: `GetRecoilAngles`' `aimMult` is `IsAiming ? 0.4f :
			// 1f`, so the node silently carries 2.5x the recoil the player knows on that
			// weapon; the WA2000 is the one prefab authoring `Scoping: true` and loses its
			// scope entirely; and NZPlayer's ADS walk penalty can never apply, which is a
			// downside REMOVED.
			// ⚠️ THE TUNING HOLD IS FIRST, AND IT IS THE ONLY THING ALLOWED TO BYPASS THE CLAUSES
			// BELOW. `nz_ads` needs the weapon to STAY in the aim pose while the mouse is busy
			// dragging a slider, and every consequence of aiming — the viewmodel offset, the
			// sensitivity, the spread — has to come with it, or the editor would be showing a pose
			// the game never draws. A tuning static, false by default, and this whole block already
			// runs for the local owner only (`!IsProxy`, fifty lines up).
			IsAiming = NZombies.SckPartsRig.AimHold
				|| (!IsRunning && !IsReloading && AimAnimData != AngPos.Zero && Input.Down( InputButtonHelper.SecondaryAttack ) && !ShouldTuckVar
				&& !NZombies.TechEffects.Has( this, "t4_bullbarrel" )
				// ⚠️ "no aiming at all" (2026-10-04): Gunslinger, Walking Fire.
				&& !NZombies.TechStats.Flag( this, "f.noads" ));

			if ( wasAiming != IsAiming )
			{
				if ( IsAiming )
					OnAimStart();
				else
					OnAimStop();
			}

			if ( IsScoping )
				Owner.InputSensitivity = ScopeInfo.Sensitivity;
			else if ( IsAiming )
				Owner.InputSensitivity = AimInfo.Sensitivity;
			else
				Owner.InputSensitivity = 1f;

			OnAimAssistUpdate();

			if ( IsAiming )
				OnAimUpdate();

			if ( Scoping )
			{
				if ( IsAiming && !IsScoping )
					OnScopeStart();
				else if ( !IsAiming && IsScoping )
					OnScopeEnd();
			}

			// ⚠️ THE PER-CLASS WEAPON TECH'S TICK (2026-10-04): the aim timers, Select Fire's E+R, the Underbarrel Launcher's
			// double tap, High Noon's marks (`Weapon.ClassTech.cs`).
			TickClassTech();

			ResetBurstFireCount( Primary, InputButtonHelper.PrimaryAttack );
			ResetBurstFireCount( Secondary, InputButtonHelper.SecondaryAttack );
			BarrelHeatCheck();

			if ( CanPrimaryShoot() && !ShouldTuckVar )
			{
				if ( IsReloading && ShellReloading && ShellReloadingShootCancel )
					CancelShellReload();

				TimeSincePrimaryShoot = 0;
				Shoot( Primary, true );
			}
			else if ( CanSecondaryShoot() && !ShouldTuckVar )
			{
				TimeSinceSecondaryShoot = 0;
				Shoot( Secondary, false );
			}
			// ⚠️ NOT WHILE E IS HELD ON A SELECT FIRE GUN: E+R is its switch (2026-10-04).
			else if ( Input.Down( InputButtonHelper.Reload ) && !SelectFireHoldsReload )
			{
				if ( ShellReloading )
					OnShellReload();
				else
					Reload();
			}
			else if ( ShouldAutoReload() )
			{
				// ⚠️ THE SAME TWO CALLS THE R KEY MAKES, deliberately. An empty magazine reloads
				// the way it always did -- shell weapons feed a shell at a time, everything else
				// runs the normal reload -- and nothing about the reload itself is special-cased
				// for having been started automatically.
				if ( ShellReloading )
					OnShellReload();
				else
					Reload();
			}

			if ( IsReloading && TimeSinceReload >= 0 )
			{
				if ( ShellReloading )
					OnShellReloadFinish();
				else
					OnReloadFinish();
			}
		}
	}

	protected virtual void UpdateModels()
	{
		// Should draw after deploy
		if ( (IsProxy || Owner.IsBot) && WorldModelRenderer is not null )
		{
			if ( WorldModelRenderer.RenderType != ModelRenderer.ShadowRenderType.On )
				WorldModelRenderer.RenderType = ModelRenderer.ShadowRenderType.On;

			if ( !WorldModelRenderer.RenderOptions.Game )
				WorldModelRenderer.RenderOptions.Game = true;
		}

		if ( !IsProxy && !Owner.IsBot && WorldModelRenderer is not null )
		{
			var worldModelRenderType = Owner.IsFirstPerson ? ModelRenderer.ShadowRenderType.ShadowsOnly : ModelRenderer.ShadowRenderType.On;

			if ( WorldModelRenderer.RenderType != worldModelRenderType )
				WorldModelRenderer.RenderType = worldModelRenderType;

			// Should draw after deploy
			if ( !Owner.IsFirstPerson && !WorldModelRenderer.RenderOptions.Game )
				WorldModelRenderer.RenderOptions.Game = true;

			// Attachments
			UpdateAttachments(worldModelRenderType);
		}
	}

	protected virtual void UpdateAttachments(ModelRenderer.ShadowRenderType worldModelRenderType)
	{
		Attachments.ForEach( ( att ) =>
		{
			if ( !att.Equipped ) return;

			if ( att.ViewModelRenderer.IsValid() )
				att.ViewModelRenderer.Enabled = Owner.IsFirstPerson && ViewModelHandler.ShouldDraw;

			if ( att.WorldModelRenderer.IsValid() && att.WorldModelRenderer.RenderType != worldModelRenderType )
				att.WorldModelRenderer.RenderType = worldModelRenderType;
		} );
	}

	/// <summary>Override to use a custom ViewModelHandler</summary>
	///
	/// ⚠️ IT ALSO MAKES SURE THE SCK RIG EXISTS. That rig draws the Prisma's aim sights on the
	/// real weapon (`SckPartsRig.LiveSights`), and it can only do so from a component in the
	/// scene — which until now was created by the tuning console commands and by nothing else,
	/// so the sights appeared for anyone who had opened the editor and for nobody who had not.
	///
	/// ⚠️ HERE RATHER THAN ON THE PLAYER PREFAB because a viewmodel is the exact precondition:
	/// the rig has nothing to bind to without one, and this is the single place one is built.
	/// It is idempotent and returns immediately once a rig exists.
	protected virtual ViewModelHandler CreateViewModelHandler( GameObject go )
	{
		NZombies.SckPartsRig.EnsureRig();

		return go.Components.Create<ViewModelHandler>();
	}

	protected virtual ViewModel CreateViewModel( Model model, bool createHandler = true )
	{
		var viewModelGO = new GameObject( true, "Viewmodel - " + ClassName );
		viewModelGO.SetParent( Owner.GameObject, false );
		viewModelGO.Tags.Add( TagsHelper.ViewModel );
		viewModelGO.NetworkMode = NetworkMode.Never;

		var viewModelRenderer = viewModelGO.Components.Create<SkinnedModelRenderer>();

		// ⛔ ANIMGRAPH OFF FOR A PORTED MODEL. SWB's own weapons ship a
		// `.vanmgrph`; ours have 41 raw clips and no graph, and `Sequence` is
		// documented as "requires disabled if the scene model has one". With the
		// graph left on, setting Sequence.Name is ignored and the gun never
		// animates — silently, because neither route reports an unknown name.
		//
		// ⚠️ Only when the model actually lacks a graph, so an SWB-authored
		// weapon dropped in later keeps its graph and its blending.
		if ( model.AnimGraph is null ) viewModelRenderer.UseAnimGraph = false;

		// ⛔ REPORT WHAT THE MODEL CAN ACTUALLY PLAY, unconditionally and once.
		//
		// I added a warning for "sequence not found" and read its SILENCE as "the
		// names are fine" — but that guard skips when `SequenceNames` is null, so
		// silence covered both "all good" and "there is no sequence list at all".
		// A diagnostic that is quiet in the broken case answers nothing.
		//
		// ⚠️ ANIMATIONS AND SEQUENCES ARE DIFFERENT LISTS (INSTRUCTIONS records
		// this): an `AnimFile` in the .vmdl adds an ANIMATION, while `Sequence.Name`
		// plays a SEQUENCE. A ported vmdl can carry 41 animations and zero
		// sequences, so both counts are printed — if they disagree, the .vmdl
		// generator is what needs changing, for all 135 weapons.
		// ⛔ DO NOT REPORT SEQUENCES HERE. This point is three lines after Model is
		// assigned and the renderer is still `Enabled = false`, so the sequence
		// table has not been built and ALWAYS reads 0 — it reported `sequences=0`
		// on a model that has 41, and I regenerated the whole .vmdl chasing it.
		// `nz_wep_anims` asks the live renderer; use that.
		Log.Info( $"[swb] '{ClassName}' viewmodel: animgraph={model.AnimGraph is not null}"
			+ $"  animations={model.AnimationCount}  (sequences: run nz_wep_anims)" );
		viewModelRenderer.Model = model;
		viewModelRenderer.AnimationGraph = model.AnimGraph;
		viewModelRenderer.CreateBoneObjects = true;
		viewModelRenderer.CreateAttachments = true;
		viewModelRenderer.Enabled = false;
		viewModelRenderer.OnSoundEvent += ( sceneSound ) =>
		{
			var soundEvent = ResourceLibrary.Get<SoundEvent>( sceneSound.Name );
			if ( soundEvent is null ) return;

			using ( Rpc.FilterExclude( Owner.GameObject.Network.Owner ) )
			{
				PlaySound( soundEvent, 0.5f, 7500f, true );
			}
		};
		viewModelRenderer.OnComponentEnabled += async () =>
		{
			// Prevent flickering when enabling the component, this is controlled by the ViewModelHandler
			viewModelRenderer.RenderType = ModelRenderer.ShadowRenderType.ShadowsOnly;
			viewModelRenderer.ClearParameters();
			OnViewModelDeploy();

			// Deploy
			if ( WorldModel is null )
			{
				await GameTask.DelayRealtime( 1 );
				if ( this.IsValid() )
					OnDeploy();
			}
		};

		var viewModelCamera = Owner.ViewModelCamera;
		if ( Owner.ViewModelCamera is null )
		{
			var viewModelCameraGameObject = new GameObject();
			viewModelCameraGameObject.Name = "ViewModelCamera";
			viewModelCameraGameObject.SetParent( Owner.GameObject, false );

			// Setup the view model camera
			viewModelCamera = viewModelCameraGameObject.Components.Create<CameraComponent>();
			viewModelCamera.ClearFlags = ClearFlags.Depth | ClearFlags.Stencil;
			viewModelCamera.ZNear = 1;
			viewModelCamera.Priority = 2;
			viewModelCamera.TargetEye = StereoTargetEye.RightEye;
			viewModelCamera.RenderTags.Add( new TagSet() { TagsHelper.ViewModel, TagsHelper.Light } );

			Owner.ViewModelCamera = viewModelCamera;
		}

		Owner.Camera.RenderExcludeTags.Add( TagsHelper.ViewModel );

		SkinnedModelRenderer viewModelHandsRenderer = null;

		if ( ViewModelHands is not null )
		{
			// ⛔ THE HANDS GET THEIR OWN OBJECT, AND THIS IS A BUG FIX, NOT TIDYING. Both
			// renderers used to be components on the SAME GameObject, so they shared ONE
			// transform — and `nz_hands`, whose entire job is to move the hands relative to the
			// gun, moved the gun with them. The control could never do the one thing it was for.
			//
			// ⚠️ THE GUN DELIBERATELY STAYS ON `viewModelGO`. `ViewModelRenderer.GameObject` is
			// what the rest of the base destroys, enables, disables and anchors the loose parts
			// rig to; moving the gun to a child as well would be the same fix with a far larger
			// blast radius and no extra benefit. One of the two has to move, and the hands are
			// the one nothing else holds a reference to.
			var handsGO = new GameObject( true, "Hands" );
			handsGO.SetParent( viewModelGO, false );

			// ⛔ THE TAG IS NOT INHERITED, AND WITHOUT IT THE HANDS GO TO THE WRONG CAMERA. The
			// viewmodel camera renders `TagsHelper.ViewModel` and the player camera EXCLUDES it,
			// so an untagged hands object would vanish out of first person and turn up floating
			// in the world instead — and `ThirdPersonWeapon` would count it as a world renderer
			// on the way past.
			handsGO.Tags.Add( TagsHelper.ViewModel );
			handsGO.NetworkMode = NetworkMode.Never;

			viewModelHandsRenderer = handsGO.Components.Create<SkinnedModelRenderer>();
			// ⛔ THE ONE PLACE HANDS ARE CHOSEN, so the character override belongs here and nowhere
			// else. `HandsFor` returns null when the player has picked no character, which leaves
			// the weapon's own `ViewModelHands` untouched — some weapons legitimately want their own
			// gloves, and a fallback here would quietly replace them.
			// ⛔ SAY WHAT WAS KNOWN AT THIS EXACT INSTANT, because this is the only instant that
			// matters and it has now been guessed at twice.
			//
			// Measured on a client: the body read `character 'dempsey'` seven seconds later, and
			// the hands chosen HERE were the generic `v_hands.vmdl`. Making `CharacterId`'s setter
			// rebuild the viewmodel did NOT fix it — so either the character was not yet on the
			// body when this ran, or `HandsFor` returned null for some other reason (it also
			// returns null for a model that fails to load, or loads as the ERROR model).
			//
			// ⚠️ THOSE TWO CAUSES NEED OPPOSITE FIXES and cannot be told apart after the fact.
			// This line separates them: a non-empty CharacterId with a null result is an ASSET
			// problem; an empty CharacterId is an ORDERING problem.
			var ownerNz = Owner as NZombies.NZPlayer;
			var chosen = NZombies.PlayerCharacters.HandsFor( ownerNz );

			// ⚠️ SENT TO THE HOST FROM A CLIENT. This fires on the machine the weapon belongs to,
			// so a client's copy lands in the CLIENT's console — the half nobody is reading. Two
			// rounds have already been spent on captures that could not contain the one line that
			// mattered.
			void Say( string line )
			{
				if ( NZombies.NZGame.IsHost || !Networking.IsActive ) Log.Info( line );
				else NZombies.NZNet.Say( line );
			}

			if ( chosen is null )
				Say( $"[swb] {(NZombies.NZGame.IsHost ? "HOST" : "CLIENT")} '{ClassName}' hands: using the weapon's own — "
					+ $"CharacterId='{(ownerNz.IsValid() ? ownerNz.CharacterId ?? "(null)" : "no owner")}'"
					+ ", HandsFor gave null"
					+ (ownerNz.IsValid() && !string.IsNullOrEmpty( ownerNz.CharacterId )
						? "   ⛔ THE CHARACTER IS SET AND THE ARMS STILL DID NOT RESOLVE — that is an "
							+ "ASSET fault (missing or ERROR model), not an ordering one."
						: "   ⚠ no character on the body yet — ORDERING; the setter's rebuild covers it "
							+ "only if a weapon is already held.") );

			viewModelHandsRenderer.Model = chosen ?? ViewModelHands;
			viewModelHandsRenderer.BoneMergeTarget = viewModelRenderer;
			viewModelHandsRenderer.OnComponentEnabled += () =>
			{
				// Prevent flickering when enabling the component, this is controlled by the ViewModelHandler
				viewModelHandsRenderer.RenderType = ModelRenderer.ShadowRenderType.ShadowsOnly;
			};
		}

		ViewModelHandler handler = null;

		if ( createHandler )
		{
			handler = CreateViewModelHandler( viewModelGO );
			handler.Weapon = this;
			handler.ViewModelRenderer = viewModelRenderer;
			handler.Camera = viewModelCamera;
			handler.ViewModelHandsRenderer = viewModelHandsRenderer;
		}

		return new()
		{
			Renderer = viewModelRenderer,
			HandsRenderer = viewModelHandsRenderer,
			ModelHandler = handler
		};
	}

	protected virtual void CreateModels()
	{
		// ⛔ `!IsProxy` IS NOT A TEST OF WHOSE BODY THIS IS, AND ON THIS WEAPON IT NEVER WAS.
		// `Component.IsProxy` means "this is a NETWORKED object owned by someone else". Every weapon
		// prefab in this project is `NetworkMode.Never`, so the weapon is not a network object at
		// all and **`IsProxy` reads false on every machine** — including on a copy sitting under
		// somebody else's body. The guard has therefore never guarded anything.
		//
		// ⚠️ MEASURED, NOT INFERRED. On the host, `nz_arms` found a complete second viewmodel —
		// `Viewmodel - nz_m1911`, its gun mesh, its hands and all 105 of its bone objects — parented
		// under `Player (Ralph)`, the CLIENT's body, 444 units away:
		//
		//   107 'Viewmodel - nz_m1911' [SWB gun] under 'Player (Ralph)'  MINE=no  objEnabled=False
		//         mesh  v_m1911.vmdl   enabled=True ShadowsOnly
		//         hands v_hands.vmdl   enabled=True ShadowsOnly
		//
		// ⚠️ `PlayerPresence.Theirs`, THE SAME PREDICATE THE KNIFE AND THE GRENADE NOW USE. It
		// asks whose BODY this is rather than whether this component happens to be networked, which
		// is the question that actually matters for a first-person object.
		if ( !IsProxy && !NZombies.PlayerPresence.Theirs( GameObject ) && !Owner.IsBot
			&& ViewModel.IsValid() && !ViewModelRenderer.IsValid() )
		{
			var viewmodel = CreateViewModel( ViewModel );
			ViewModelRenderer = viewmodel.Renderer;
			ViewModelHandler = viewmodel.ModelHandler;

			// ⛔ WAS NEVER ASSIGNED. `ViewModelHandsRenderer` is declared on Weapon
			// but only ever set on the HANDLER, so the weapon's own property read
			// null whether or not the hands existed — which reads as "the hands
			// failed to load" and sends you to check the model, the prefab and the
			// compile, all three of which can be perfectly fine.
			ViewModelHandsRenderer = viewmodel.HandsRenderer;
		}

		if ( WorldModel is not null && WorldModelRenderer is null )
		{
			WorldModelRenderer = Components.Create<SkinnedModelRenderer>();
			WorldModelRenderer.Model = WorldModel;
			WorldModelRenderer.AnimationGraph = WorldModel.AnimGraph;
			WorldModelRenderer.CreateBoneObjects = true;
			WorldModelRenderer.CreateAttachments = true;

			async void OnComponentEnabled()
			{
				// Prevent flickering when enabling the component
				WorldModelRenderer.RenderType = ModelRenderer.ShadowRenderType.Off;
				WorldModelRenderer.RenderOptions.Game = false;

				// Deploy
				await GameTask.DelayRealtime( 1 );
				if ( this.IsValid() )
					OnDeploy();
			}

			WorldModelRenderer.OnComponentEnabled += () =>
			{
				// Called after weapon has been switched already
				OnComponentEnabled();
			};

			// Called when weapon models are created
			OnComponentEnabled();
			Owner.ParentToBone( GameObject, "hold_R", deleteOnFail: false );
		}
	}

	// ── reload sound events ─────────────────────────────────────────────────
	//
	// ⛔ ARC9 AUTHORS THESE AS TIMED EVENTS ON THE CLIP, and SWB fires them from
	// animgraph events — which a ported model does not have. Same gap as the
	// animations themselves, so the same answer: read the timings from the Lua
	// and play them ourselves.
	//
	//   ["reload"]       EventTable = {{magout, 0.25}, {magin, 1.0}}
	//   ["reload_empty"] EventTable = {{magout, 0.25}, {magin, 1.0}, {slidefwd, 1.5}}
	//
	// ⚠️ Times are in SECONDS FROM THE START of the clip, matching ARC9's `t`, so
	// the authored numbers transfer verbatim.

	// ⚠️ EACH SLOT'S `…Cue` IS SET WHEN THE CUE IS BUILT IN CODE (package trim step 3): its SoundEvent is then a
	// TEMPLATE, and the cue's recordings ride beside it. See GunCue. Unset = the event plays as it always did.
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public SoundEvent MagOutSound { get; set; }
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public GunCue MagOutSoundCue { get; set; }
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public float MagOutTime { get; set; } = 0.25f;

	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public SoundEvent MagInSound { get; set; }
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public GunCue MagInSoundCue { get; set; }
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public float MagInTime { get; set; } = 1.0f;

	/// <summary>Empty reload only — the slide running forward on a fresh mag.</summary>
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public SoundEvent SlideSound { get; set; }
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public GunCue SlideSoundCue { get; set; }
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public float SlideTime { get; set; } = 1.5f;

	/// <summary>
	/// A fourth cue, for weapons whose reload has one — the MPL pulls its bolt
	/// back (`charge_pull`) before letting it run forward (`charge`).
	///
	/// ⚠️ THREE FIXED SLOTS IS THE REAL LIMITATION HERE. ARC9's EventTable is an
	/// arbitrary list, so a weapon can have any number of cues; SWB has named
	/// fields. This adds the one that was actually missing rather than pretending
	/// four is enough — if a weapon needs five, turn these into a list.
	/// </summary>
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public SoundEvent ExtraCueSound { get; set; }
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public GunCue ExtraCueSoundCue { get; set; }
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public float ExtraCueTime { get; set; } = 0f;

	/// <summary>
	/// The EMPTY reload's own mag-out / mag-in / extra-cue times, for guns whose empty clip moves them -- an HK slap pulls the
	/// bolt back first, so its magazine comes out ~0.8 s later than on the tactical clip, and one time cannot fit
	/// both. Below zero (the default, and every prefab that never set them) = the tactical time serves both.
	/// </summary>
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public float MagOutEmptyTime { get; set; } = -1f;
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public float MagInEmptyTime { get; set; } = -1f;
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public float ExtraCueEmptyTime { get; set; } = -1f;

	/// <summary>
	/// Round-by-round reloads only: a sound `ShellReloadEndSoundTime` seconds into the CLOSING clip (the pump, the bolt
	/// going forward, the loading gate shutting). Null = nothing, as before.
	/// </summary>
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public SoundEvent ShellReloadEndSound { get; set; }
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public GunCue ShellReloadEndSoundCue { get; set; }
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public float ShellReloadEndSoundTime { get; set; } = 0f;

	/// <summary>
	/// The WHOLE list of a reload's sound cues, for guns whose reload has more than the slots above can hold.
	///
	/// ⛔ FOUR SLOTS IS WHY AN MW RELOAD WOULD SOUND THIN. A Modern Warfare Base reload carries 4–15 timed sounds in its
	/// model's own events — the lift, mag out, mag in, the mag seated, the charging handle, the settle, cloth, and a mag
	/// hitting the floor — and MagOut / MagIn / ExtraCue / Slide keep three or four of them (Docs/MW_BASE_PORTING.md §5).
	///
	/// ⚠️ A LIST REPLACES THE SLOTS FOR THAT RELOAD, it is not added to them, so a cue that is in both never plays twice.
	/// Empty — every prefab that never set it — plays the slots exactly as before. Times are 30-fps seconds from the clip's
	/// start, like every cue here, and take the same `_reloadCueScale`. Shell-by-shell reloads keep the slots: their
	/// insert cue belongs to each shell (see TickReloadSounds).
	/// </summary>
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public List<ReloadCue> ReloadCues { get; set; } = new();

	/// <summary>The EMPTY reload's own full cue list. Empty = `ReloadCues` serves both reloads (and when that is empty
	/// too, the slots).</summary>
	[Property, Group( "Reload sounds" ), Feature( "Sounds" )] public List<ReloadCue> ReloadEmptyCues { get; set; } = new();

	float _reloadElapsed;
	int _reloadEvent;

	/// <summary>
	/// The per-shell insert cue's own clock: seconds into the CURRENT segment of a shell-by-shell reload (one shell),
	/// restarted by every `StartReload`. `_reloadElapsed` runs across the whole reload and cannot do this job.
	/// </summary>
	float _shellCueElapsed;
	bool _shellCuePlayed;

	/// <summary>
	/// Clip seconds -> real seconds for this reload's sound cues: the fitted reload time over the clip's authored
	/// length, set by `StartReload` beside the PlaybackRate it describes.
	///
	/// ⛔ WITHOUT IT THE SOUNDS AND THE HANDS DISAGREED ON EVERY RE-TIMED RELOAD. `StartReload` fits the clip to the
	/// reload (`PlaybackRate = Duration / animTime`), but the cues fired at their AUTHORED clip seconds — so once the
	/// balance passes shortened a reload (Destiny's play about 3x faster than authored, BO3's ~1.9x), the magazine
	/// sound landed long after the hands had moved on, and any cue past the new end never played at all. Speed
	/// Cola desynced them the same way.
	/// </summary>
	float _reloadCueScale = 1f;

	/// <summary>
	/// A clip's engine seconds -> the 30-fps seconds cue times are authored in: 24 / 30. Every ported clip is a DMX
	/// at Blender's default 24 fps; if the exporter ever writes the real rate, this becomes (that rate / 30).
	/// </summary>
	const float ClipToCueSeconds = 24f / 30f;

	/// <summary>
	/// Fire the reload's sound events as the clip plays.
	///
	/// ⚠️ Driven by our OWN elapsed timer rather than `TimeSinceReload`, because
	/// that is reset by the reload itself and scaled by ReloadSpeed — two things
	/// that would silently shift every event. This counts real seconds from the
	/// moment the reload began, which is what ARC9's `t` values mean.
	///
	/// ⚠️ An index, not a "has played" flag per sound: events are authored in
	/// order, so one counter cannot double-fire or skip.
	/// </summary>
	void TickReloadSounds()
	{
		if ( !IsReloading )
		{
			_reloadElapsed = 0f;
			_reloadEvent = 0;
			_shellCueElapsed = 0f;
			_shellCuePlayed = false;
			return;
		}

		_reloadElapsed += Time.Delta;
		_shellCueElapsed += Time.Delta;

		// ⚠️ A FULL CUE LIST, WHEN THE GUN HAS ONE, REPLACES THE SLOTS FOR THIS RELOAD — see `ReloadCues`.
		var list = IsReloadingEmpty && ReloadEmptyCues is { Count: > 0 } ? ReloadEmptyCues : ReloadCues;
		if ( !ShellReloading && list is { Count: > 0 } )
		{
			// ⚠️ SORTED HERE, NOT TRUSTED — the index walker skips an out-of-order entry rather than playing it late.
			var cues = list.Where( c => c is not null ).OrderBy( c => c.Time ).ToArray();
			while ( _reloadEvent < cues.Length && _reloadElapsed >= cues[_reloadEvent].Time * _reloadCueScale )
			{
				var cue = cues[_reloadEvent];
				_reloadEvent++;
				PlayCue( cue.Sound, cue.Cue );
			}
			return;
		}

		// ⚠️ MUST STAY SORTED BY TIME — the index walker below assumes ordering,
		// so an out-of-order entry is silently skipped rather than played late.
		var events = new (float t, SoundEvent s, GunCue c)[]
		{
			(IsReloadingEmpty && MagOutEmptyTime >= 0f ? MagOutEmptyTime : MagOutTime, MagOutSound, MagOutSoundCue),
			(IsReloadingEmpty && MagInEmptyTime >= 0f ? MagInEmptyTime : MagInTime, ShellReloading ? null : MagInSound,
				ShellReloading ? null : MagInSoundCue),
			(IsReloadingEmpty && ExtraCueEmptyTime >= 0f ? ExtraCueEmptyTime : ExtraCueTime, ExtraCueSound, ExtraCueSoundCue),
			(SlideTime,    IsReloadingEmpty ? SlideSound : null, IsReloadingEmpty ? SlideSoundCue : null),
		};
		System.Array.Sort( events, ( a, b ) => a.t.CompareTo( b.t ) );

		while ( _reloadEvent < events.Length && _reloadElapsed >= events[_reloadEvent].t * _reloadCueScale )
		{
			var e = events[_reloadEvent];
			_reloadEvent++;
			PlayCue( e.s, e.c );
		}

		// ⛔ ON A SHELL-BY-SHELL RELOAD THE INSERT SOUND BELONGS TO EACH SHELL, NOT TO THE RELOAD. The walker above
		// runs one clock across the whole reload, because `OnShellReloadFinish` clears `IsReloading` and sets it again
		// inside a single call, so the reset at the top never runs between shells. MagIn played on the FIRST shell only,
		// and an 8-shell reload went quiet after it. The other three slots keep the whole-reload clock: a bolt opened
		// once, or a pump after an empty reload, must not repeat per shell.
		//
		// ⚠️ `MagInTime` IS THEREFORE A TIME INSIDE THE INSERT CLIP on these guns (30-fps seconds, like every cue), and
		// `_reloadCueScale` is this segment's -- `StartReload` sets it per shell. A time past the clip's end never plays.
		if ( ShellReloading && !_shellCuePlayed && _shellCueElapsed >= MagInTime * _reloadCueScale )
		{
			_shellCuePlayed = true;
			PlayCue( MagInSound, MagInSoundCue );
		}
	}

	/// <summary>True while the EMPTY reload is the one playing — the slide event
	/// belongs only to that one.</summary>
	public bool IsReloadingEmpty { get; private set; }

	/// <summary>
	/// The held weapon's reload sound cues as `TickReloadSounds` reads them. `nz_reload_cues`.
	///
	/// ⚠️ TIMES ARE CUE SECONDS (30 fps), NOT REAL ONES. The last reload's scale turns them into real seconds; it is
	/// printed so a cue that lands early or late can be told apart from a cue that is authored early or late.
	/// The per-shot bolt/pump cycle's cues (`BoltCycleCues`) are the exception: REAL seconds, since that clip plays unfitted.
	/// </summary>
	[ConCmd( "nz_reload_cues" )]
	public static void ReloadCuesReport()
	{
		var w = Game.ActiveScene?.GetAllComponents<Weapon>()
			.FirstOrDefault( x => x.IsValid() && x.GameObject.Enabled && !x.IsProxy );
		if ( w is null ) { Log.Info( "[cues] no held weapon" ); return; }

		static string S( SoundEvent s ) => s is null ? "-" : s.ResourcePath;
		static string Q( SoundEvent s, GunCue c ) => c is not null && c.IsSet ? $"{c.Event} (built)" : S( s );
		static string E( float t ) => t >= 0f ? $"{t:0.###}" : "same";

		Log.Info( $"[cues] {w.DisplayName}  reload {w.ReloadAnim} {w.ReloadTime:0.##}s, empty {w.ReloadEmptyAnim} {w.ReloadEmptyTime:0.##}s, " +
			(w.ShellReloading ? "shell by shell (MagIn plays on every shell)" : "magazine") );
		Log.Info( $"[cues]   MagOut   {w.MagOutTime:0.###}  empty {E( w.MagOutEmptyTime )}  {Q( w.MagOutSound, w.MagOutSoundCue )}" );
		Log.Info( $"[cues]   MagIn    {w.MagInTime:0.###}  empty {E( w.MagInEmptyTime )}  {Q( w.MagInSound, w.MagInSoundCue )}" );
		Log.Info( $"[cues]   ExtraCue {w.ExtraCueTime:0.###}  empty {E( w.ExtraCueEmptyTime )}  {Q( w.ExtraCueSound, w.ExtraCueSoundCue )}" );
		Log.Info( $"[cues]   Slide    {w.SlideTime:0.###}  empty reload only  {Q( w.SlideSound, w.SlideSoundCue )}" );
		if ( w.ShellReloading )
			Log.Info( $"[cues]   End      {w.ShellReloadEndSoundTime:0.###}s into '{w.ShellReloadEndAnim}' ({w.ShellReloadEndTime:0.##}s)  {Q( w.ShellReloadEndSound, w.ShellReloadEndSoundCue )}" );
		foreach ( var (label, cues) in new[] { ("list", w.ReloadCues), ("empty list", w.ReloadEmptyCues) } )
		{
			if ( cues is not { Count: > 0 } ) continue;
			Log.Info( $"[cues]   {label} ({cues.Count}, replaces the slots): " +
				string.Join( "  ", cues.Where( c => c is not null ).OrderBy( c => c.Time ).Select( c => $"{c.Time:0.###} {Q( c.Sound, c.Cue )}" ) ) );
		}
		if ( w.BoltActionPerShot )
			Log.Info( $"[cues]   cycle after each shot: '{w.BoltCycleAnim}' {w.BoltBackTime:0.##}s, {w.BoltCycleCues?.Count ?? 0} cue(s) in REAL seconds: " +
				string.Join( "  ", (w.BoltCycleCues ?? new()).Where( c => c is not null ).OrderBy( c => c.Time ).Select( c => $"{c.Time:0.###} {Q( c.Sound, c.Cue )}" ) ) );
		Log.Info( $"[cues]   last reload: x{w._reloadCueScale:0.###} cue seconds -> real seconds, empty={w.IsReloadingEmpty}" );
	}

	/// <summary>
	/// Report the hands renderer's actual runtime state. `nz_hands_report`.
	///
	/// ⚠️ Reports the RENDERER, not the prefab field — "the prefab says
	/// v_hands.vmdl" and "a renderer exists, is enabled, is drawing, and has
	/// bones bound to the viewmodel" are four separate claims, and the arms
	/// vanish if any one of them is false.
	/// </summary>
	[ConCmd( "nz_hands_report" )]
	public static void HandsReport()
	{
		var w = Game.ActiveScene?.GetAllComponents<Weapon>()?.FirstOrDefault( x => x.IsValid() );
		if ( w is null ) { Log.Info( "[hands] no weapon" ); return; }

		Log.Info( $"[hands] prefab ViewModelHands = {(w.ViewModelHands is null ? "NULL" : w.ViewModelHands.ResourcePath)}" );

		// ⚠️ Check BOTH: the handler's copy is the one CreateViewModel has always
		// populated, the weapon's own was fixed only just now. Disagreement
		// between them is itself the diagnosis.
		var r = w.ViewModelHandsRenderer ?? w.ViewModelHandler?.ViewModelHandsRenderer;
		Log.Info( $"[hands] weapon.renderer={(w.ViewModelHandsRenderer is null ? "null" : "set")} " +
			$"handler.renderer={(w.ViewModelHandler?.ViewModelHandsRenderer is null ? "null" : "set")}" );

		if ( r is null )
		{
			Log.Info( "[hands] no renderer anywhere — CreateViewModel saw ViewModelHands as null" );
			return;
		}

		Log.Info( $"[hands] renderer enabled={r.Enabled} type={r.RenderType} model={r.Model?.ResourcePath}" );
		Log.Info( $"[hands]   bones={r.Model?.BoneCount} bounds={r.Model?.Bounds} " +
			$"mergeTarget={(r.BoneMergeTarget is null ? "NULL" : "set")}" );
		Log.Info( $"[hands]   worldPos={r.WorldPosition} scale={r.WorldScale} " +
			$"vm={w.ViewModelRenderer?.WorldPosition}" );

		// ⚠️ THE DECIDING TEST. Everything above can read healthy while the merge
		// matched nothing — bones stay at bind pose, which for these arms is
		// spread 52 units wide and below the camera, i.e. invisible rather than
		// visibly wrong. If the merge works, the arms' Bip01_*_Hand sits exactly
		// on the weapon's j_wrist_*; if it silently no-ops, they diverge.
		var vm = w.ViewModelRenderer;

		// ⚠️ If the poses diverge, the cause is almost always names: print what
		// each model ACTUALLY calls its bones after the export/compile round trip
		// rather than what the source files called them.
		var armNames = r.Model.Bones.AllBones.Select( x => x.Name ).ToList();
		var gunNames = vm?.Model.Bones.AllBones.Select( x => x.Name ).ToList() ?? new();
		var shared = armNames.Count( n => gunNames.Contains( n ) );
		Log.Info( $"[hands]   armBones={armNames.Count} gunBones={gunNames.Count} sharedNames={shared}" );
		Log.Info( $"[hands]   arms: {string.Join( ", ", armNames.Take( 5 ) )}" );
		// ⚠️ ALL of them — 6 is few enough to print, and WHICH six is the whole
		// question: if they are only the gun's own bones (j_gun/j_bolt/tag_*),
		// ModelDoc pruned every bone no mesh is weighted to, and the arm rig the
		// bonemerge needs was never compiled into the weapon at all.
		Log.Info( $"[hands]   gun: {string.Join( ", ", gunNames )}" );
		// ⚠️ COMPARE THE SAME BONE ON BOTH MODELS. A merge that works makes them
		// IDENTICAL — comparing different bones (hand vs wrist) only ever shows
		// "close", which cannot tell a working merge from a nearly-working one.
		// If these match exactly, the SKELETON is driven correctly and any
		// remaining mess is the MESH's binding, not the merge.
		foreach ( var bone in new[] {
			"ValveBiped_Bip01_Spine4", "ValveBiped_Bip01_L_UpperArm",
			"ValveBiped_Bip01_L_Forearm", "ValveBiped_Bip01_L_Hand" } )
		{
			var okA = r.TryGetBoneTransform( r.Model.Bones.GetBone( bone ), out var ta );
			Transform tg = default;
			var okG = vm is not null && vm.TryGetBoneTransform( vm.Model.Bones.GetBone( bone ), out tg );
			var delta = okA && okG ? ta.Position.Distance( tg.Position ) : -1f;
			Log.Info( $"[hands]   {bone,-28} arms={(okA ? ta.Position.ToString() : "MISSING")} " +
				$"gun={(okG ? tg.Position.ToString() : "MISSING")} delta={delta:F3}" );
		}
	}

	/// <summary>Log every step of the shoot/sound path. `nz_wep_debug 1`.</summary>
	public static bool WeaponDebug { get; set; }

	// ⚠️ NO [ConCmd] HERE SINCE 2026-10-05: `nz_wep_debug` is `SoundCommands.WeaponDebug`, which sets this same flag (and
	// `nz_wep_debug -1` only reports it). Both registered the name, and the engine kept whichever it met first.
	public static void SetWeaponDebug( int on = 1 )
	{
		WeaponDebug = on != 0;
		Log.Info( $"[swb-dbg] weapon debug {(WeaponDebug ? "ON" : "off")}" );
	}

	// ⛔ NOT AN RPC SINCE 2026-10-05: no other machine has a weapon object, so remote shots are heard through `NZNet.ShotSound`.
	// See `HandleShootEffects`.
	public void PlaySound( SoundEvent sound, float volume = float.NaN, float distance = float.NaN, bool shouldFollow = false )
	{
		PlaySoundLocal( sound, volume, distance, shouldFollow );
	}

	/// <summary>
	/// Play a gun cue: its BUILT event when it has recordings (GunCue), otherwise its event field exactly as before.
	///
	/// ⛔ A BUILT EVENT PLAYS HERE, NEVER THROUGH THE RPC. It has no path, and an RPC argument travels as one. Nothing
	/// is lost by it: `PlaySound`'s broadcast never arrived anywhere (the weapon is `NetworkMode.Never`), and the shot
	/// reaches the other players through `RelayShotSound`, which sends the cue's key.
	/// </summary>
	public void PlayCue( SoundEvent sound, GunCue cue, float volume = float.NaN, float distance = float.NaN, bool shouldFollow = false )
	{
		if ( cue is not null && cue.IsSet )
		{
			var built = GunSounds.Resolve( sound, cue );
			if ( built is not null ) PlaySoundLocal( built, volume, distance, shouldFollow );
			return;
		}

		if ( sound is not null ) PlaySound( sound, volume, distance, shouldFollow );
	}

	/// <summary>
	/// Cut every packed recording this gun can play, ahead of need (GunAudioPacks, step 4): the draw, the four slots, the
	/// shell close, the three timed lists and both shots. Nothing to do for a gun with no packed cues.
	/// </summary>
	void PrepareGunSounds()
	{
		var names = new List<string>();
		void Add( GunCue c )
		{
			if ( c?.Packed is { Count: > 0 } p ) names.AddRange( p );
		}

		Add( DeploySoundCue );
		Add( MagOutSoundCue );
		Add( MagInSoundCue );
		Add( SlideSoundCue );
		Add( ExtraCueSoundCue );
		Add( ShellReloadEndSoundCue );
		foreach ( var list in new[] { ReloadCues, ReloadEmptyCues, BoltCycleCues } )
			if ( list is not null )
				foreach ( var rc in list ) Add( rc?.Cue );
		Add( Primary?.ShootSoundCue );
		Add( Secondary?.ShootSoundCue );

		if ( names.Count > 0 ) _ = GunAudioPacks.Prepare( names, $"{GameObject?.Name} arrived" );
	}

	/// <summary>`PlaySound`'s body, without the RPC: what `PlayCue` uses for a built event.</summary>
	public void PlaySoundLocal( SoundEvent sound, float volume = float.NaN, float distance = float.NaN, bool shouldFollow = false )
	{
		if ( sound is null || !this.IsValid() ) return;

		if ( !shouldFollow )
			shouldFollow = CanSeeViewModel;

		// ⛔ THESE TWO ARGUMENTS USED TO BE WRITTEN ONTO `sound` ITSELF, AND A SoundEvent IS A
		// SHARED GameResource. There is ONE instance of `m1911.fire` for the whole game, so
		// `sound.Volume = 0.5f` did not make THIS playback quieter — it permanently re-authored
		// the asset, for every weapon that uses that cue, for the rest of the session, including
		// every later play that passed no volume at all.
		//
		// The caller that made this bite is the viewmodel's animation sound hook, which passes
		// `0.5f, 7500f` on EVERY animation-embedded cue — so every reload click, bolt pull and
		// mag drop in the game had its authored volume silently replaced by 0.5 and its range by
		// 7500 the first time any weapon played it. Balancing the mix by editing .sound assets
		// could not work while that was true: the numbers were being overwritten at runtime.
		//
		// ⚠️ `ZombieAI.VoiceRangeScale` ALREADY WROTE THIS DOWN and named both sites: *"the asset
		// is SHARED — writing Distance on the event would change it globally, permanently, and for
		// anything else that happens to use that cue. `CreateBulletImpact` already does exactly
		// that with `sound.Distance = 10000` and it is a bug waiting to be noticed."* It has now
		// been noticed, from the other end — reported as the mix being impossible to balance.
		//
		// ⚠️ SAME EFFECT FOR THIS PLAYBACK, NONE FOR ANY OTHER. `SoundHandle.Volume` and
		// `.Distance` are per-playback and start from the event's authored values, so assigning
		// them below is exactly what assigning the event used to do for the sound in hand — minus
		// the part that leaked into every other one. `PlayWorldSound` at the foot of this file
		// has always done it this way; this just stops the two from disagreeing.

		// ⛔ THE WEAPON OBJECT IS NOT IN THE WORLD IN FIRST PERSON.
		//
		// Measured: `PlaySound 'm1911.fire' at -181,-106,-1000053` — a MILLION
		// units below the map. SWB parks it there so the world model cannot be
		// seen while the viewmodel is up. The sound was always playing, with a
		// valid handle, at full volume, a million units from the listener — and
		// `follow = true` then pinned it there.
		//
		// So when the viewmodel is what you can see, the sound belongs at the
		// EYE, not at the weapon. Everything downstream (falloff, occlusion,
		// distance) was fine; the position was the entire bug.
		var origin = WorldPosition;
		if ( CanSeeViewModel && Owner.IsValid() )
		{
			origin = Owner.EyePos;
			shouldFollow = false;   // ⚠️ or it follows the object back out of the world
		}

		var handle = Sound.Play( sound, origin );

		// ⚠️ AFTER THE PLAY, BECAUSE THE HANDLE DOES NOT EXIST BEFORE IT. That ordering is the
		// whole reason the old code reached for the asset instead.
		if ( handle.IsValid() )
		{
			if ( !float.IsNaN( volume ) ) handle.Volume = volume;
			if ( !float.IsNaN( distance ) ) handle.Distance = distance;
		}

		if ( WeaponDebug )
			Log.Info( $"[swb-dbg] PlaySound '{sound.ResourceName}'"
				+ $"  at {origin}  (weapon obj at {WorldPosition})"
				+ $"  handle={(handle.IsValid() ? "valid" : "DEAD")}"
				+ $"  follow={shouldFollow}"
				+ $"  goScale={GameObject.WorldScale}"
				+ $"  vol={sound.Volume}{(float.IsNaN( volume ) ? "" : $"->{volume}")}"
				+ $"  dist={sound.Distance}{(float.IsNaN( distance ) ? "" : $"->{distance}")}"
				+ $"  netActive={Networking.IsActive}" );

		if ( shouldFollow )
		{
			handle?.Parent = this.GameObject;
			handle?.FollowParent = true;
		}
		else
		{
			// ⛔ `origin`, NOT `WorldPosition`. This line is why the previous fix
			// changed nothing: I moved where the sound STARTS, and then this moved
			// it straight back to the weapon's parked position a million units
			// under the map. The log even said so — it printed the origin I chose
			// while the handle was being relocated on the next line.
			handle?.Position = origin;
		}
	}

	[Rpc.Broadcast]
	public void PlayWorldSound( string eventName, float volume = 1, float distance = 7500 )
	{
		var handle = Sound.Play( eventName, WorldPosition );
		handle.Volume = volume;
		handle.Distance = distance;
	}
}

/// <summary>One timed sound in a reload — see `Weapon.ReloadCues`.</summary>
public class ReloadCue
{
	/// <summary>30-fps seconds from the start of the reload clip, like every reload cue.</summary>
	[Property] public float Time { get; set; }

	[Property] public SoundEvent Sound { get; set; }

	/// <summary>Set when this cue is built in code: `Sound` is then its template. See GunCue.</summary>
	[Property] public GunCue Cue { get; set; }
}