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.
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; }
}