Component that manages showing a third-person weapon model on a player body. It publishes what the local player holds, builds a non-networked world-model GameObject parented to the body, aligns it to a chosen weapon bone vs body hand bone, handles tuning offsets, melee/reload gestures and reporting/debug console commands.
using Sandbox;
using System;
using System.Linq;
using SWB.Shared;
namespace NZombies;
/// <summary>
/// PUT THE GUN IN THE PLAYER'S HANDS, ON EVERY SCREEN.
///
/// ⛔ NOBODY HAS EVER VISIBLY HELD A WEAPON IN THIS PROJECT, IN ANY MODE. `NZPlayer.HoldType` has
/// carried its own confession since long before there were two players:
///
/// ⚠️ Write-only in the interface. It drives the THIRD-person hold pose, which nothing
/// renders yet; stored so a future world model can use it without SWB needing to change.
///
/// Nothing read it, and SWB builds its `WorldModelRenderer` on the WEAPON object — which is
/// `NetworkMode.Never` and exists only on its owner's machine. So a remote player walked and idled
/// correctly and held nothing, because there was nothing to hold. User: *"players are kind of just
/// standing, we do not see other players shooting, holding their weapon etc."*
///
/// ⚠️ THIS IS THE FUTURE THAT COMMENT WAS WAITING FOR, and it needed the prefab work first:
/// `[Sync]` does nothing on a `NetworkMode.Snapshot` object (`SBOX_MULTIPLAYER.md` §2), and the
/// player only became `NetworkMode.Object` when spawning moved to `prefabs/player.prefab`.
///
/// ⚠️ IT TOUCHES NO SWB CODE. The owner POLLS its own held weapon rather than SWB pushing — one
/// comparison a frame against a property SWB already maintains, and no edit to a file that has
/// twice now had a networking change go wrong inside it.
/// </summary>
public sealed class ThirdPersonWeapon : Component
{
/// <summary>
/// Bone names to try, in order, for where the gun sits.
///
/// ⛔ A LIST BECAUSE THE BODIES ARE PORTED AND RETARGETED, and this project has been bitten by
/// assuming a rig before. `dempsey_rigged.vmdl` and friends were rebound onto s&box's human
/// skeleton by `Tools/retarget_to_human.py`, while the fallback body is the stock Citizen —
/// so the same code has to find a hand on two different lineages, and `nz_arms` already showed
/// ValveBiped names surviving on the viewmodel meshes.
///
/// ⚠️ IT SAYS WHICH ONE IT FOUND, ONCE. A gun that silently fails to appear is exactly the
/// class of bug this session has spent a day on.
/// </summary>
static readonly string[] HandBones =
{
"hold_R", "hand_R", "hand_r", "R_Hand",
"ValveBiped.Bip01_R_Hand", "ValveBiped_Bip01_R_Hand",
};
/// <summary>
/// The bone IN THE WEAPON MESH that should end up sitting on the body's hand.
///
/// ⛔ THIS IS WHY THE GUN DOES NOT NEED HAND-TUNED OFFSETS. `nz_3p_bones` on any of the 418
/// viewmodels shows the entire Call of Duty first-person rig still inside the mesh — 103 bones
/// on `v_m1911`, including `ValveBiped_Bip01_R_Hand`, `j_wrist_ri` and `tag_weapon`. The gun's
/// position relative to that hand IS the authored grip: an artist placed it there, per weapon,
/// and it is already correct for every weapon in the pack.
///
/// ⚠️ SO THE JOB IS NOT "WHERE DOES THIS GUN GO", IT IS "PUT THAT HAND ON THIS HAND". A
/// per-weapon offset would be 418 numbers to find; an anchor is zero, and a weapon added later
/// works without anybody touching this file.
///
/// ⛔ `j_gun`, AND BOTH OBVIOUS ALTERNATIVES WERE TRIED AND WERE WRONG. A viewmodel's BIND pose
/// is not its idle pose: `v_m1911` binds with the arms nowhere near the weapon, so anchoring
/// `ValveBiped_Bip01_R_Hand` put the viewmodel's wrist precisely on the citizen's hand — 0.2u,
/// measured; the arithmetic was never in question — and left the pistol hanging a metre away.
/// `tag_weapon` and `tag_weapon1` are no better: both belong to the ARMS rig, marking where the
/// arms EXPECT a weapon, and sit 58u from the gun in a mesh whose entire bounding box is 10
/// inches across.
///
/// ⚠️ THE MESH ITSELF SETTLED IT, WHICH NO BONE NAME COULD HAVE. `nz_3p_bones` with no filter
/// prints `Bounds` and ranks bones by distance to it, and the ranking is unambiguous:
///
/// mesh centre 1.43,-0.01,-1.28 size 10.25,1.53,6.38
/// nearest: j_gun (1.9u) Joints (1.9u) j_gun1 (1.9u) tag_brass (2.6u) tag_origin (3.6u)
///
/// ⚠️ SO THE GUN IS BUILT AT ITS OWN ORIGIN and `j_gun` is the bone inside it. Everything
/// after this is a rigid offset, not a search.
/// </summary>
static readonly string[] GunAnchors =
{
"j_gun", "j_gun1", "tag_origin", "tag_weapon1", "tag_weapon",
// ⛔ THE HL2 VIEWMODEL RIGS END HERE, AND THE NAME HAS AN UNDERSCORE. Every CoD viewmodel
// carries a `j_gun`; an HL2 `c_` rig does not — the C-CE Prisma is a kit baked onto HL2's
// AR2 arms and its only meaningful joint is the hand itself. Without this the anchor search
// fails, the gun falls back to its MESH ORIGIN, and in third person it floats beside the
// player instead of sitting in their fist.
//
// ⚠️ UNDERSCORE, NOT DOT, AND THAT IS NOT A TYPO. Source names the bone
// `ValveBiped.Bip01_R_Hand`, but Blender Source Tools sanitises the dot on export — so the
// COMPILED model has `ValveBiped_Bip01_R_Hand` while every source file, .qc and lua says
// otherwise. `nz_3p_anchor` with the dotted name silently finds nothing.
"ValveBiped_Bip01_R_Hand", "ValveBiped.Bip01_R_Hand",
};
/// <summary>
/// Force a specific pair of bones instead of letting the lists decide. `nz_3p_anchor`.
///
/// ⚠️ FOR FINDING THE RIGHT PAIR WITHOUT A REBUILD. Two rigs meet here and only one of them
/// is ours; which joint reads best is a judgement made by looking, not by reasoning, and every
/// rebuild-and-rejoin cycle to look at one is minutes.
/// </summary>
public static string BodyBoneOverride { get; set; } = "";
public static string GunBoneOverride { get; set; } = "";
/// <summary>
/// The correction that turns a VIEWMODEL mesh into something that sits in a fist.
///
/// ⛔ STATIC AND LIVE-TUNABLE, EXACTLY LIKE `ViewModelHandler.VMYaw`, AND FOR THE SAME REASON:
/// this is a hunt for six numbers shared by 418 weapons, and a property on each prefab would be
/// 418 edits and a restart per attempt. Once it is right it gets baked and this goes away.
///
/// ⚠️ A SURVIVING STATIC — INSTRUCTIONS PATTERN 1. It does NOT reset on hotload, so a value
/// typed in one session is still there in the next; `nz_3p_tune` with no argument prints the
/// current numbers so a session can never be tuning blind.
/// </summary>
public static float X { get; set; }
public static float Y { get; set; }
/// <summary>Down into the fist. The gun rides on the knuckles at 0 and disappears into the palm by -3.</summary>
public static float Z { get; set; } = -1.5f;
public static float Pitch { get; set; }
public static float Yaw { get; set; }
/// <summary>
/// A quarter turn about the barrel.
///
/// ⚠️ WITHOUT IT EVERY WEAPON IS HELD FLAT, gangster-style, slide facing the sky — the CoD
/// weapon rigs and the s&box citizen's `hold_R` simply disagree about which way is up. It is
/// the same class of thing as `ViewModelHandler.VMYaw`: one constant, shared by the whole pack,
/// because they all came out of the same exporter.
/// </summary>
public static float Roll { get; set; } = 90f;
/// <summary>How much to shrink the viewmodel mesh. Viewmodels are authored oversized.</summary>
public static float Scale { get; set; } = 1f;
/// <summary>
/// `nz_3p_anchor <body bone> <weapon bone>` — try a different pair of joints.
///
/// ⚠️ EMPTY EITHER ARGUMENT TO GO BACK TO AUTOMATIC. `nz_3p_anchor` on its own resets both.
/// ⚠️ IT DROPS EVERY GUN SO THEY REBUILD, because the anchor is resolved once when the gun
/// is created — without this the command would appear to do nothing until a weapon switch.
/// </summary>
[ConCmd( "nz_3p_anchor" )]
public static void Anchor( string bodyBone = "", string gunBone = "" )
{
BodyBoneOverride = bodyBone ?? "";
GunBoneOverride = gunBone ?? "";
var n = 0;
foreach ( var go in PlayerSpawner.AllBodies() )
{
var tp = go.Components.Get<ThirdPersonWeapon>( FindMode.EverythingInSelf );
if ( !tp.IsValid() ) continue;
tp.Drop();
// ⛔ `Drop()` ALONE LOOKS LIKE IT WORKS AND DOES NOTHING. The gun is only ever built
// when the published path stops matching `_shownPath`, so dropping the object without
// also forgetting the path leaves a component that is holding nothing and believes it is
// up to date — `nz_3p` reported `gun object no` for a weapon still in the player's hands.
tp._shownPath = "";
n++;
}
Log.Info( $"[nz-3p] anchor: body '{(string.IsNullOrEmpty( BodyBoneOverride ) ? "auto" : BodyBoneOverride)}'"
+ $" ← weapon '{(string.IsNullOrEmpty( GunBoneOverride ) ? "auto" : GunBoneOverride)}'"
+ $" ({n} rebuilding)" );
}
/// <summary>
/// `nz_3p_tune [x] [y] [z] [pitch] [yaw] [roll] [scale]` — place the gun in the hand.
///
/// ⚠️ NO ARGUMENT REPORTS AND CHANGES NOTHING, so it is safe to type first.
/// </summary>
[ConCmd( "nz_3p_tune" )]
public static void Tune( float x = float.NaN, float y = 0f, float z = -1.5f,
float pitch = 0f, float yaw = 0f, float roll = 90f, float scale = 1f )
{
if ( !float.IsNaN( x ) )
{
X = x; Y = y; Z = z; Pitch = pitch; Yaw = yaw; Roll = roll; Scale = scale;
}
Log.Info( $"[nz-3p] gun in hand: pos {X:0.##} {Y:0.##} {Z:0.##}"
+ $" ang {Pitch:0.##} {Yaw:0.##} {Roll:0.##} scale {Scale:0.###}" );
Log.Info( $"[nz-3p] nz_3p_tune {X:0.##} {Y:0.##} {Z:0.##} {Pitch:0.##} {Yaw:0.##} {Roll:0.##} {Scale:0.###}" );
}
/// <summary>
/// The gun object in this player's hand ON THIS MACHINE, or null when they hold nothing.
///
/// ⚠️ THIS IS WHAT LETS A REMOTE PLAYER'S MUZZLE FLASH EXIST AT ALL. `PapMuzzleFlash.Spawn`
/// needs a muzzle to hang off, and the real one belongs to a weapon that is
/// `NetworkMode.Never` — it is on exactly one machine. This object is on every machine, at the
/// right hand, following the animation, which is the only place a remote flash can honestly go.
/// </summary>
public GameObject Gun => _go;
/// <summary>The in-hand gun object for a given player on this machine.</summary>
public static GameObject GunOf( NZPlayer player )
=> player.IsValid()
&& player.Components.Get<ThirdPersonWeapon>( FindMode.EverythingInSelf ) is { } tp
? tp.Gun
: null;
GameObject _go;
SkinnedModelRenderer _renderer;
string _shownPath = "";
string _shownHold = "";
Model _posedModel;
string _bone;
string _anchor;
bool _warned;
/// <summary>The last `Build` found no hand, so `Apply` looks again every <see cref="HandRetry"/> seconds.</summary>
bool _noHand;
TimeSince _sinceBuild;
/// <summary>The body model the gun was hung on: a different one (a character picked, a respawn) hangs it again.</summary>
Model _handModel;
/// <summary>How often a gun with no hand to sit in looks for one again.</summary>
const float HandRetry = 0.25f;
// ══ the knife swing (2026-10-05) ═══════════════════════════════════════════════════════
/// <summary>
/// What `Knife` sends through `NZNet.PlayerAnim` for a swing. Not an animgraph parameter: a request this component turns into
/// one, so every machine plays the same swing — the melee hold, the knife in the hand, then `b_attack`.
/// </summary>
public const string MeleeGesture = "nz_melee";
static float? _meleeSeconds;
/// <summary>
/// How long a third-person knife swing keeps the melee hold and the knife before the gun comes back. 1.0s: the length of the
/// attack clip itself (`Citizen@Melee_Weapons_2H_Attack_01.fbx`, and the punches, all 1.0s). It was 0.6s, which put the gun's
/// hold back mid-swing and cut the swing off — the user: *"i hardly see it"* (2026-10-05).
/// </summary>
public static float MeleeSeconds { get => _meleeSeconds ?? 1f; set => _meleeSeconds = value; }
/// <summary>
/// A hold as the body's animgraph names it.
///
/// ⛔ NOT ALWAYS THE LOWERCASED `HoldTypes` NAME (2026-10-05). The graph's `holdtype` options are `none, pistol, rifle,
/// shotgun, holditem, melee_punch, melee_weapons, rpg` (+ `physgun` on the Citizen graphs), read out of
/// `citizen_human_reuse.vanmgrph` and `citizen.vanmgrph`. `Punch` and `Swing` are NOT options, so setting them did nothing:
/// no melee weapon ever posed, and the knife's swing played in whatever hold the body was already in. The user, of the
/// third-person knife: *"i hardly see it"*.
/// </summary>
static string AnimHold( HoldTypes hold ) => hold switch
{
HoldTypes.Punch => "melee_punch",
HoldTypes.Swing => "melee_weapons",
_ => hold.ToString().ToLowerInvariant(),
};
/// <summary>The knife's own attach bone in its BO2 mesh, which has no `j_gun` (`Build`).</summary>
const string KnifeAnchor = "tag_knife";
/// <summary>A knife swing is playing on this body until this runs out (`PlayBodyAnim`).</summary>
TimeUntil _meleeUntil;
/// <summary>The swing's `b_attack`, held back a frame so the melee hold is in place when the animgraph reads it.</summary>
bool _meleeAttackPending;
bool MeleeActive => _meleeUntil > 0f;
/// <summary>The last gun the "is now holding" and "grip" lines were written for: a swing hangs the knife and then the gun
/// again, and writing both lines every swing, on every machine, would bury the log.</summary>
string _loggedHold, _loggedGrip;
// ══ the reload (2026-10-05) ═══════════════════════════════════════════════════════════
/// <summary>
/// One step of a reload, as the gun reports it (`Weapon.BodyReload.cs` → `NZNet.PlayerReload`). The values travel as ints.
///
/// ⛔ THE RELOAD WAS ONE BARE `b_reload`, sent through `PlayBodyAnim` before the reload had a length. The body played its clip at
/// the authored 1.67 s whatever the gun took, a shotgun re-sent it every round to a graph that answers it once, nothing said
/// when a reload ended, and the left hand stayed pinned to the gun (`GunGrip.Hands`). The user: *"I want the reload animations
/// to also be seen in third person"*. Each step now carries its length, and the body fits its clip to it (`speed_reload`).
/// `nz_anim b_reload` still fires the bare gesture, at whatever `speed_reload` was last set.
/// </summary>
public enum ReloadPhase
{
/// <summary>A whole magazine reload.</summary>
Magazine = 0,
/// <summary>A round-at-a-time reload opens, loading its first round as it does (`Weapon.OnShellReload`).</summary>
ShellStart = 1,
/// <summary>One more round goes in.</summary>
ShellInsert = 2,
/// <summary>A round-at-a-time reload closes, or stops part way.</summary>
ShellEnd = 3,
/// <summary>A magazine reload stopped part way (the knife, a power-up).</summary>
Cancel = 4,
}
// ⛔ THE CLIP LENGTHS ARE READ FROM THE ENGINE'S FILES, NOT GUESSED (INSTRUCTIONS §41): frames at 30 fps, from
// `addons/citizen/Assets/models/citizen/prefabs/citizen_animationlist.vmdl_prefab` and the clip FBXs' `TimeSpanStop`.
/// <summary>`Pistol_2H_Reload_01`, `Pistol_RH_Reload_01`, and `SMG_2H_Reload_01`, which the rifle and rpg holds play: 50 frames.</summary>
const float MagazineClip = 50f / 30f;
/// <summary>`Shotgun_2H_Reload_01_entry`, frames 0–13: the gun brought up to load.</summary>
const float ShellEntryClip = 13f / 30f;
/// <summary>`Shotgun_2H_Reload_01_loop`, frames 12–20: one shell in.</summary>
const float ShellInsertClip = 8f / 30f;
/// <summary>`Shotgun_2H_Reload_01_exit`, frames 20–35: back up to aim.</summary>
const float ShellExitClip = 15f / 30f;
/// <summary>The body's closing when the gun has no closing clip of its own and is ready at once.</summary>
const float ShellExitQuick = 0.3f;
/// <summary>How long past a round's end `b_reloading` waits for the next step before letting go by itself.</summary>
const float ShellGrace = 0.75f;
/// <summary>How long a round-at-a-time reload stays shown past its step, so the left hand does not snap back between rounds.</summary>
const float ShellMargin = 0.35f;
/// <summary>A round-at-a-time reload on a hold with no round loop (a revolver, a tube-fed rifle) plays its one reload clip
/// about this often, rather than once in slow motion across the whole reload.</summary>
const float RepeatEvery = 2.5f;
/// <summary>Steps the RPC handed over, put on the body in `OnUpdate` (`TickReload`), not inside the RPC.</summary>
System.Collections.Generic.Queue<(ReloadPhase Phase, float Seconds, float Total)> _reloadSteps;
/// <summary>The body shows a reload until this runs out, and the left hand is off the gun meanwhile (`Apply`).</summary>
TimeUntil _reloadUntil;
bool ReloadActive => _reloadUntil > 0f;
/// <summary>`b_reloading` is held: the shotgun graph's round loop is open.</summary>
bool _shellHeld;
/// <summary>`b_reloading` lets go by then unless a step renews it: an owner who went down or switched away mid-reload sends no
/// closing.</summary>
TimeUntil _shellRelease;
/// <summary>A magazine reload on the shotgun hold, played as the loop in three parts: its push and its closing, still to come.</summary>
TimeUntil _magInsertAt, _magCloseAt;
bool _magInsertDue, _magCloseDue;
float _magPart;
/// <summary>A round-at-a-time reload on a hold with no loop: how many more plays of the reload clip, how far apart, and when.</summary>
int _repeatsLeft;
float _repeatSlot;
TimeUntil _repeatAt;
/// <summary>The body whose animgraph tags this listens to (`WatchTags`).</summary>
SkinnedModelRenderer _tagBody;
/// <summary>The graph's `Reloading` tag came on since the watch began.</summary>
bool _reloadTagSeen;
/// <summary>A reload waiting to be reported, until the watch runs out (`ReportReload`).</summary>
bool _reloadWatch;
TimeUntil _reloadWatchUntil;
string _reloadWatchHold, _reloadWatchLine;
/// <summary>The holds a reload has been reported for on this body, once each; and how many failures have been.</summary>
System.Collections.Generic.HashSet<string> _reloadSaid;
int _reloadWarned;
/// <summary>
/// Where the weapon's anchor bone sits relative to the weapon OBJECT, in unscaled model units.
///
/// ⚠️ READ OFF THE LIVE RENDERER, NOT OUT OF THE MODEL. `Model.Bones[…].LocalTransform` is
/// undocumented as to what it is relative to, and measuring it settled the question the wrong
/// way round: composing the parent chain for `tag_weapon` gives (-23, -238, 8), a point twenty
/// feet from a gun. The renderer's own `TryGetBoneTransform` has no such ambiguity.
///
/// ⚠️ RESOLVED BEFORE THE OBJECT IS MOVED, which is the whole reason it is a cached nullable
/// rather than something read on demand: the renderer's bones were computed for the transform
/// this object had LAST frame, so reading them is only meaningful while that transform is still
/// what is on the object.
/// </summary>
Transform? _anchorLocal;
NZPlayer Player => Components.Get<NZPlayer>( FindMode.EverythingInSelf );
/// <summary>The body's renderer — the thing the hold pose is set on and the hand belongs to.</summary>
SkinnedModelRenderer Body
{
get
{
var ctrl = Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
if ( ctrl.IsValid() && ctrl.Renderer.IsValid() ) return ctrl.Renderer;
// ⛔ `PlayerController.Renderer` READS NULL MORE OFTEN THAN IT LOOKS.
// `PlayerCharacters.ApplyBody` carries the same fallback and the same warning — it is a
// component REFERENCE, and this project has never established that one survives every
// path a body can arrive by. Measured here: it was null on the scene's own player, in
// the editor, on the first frame of play.
//
// ⚠️ EXCLUDING VIEWMODELS BY TAG, which is what makes the fallback safe now: weapons
// no longer cross the network at all, so on any body the only other skinned renderer is
// the body — and a viewmodel is tagged.
return Components
.GetAll<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants )
.FirstOrDefault( r => r.IsValid()
&& !r.GameObject.Tags.Has( SWB.Shared.TagsHelper.ViewModel ) );
}
}
protected override void OnUpdate()
{
var np = Player;
if ( !np.IsValid() ) return;
// ⚠️ ONLY THE OWNER MAY WRITE. `[Sync]` silently discards a write from a proxy
// (`SBOX_MULTIPLAYER.md` §5), so doing it anyway would fail quietly on every machine but
// one — which is the shape of half the bugs this session.
if ( !Networking.IsActive || PlayerPresence.Mine( GameObject ) ) Publish( np );
// ⚠️ THE RELOAD BEFORE `Apply` (2026-10-05), so the left hand comes off the gun on the frame a reload starts.
TickReload( np );
Apply( np );
}
/// <summary>
/// Say what I am holding, so every other machine can show it.
///
/// ⚠️ THE WORLD MODEL'S PATH, NOT THE WEAPON PREFAB'S. Any machine can `Model.Load` a path
/// with no prefab, no `SWB.Weapon` component and no inventory — and the weapon prefabs are
/// `NetworkMode.Never` precisely so they never reach another machine. Sending what to DRAW
/// asks nothing of the thing that is not there.
/// </summary>
void Publish( NZPlayer np )
{
var wep = Rarity.HeldBy( np );
// ⚠️ A DOWNED PLAYER HOLDS NOTHING. `GoDown` strips the weapon, and a gun left in a
// crawling player's hand is the kind of detail that reads as a bug from across the map.
//
// ⛔ NOT ONE OF THE 418 WEAPON PREFABS HAS A `WorldModel`. Measured, not assumed — every
// one of them is `WorldModel = None`, because the ARC9 port brought viewmodels and nothing
// else. So "use the weapon's world model" would draw nothing, for every weapon, forever.
//
// ⚠️ THE VIEWMODEL MESH IS THE STAND-IN, AND IT IS A STAND-IN. `v_m1911.vmdl` is the gun
// alone — the hands are a separate bone-merged renderer, which `nz_arms` shows — so the
// geometry is right even though the ORIGIN is authored for a viewmodel camera rather than
// for a fist. `nz_3p_tune` exists to dial that out once, globally, the same way
// `ViewModelHandler.VMYaw` had to for the first-person case: *"every ARC9 BO1 viewmodel is
// authored a quarter turn from the axis SWB expects."*
//
// ⚠️ WHEN REAL WORLD MODELS ARE PORTED, THIS LINE IS THE ONLY PLACE THAT CHANGES — the
// `?? ` falls away and everything downstream already works on a path.
var model = wep.IsValid() ? wep.WorldModel ?? wep.ViewModel : null;
// ⚠️ ITS DISPLAY MODEL WHEN IT HAS ONE (`WeaponDisplay`, 2026-10-01): an MW viewmodel with no clip playing is its
// bind pose, parts apart. The anchors there keep their bind transforms, so the grip below does not move.
var path = model is not null && !np.IsDown ? WeaponDisplay.PathFor( model.ResourcePath ?? "" ) : "";
// ⚠️ THROUGH `GunGrip.HoldFor` (2026-10-05): a long gun tagged with the pistol hold takes the rifle's, by its manifest class.
var hold = wep.IsValid() && !np.IsDown ? GunGrip.HoldFor( wep, wep.HoldType ) : HoldTypes.None;
if ( np.WorldModelPath != path ) np.WorldModelPath = path;
if ( np.HoldTypeId != (int)hold ) np.HoldTypeId = (int)hold;
}
/// <summary>
/// Fire a one-shot GESTURE on this player's body — the reload, the swing. Runs on every
/// machine, called by <see cref="NZNet.PlayerAnim"/>.
///
/// ⛔ NOTHING IN THIS PROJECT EVER DID THIS. `holdtype` was the ONLY animgraph parameter any
/// code set on a player body, so a reload or a knife swing moved the viewmodel and left the
/// body standing there holding its pose — for everyone, including the player themselves in
/// third person. User: *"the players do not see each other do the reload and knifing
/// animations."* It reads as a networking fault and is not one: there was no animation to fail
/// to replicate.
///
/// ⚠️ THE NAMES COME FROM THE ENGINE, NOT FROM A GUESS. `Sandbox.Engine.xml` documents
/// `BaseCombatWeapon.OnShootEffects` as firing *"the holder's b_attack"* and `OnReloadStarted`
/// as firing *"the b_reload gesture"*. A wrong parameter name compiles and animates nothing,
/// which is the same silent failure the `holdtype` note above was written about.
///
/// ⚠️ A GESTURE, SO IT IS SET AND FORGOTTEN. The animgraph consumes the trigger; there is no
/// matching "false" to send and no duration to keep in step across machines.
/// </summary>
public void PlayBodyAnim( string param )
{
if ( string.IsNullOrEmpty( param ) ) return;
var body = Body;
if ( !body.IsValid() ) return;
// ⛔ THE KNIFE ASKS FOR A SWING, NOT FOR `b_attack` (2026-10-05). It sent `b_attack` alone, and the animgraph plays
// `b_attack` for the hold the body is in — the GUN's, since the knife never puts the gun away — so a third-person knife
// was a pistol or a rifle recoiling. The user: *"In third person, the players do not have a knifing animation"*. A swing
// is the melee-weapons hold and then `b_attack`; `Apply` keeps the hold, with the knife in the hand, for `MeleeSeconds`.
//
// ⚠️ THE GRAPH HAS NO KNIFE STAB. GMod's quick-knife switched to a real knife weapon (`tfa_quickknife_base`, hold type
// "knife") and played its own attack gesture; s&box's graphs have punches (left or right, at random) and one weapon swing
// (`Melee_Weapons_2H_Attack_01`). The swing is the one that reads from across a room, with the knife in the right hand.
//
// ⚠️ A DOWNED PLAYER GETS THE OLD GESTURE ONLY: the crawl is its own pose, and a melee hold laid over it is not this fix.
if ( param == MeleeGesture )
{
if ( Player is { } np && np.IsValid() && np.IsDown )
{
body.Set( "b_attack", true );
return;
}
_meleeUntil = MeleeSeconds;
_meleeAttackPending = true;
return;
}
body.Set( param, true );
}
/// <summary>
/// One step of this player's reload, on every machine (`NZNet.PlayerReload`). Queued: `TickReload` puts it on the body.
/// </summary>
public void PlayReload( int phase, float seconds, float total )
{
// ⚠️ MADE HERE, NOT BY AN INITIALISER: a component alive across a hotload gets a new field without its initialiser running.
_reloadSteps ??= new();
if ( _reloadSteps.Count < 8 ) _reloadSteps.Enqueue( ((ReloadPhase)phase, seconds, total) );
}
/// <summary>
/// The reload's steps onto the body, and the parts it plays by itself. Every frame on every machine, BEFORE `Apply`.
/// </summary>
void TickReload( NZPlayer np )
{
var body = Body;
if ( !body.IsValid() )
{
_reloadSteps?.Clear();
return;
}
WatchTags( body );
// ⚠️ THE HOLD AS THE GRAPH NAMES IT (`AnimHold`). None while downed or mid knife swing: neither has a reload.
var hold = MeleeActive || np.IsDown ? null : AnimHold( (HoldTypes)np.HoldTypeId );
while ( _reloadSteps is { Count: > 0 } )
{
var step = _reloadSteps.Dequeue();
ApplyReloadStep( body, hold, step.Phase, step.Seconds, step.Total );
}
if ( _magInsertDue && _magInsertAt <= 0f )
{
_magInsertDue = false;
body.Set( "speed_reload", ReloadRate( ShellInsertClip, _magPart ) );
body.Set( "b_reloading_insert", true );
}
if ( _magCloseDue && _magCloseAt <= 0f )
{
_magCloseDue = false;
CloseShell( body, _magPart );
}
if ( _repeatsLeft > 0 && _repeatAt <= 0f )
{
_repeatsLeft--;
_repeatAt = _repeatSlot;
body.Set( "speed_reload", ReloadRate( MagazineClip, _repeatSlot * 0.9f ) );
body.Set( "b_reload", true );
}
// ⚠️ NOTHING RENEWED THE LOOP, OR THE PLAYER WENT DOWN: let it go, or the body would stand in the loading pose for good.
if ( _shellHeld && (_shellRelease <= 0f || np.IsDown) ) CloseShell( body, ShellExitQuick );
ReportReload( body );
}
/// <summary>Put one step on the body, as its hold plays it.</summary>
void ApplyReloadStep( SkinnedModelRenderer body, string hold, ReloadPhase phase, float seconds, float total )
{
seconds = MathF.Max( 0f, seconds );
total = MathF.Max( seconds, total );
// ⚠️ ONLY THE SHOTGUN HOLD HAS A ROUND LOOP (`b_reloading` held, `b_reloading_insert` per round, read out of
// `citizen_holdtype_shotgun.vsubgrph`). The pistol, rifle and rpg holds have one reload clip, on `b_reload`. The other holds
// (none, an item, the melee ones) have no reload at all, so a step on one shows nothing.
var shotgun = hold == "shotgun";
var clip = hold is "pistol" or "rifle" or "rpg";
if ( !shotgun && !clip && (phase is ReloadPhase.Magazine or ReloadPhase.ShellStart or ReloadPhase.ShellInsert) ) return;
var wasShowing = ReloadActive;
switch ( phase )
{
case ReloadPhase.Magazine:
StopReloadParts();
if ( shotgun )
{
// ⚠️ A MAGAZINE ON THE SHOTGUN HOLD IS THE ROUND LOOP IN THREE PARTS, each fitted to its share: up to load, one push
// in, back to aim. The hold's own `b_reload` clip holds a 1.2 s loop no `speed_reload` shortens.
_magPart = seconds * 0.35f;
OpenShell( body, seconds * 0.3f, seconds + ShellGrace );
_magInsertAt = seconds * 0.3f;
_magInsertDue = true;
_magCloseAt = seconds * 0.65f;
_magCloseDue = true;
}
else
{
body.Set( "speed_reload", ReloadRate( MagazineClip, seconds ) );
body.Set( "b_reload", true );
}
Showing( hold, seconds, 0.15f, "a magazine", wasShowing );
break;
case ReloadPhase.ShellStart:
StopReloadParts();
if ( shotgun )
{
OpenShell( body, seconds, seconds + ShellGrace );
Showing( hold, seconds, ShellMargin, "round by round", wasShowing );
break;
}
// ⚠️ NO LOOP ON THIS HOLD: ITS ONE CLIP ACROSS THE WHOLE RELOAD, played again every `RepeatEvery` or so when that is
// long, rather than in slow motion or once per round.
var plays = Math.Max( 1, (int)MathF.Round( total / RepeatEvery ) );
_repeatSlot = total / plays;
_repeatsLeft = plays - 1;
_repeatAt = _repeatSlot;
body.Set( "speed_reload", ReloadRate( MagazineClip, _repeatSlot * 0.9f ) );
body.Set( "b_reload", true );
Showing( hold, total, ShellMargin, $"round by round, as {plays} clip(s)", wasShowing );
break;
case ReloadPhase.ShellInsert:
if ( shotgun )
{
if ( !_shellHeld ) OpenShell( body, seconds, seconds + ShellGrace );
else
{
body.Set( "speed_reload", ReloadRate( ShellInsertClip, seconds ) );
body.Set( "b_reloading_insert", true );
_shellRelease = seconds + ShellGrace;
}
}
_reloadUntil = MathF.Max( _reloadUntil, seconds + ShellMargin );
break;
case ReloadPhase.ShellEnd:
_repeatsLeft = 0;
if ( _shellHeld ) CloseShell( body, seconds > 0.05f ? seconds : ShellExitQuick );
// ⚠️ THE ESTIMATE RAN LONG (fewer rounds went in than it counted): the clip still playing is hurried to its end.
else if ( clip && _reloadUntil > seconds + 0.5f )
{
body.Set( "speed_reload", 5f );
_reloadUntil = MathF.Max( seconds, 0.15f );
}
break;
case ReloadPhase.Cancel:
StopReloadParts();
// ⚠️ THE CLIP ALREADY PLAYING RUNS OUT AT ONCE: the graph's Reload state only leaves when its clip finishes.
if ( _shellHeld ) CloseShell( body, 0.1f );
else body.Set( "speed_reload", 5f );
_reloadUntil = 0.15f;
break;
}
}
/// <summary>Forget the parts still to play by themselves: the shotgun-hold magazine's push and closing, the clip's repeats.</summary>
void StopReloadParts()
{
_magInsertDue = _magCloseDue = false;
_repeatsLeft = 0;
}
/// <summary>Open the shotgun graph's round loop: up to load, the entry clip over <paramref name="entrySeconds"/>.</summary>
void OpenShell( SkinnedModelRenderer body, float entrySeconds, float release )
{
body.Set( "speed_reload", ReloadRate( ShellEntryClip, entrySeconds ) );
body.Set( "b_reloading", true );
_shellHeld = true;
_shellRelease = release;
}
/// <summary>Close it: back up to aim, the exit clip over <paramref name="exitSeconds"/>.</summary>
void CloseShell( SkinnedModelRenderer body, float exitSeconds )
{
body.Set( "speed_reload", ReloadRate( ShellExitClip, exitSeconds ) );
body.Set( "b_reloading", false );
_shellHeld = false;
_magInsertDue = _magCloseDue = false;
_reloadUntil = exitSeconds + 0.1f;
}
/// <summary>The `speed_reload` that plays a clip of <paramref name="clip"/> seconds in <paramref name="seconds"/>, within the
/// graph's own 0.05–5.</summary>
static float ReloadRate( float clip, float seconds ) => Math.Clamp( clip / MathF.Max( 0.01f, seconds ), 0.05f, 5f );
/// <summary>
/// A reload is on the body for <paramref name="seconds"/>: the left hand comes off the gun for it, and the graph's answer is
/// watched for (`ReportReload`).
/// </summary>
void Showing( string hold, float seconds, float margin, string kind, bool wasShowing )
{
_reloadUntil = seconds + margin;
// ⚠️ NOT WATCHED WHEN ONE WAS ALREADY SHOWING: the graph's tag would not come on afresh, and the line would be a false alarm.
if ( wasShowing ) return;
_reloadTagSeen = false;
_reloadWatch = true;
_reloadWatchUntil = MathF.Min( seconds, 1f ) + 0.5f;
_reloadWatchHold = hold;
_reloadWatchLine = $"{hold} hold, {kind}, {seconds:0.00}s";
}
/// <summary>
/// SAY WHETHER THE BODY TOOK IT, ON EVERY MACHINE. Five things stand between a gun's reload and a body showing it — the send,
/// the RPC, finding the body, the graph accepting it, nothing pinning the arms — and four of them fail silently.
///
/// ⚠️ ONCE PER HOLD PER BODY when it works, and the first three per body that do not. "Did not" means the graph never reported
/// its `Reloading` tag: either it refused the reload, or tag events do not reach code on this build. Which one is a matter of
/// watching the body.
/// </summary>
void ReportReload( SkinnedModelRenderer body )
{
if ( !_reloadWatch || (!_reloadTagSeen && _reloadWatchUntil > 0f) ) return;
_reloadWatch = false;
if ( _reloadTagSeen )
{
_reloadSaid ??= new();
if ( _reloadSaid.Add( _reloadWatchHold ?? "" ) )
Log.Info( $"[nz-3p] '{GameObject.Name}' reloads on its body ({_reloadWatchLine}): its animgraph entered 'Reloading' ✔"
+ " and the left hand is off the gun for it" );
return;
}
if ( _reloadWarned++ < 3 )
Log.Warning( $"[nz-3p] ⚠️ '{GameObject.Name}': a reload went to its body ({_reloadWatchLine}, model "
+ $"{Leaf( body.Model?.ResourceName )}) but its animgraph never reported 'Reloading'. Either the graph refused it, or"
+ " tag events do not reach code here: watch the body to tell which." );
}
/// <summary>Listen to this body's animgraph tags, and stop listening to the last one's.</summary>
void WatchTags( SkinnedModelRenderer body )
{
if ( ReferenceEquals( body, _tagBody ) ) return;
if ( _tagBody.IsValid() ) _tagBody.OnAnimTagEvent -= OnAnimTag;
_tagBody = body;
body.OnAnimTagEvent += OnAnimTag;
}
void OnAnimTag( SceneModel.AnimTagEvent e )
{
if ( e.Name == "Reloading" && e.Status == SceneModel.AnimTagStatus.Start ) _reloadTagSeen = true;
}
/// <summary>Show whatever this player says they are holding. Runs on every machine.</summary>
void Apply( NZPlayer np )
{
var body = Body;
if ( !body.IsValid() ) return;
// ── the pose ──────────────────────────────────────────────────────────────────────
//
// ⚠️ AN OPTION NAME, NOT A NUMBER. `SkinnedModelRenderer.Set( "holdtype", … )` is
// documented as taking the enum's OPTION NAME — `Set( "holdtype", "pistol" )`. Sending an
// int would compile and animate nothing.
//
// ⛔ AND THE GRAPH'S OPTION NAME, WHICH IS NOT ALWAYS OURS LOWERCASED (2026-10-05) — see `AnimHold`. This read
// "our `HoldTypes` is a copy of `CitizenAnimationHelper.HoldTypes`, so the lowercased member name is the option",
// which was true of six of the eight and false of both melee holds.
var hold = AnimHold( (HoldTypes)np.HoldTypeId );
// ⚠️ A KNIFE SWING HOLDS THE MELEE POSE (2026-10-05, `PlayBodyAnim`). The gun's comes back by itself when it runs out: the
// next frame's hold no longer matches the one shown.
if ( MeleeActive ) hold = AnimHold( HoldTypes.Swing );
// ⛔ THE RENDERER IS PART OF THE COMPARISON, NOT JUST THE VALUE. `PlayerCharacters.ApplyBody`
// swaps `rend.Model` when a player picks a character, and the new model starts on its
// animgraph's DEFAULT holdtype — while this component still believes it has already sent the
// right one and would never send it again. The result would be a player who holds their
// weapon correctly until they change character and empty-handed-looking after, which is
// exactly the sort of "only sometimes" bug that costs a session to find.
var posed = false;
if ( hold != _shownHold || !ReferenceEquals( body.Model, _posedModel ) )
{
_shownHold = hold;
_posedModel = body.Model;
body.Set( "holdtype", hold );
posed = true;
}
// ⚠️ THE SWING ITSELF A FRAME AFTER ITS HOLD (2026-10-05). Set in the same frame, the animgraph could read `b_attack` while
// still in the gun's pose and play the gun's attack after all. A swing that runs out before it fires is dropped.
if ( _meleeAttackPending )
{
if ( !MeleeActive ) _meleeAttackPending = false;
else if ( !posed )
{
_meleeAttackPending = false;
body.Set( "b_attack", true );
}
}
// ── the gun ───────────────────────────────────────────────────────────────────────
//
// ⚠️ THE KNIFE STANDS IN FOR IT DURING A SWING (2026-10-05) — its viewmodel mesh, as every gun's is (`Publish`) — and the
// gun comes back with the hold. The first-person knife puts the gun out of sight the same way (`Knife.ShowGuns`).
var path = MeleeActive ? KnifeViewModel.ModelPath : np.WorldModelPath;
if ( path != _shownPath )
{
_shownPath = path;
Drop();
if ( !string.IsNullOrEmpty( _shownPath ) ) Build( body );
}
// ⛔ AND HUNG AGAIN WHEN THE HAND WAS NOT THERE, OR THE BODY CHANGED (2026-10-05). This ran on a new path only, so a gun
// shown at the wrong moment stayed missing: a body that has just respawned has no bones for a frame, and one still on
// the citizen model has no hand by these names (`[nz-3p] ⛔ no hand bone on 'dempsey_rigged'` and `on 'citizen'`, both
// at the 23:45:55 respawn). The player looked empty-handed to everyone until they switched guns. The warning stays at
// one per gun; the retry is six bone lookups, four times a second, only while a hand is missing.
else if ( !string.IsNullOrEmpty( _shownPath )
&& ( _noHand ? _sinceBuild > HandRetry : _go.IsValid() && !ReferenceEquals( body.Model, _handModel ) ) )
{
var warned = _warned;
Drop();
_warned = warned;
Build( body );
}
if ( !_go.IsValid() )
{
GunGrip.Clear( body );
return;
}
// ⛔ READ THE ANCHOR *BEFORE* MOVING ANYTHING. The renderer's bone transforms were computed
// for the transform this object had LAST frame, so this is the one moment in the frame where
// `_go.WorldTransform` and the bone agree — reading after the write below would measure the
// offset against a transform the bones know nothing about, and the gun would walk away from
// the hand a little further every frame.
if ( _anchor is not null && _anchorLocal is null && _renderer.IsValid()
&& _renderer.TryGetBoneTransform( _anchor, out var anchorWorld ) )
{
_anchorLocal = _go.WorldTransform.ToLocal( anchorWorld );
// ⚠️ ONCE PER GUN AND NEVER FOR THE KNIFE (2026-10-05) — see `_loggedGrip`.
if ( _shownPath != KnifeViewModel.ModelPath && _shownPath != _loggedGrip )
{
_loggedGrip = _shownPath;
Log.Info( $"[nz-3p] '{GameObject.Name}' grip: '{_anchor}' sits at "
+ $"{_anchorLocal.Value.Position} in {Leaf( _shownPath )}" );
}
}
// ⛔ A GUN WITH NO GUN BONE TO TRUST IS SEATED BY ITS OWN SHAPE (2026-10-05, `GunGrip.Frame`): the TFA-format packs, whose
// only anchor was the first-person ARMS' hand, so the gun sat wherever those arms held it in their bind pose — Destiny's
// pointed back past the hip, 28 u from the hand. Read in this same moment, before the object moves, once per gun (the mark is
// on the gun object), and never for the knife: `tag_knife` is trusted. Retried a few frames while the bones are not up yet.
if ( GunGrip.MeshGrip && GunGrip.Untrusted( _anchor ) && _shownPath != KnifeViewModel.ModelPath && _renderer.IsValid() )
{
var st = _go.Components.Get<GunGripState>( FindMode.EverythingInSelf ) ?? _go.Components.Create<GunGripState>();
if ( !st.FrameDone && ( _anchor is null || _anchorLocal is not null ) )
{
if ( GunGrip.Frame( _renderer, _anchorLocal, np.HoldTypeId == (int)HoldTypes.Pistol ) is Transform seat )
{
_anchorLocal = seat;
st.FrameDone = true;
}
else if ( ++st.FrameTries > 30 ) st.FrameDone = true;
}
}
// ⚠️ POSITIONED FROM THE BONE EVERY FRAME RATHER THAN PARENTED TO A BONE OBJECT. Bone
// GameObjects only exist when `CreateBoneObjects` is on, and turning that on for every
// player's whole skeleton to hang one gun off it is a hundred objects for one.
// `TryGetBoneTransform` is the same answer without them.
if ( _bone is null || !body.TryGetBoneTransform( _bone, out var hand ) ) return;
// ⚠️ THE TUNING IS APPLIED IN THE HAND'S OWN FRAME, not in world space — an offset along
// world axes would swing the gun around the player as they turned. It moves the GRIP
// relative to the hand, which is what anybody adjusting this is actually looking at.
var target = new Transform(
hand.Position + hand.Rotation * new Vector3( X, Y, Z ),
hand.Rotation * Rotation.From( Pitch, Yaw, Roll ) );
// ⚠️ SOLVING FOR THE OBJECT, NOT PLACING IT. We know where the grip must END UP, and
// where the grip sits inside the mesh; the object transform is whatever puts one on the
// other. With no anchor this collapses to identity and the mesh origin goes to the hand,
// which is the old behaviour and still the right fallback for a real world model.
var grip = _anchorLocal ?? new Transform( Vector3.Zero, Rotation.Identity, 1f );
var rot = target.Rotation * grip.Rotation.Inverse;
_go.WorldRotation = rot;
_go.WorldPosition = target.Position - rot * (grip.Position * Scale);
_go.WorldScale = Vector3.One * Scale;
// ⚠️ THE LEFT HAND ONTO A LONG GUN (2026-10-05, `GunGrip.Hands`), now the gun is where it is drawn this frame. Off during a
// knife swing: the body is in the two-handed melee swing then, with no gun to hold.
// ⚠️ AND OFF FOR A RELOAD (2026-10-05, `ReloadActive`): pinned to the handguard, the reload's own left hand (the magazine
// out and in, the shells) never moved.
GunGrip.Hands( body, _go, _renderer, (HoldTypes)np.HoldTypeId,
MeleeActive || _shownPath == KnifeViewModel.ModelPath || ReloadActive,
GunGrip.Untrusted( _anchor ) ? _anchorLocal : null );
}
/// <summary>Throw the current gun away so the next frame builds a fresh one.</summary>
void Drop()
{
if ( _go.IsValid() ) _go.Destroy();
_go = null;
_renderer = null;
_anchor = null;
_anchorLocal = null;
_warned = false;
_noHand = false;
}
void Build( SkinnedModelRenderer body )
{
_sinceBuild = 0f;
_noHand = false;
_handModel = body.Model;
var model = Model.Load( _shownPath );
// ⚠️ `IsError` TOO, NOT JUST NULL — `Model.Load` returns the ERROR MODEL for a path it
// cannot resolve, and a checkerboard box in a player's hand looks like a rendering fault
// rather than a missing asset. The same trap `ZombieAI.EnsureBody` and `Powerup.Spawn`
// both document.
if ( model is null || model.IsError )
{
if ( !_warned )
{
_warned = true;
Log.Warning( $"[nz-3p] world model '{_shownPath}' will not load — "
+ "this player will appear empty-handed" );
}
return;
}
_bone = !string.IsNullOrEmpty( BodyBoneOverride ) && body.TryGetBoneTransform( BodyBoneOverride, out _ )
? BodyBoneOverride
: HandBones.FirstOrDefault( b => body.TryGetBoneTransform( b, out _ ) );
if ( _bone is null )
{
_noHand = true;
if ( !_warned )
{
_warned = true;
Log.Warning( $"[nz-3p] ⛔ no hand bone on '{body.Model?.ResourceName}' — tried "
+ string.Join( ", ", HandBones ) + ". The gun has nowhere to sit, so it is not "
+ "being drawn rather than being dropped at the player's feet." );
}
return;
}
// ⛔ A CHILD OF THE BODY, WHICH IS WHAT HIDES IT IN FIRST PERSON. Tags inherit downward —
// proven this session — and the engine hides your own body by tagging that object
// `viewer`. A gun parented anywhere else would be excluded from nothing and would hang in
// front of your own camera, which is the floating-arms bug with a different mesh.
//
// ⚠️ THE TRANSFORM IS STILL WRITTEN IN WORLD SPACE every frame, so the parenting is only
// there for the tag and for cleanup.
// ⚠️ CHOSEN AGAINST THE MODEL, NOT THE RENDERER, because the renderer does not exist yet
// — and `Model.Bones.AllBones` answers "is this name in here" without any of the ambiguity
// that made `LocalTransform` unusable for the position.
var names = model.Bones?.AllBones?.Select( b => b.Name ).ToHashSet()
?? new System.Collections.Generic.HashSet<string>();
// ⚠️ THE KNIFE BY ITS OWN TAG (2026-10-05). Its BO2 mesh has no `j_gun`, and the ValveBiped hand the list would reach next
// is the arms' BIND pose, which for the guns sat a metre from the weapon (see `GunAnchors`).
_anchor = !string.IsNullOrEmpty( GunBoneOverride ) && names.Contains( GunBoneOverride )
? GunBoneOverride
: _shownPath == KnifeViewModel.ModelPath && names.Contains( KnifeAnchor )
? KnifeAnchor
: GunAnchors.FirstOrDefault( names.Contains );
_go = new GameObject( true, "Weapon (third person)" );
_go.SetParent( body.GameObject, false );
_go.NetworkMode = NetworkMode.Never;
_go.Flags |= GameObjectFlags.NotSaved;
_renderer = _go.Components.Create<SkinnedModelRenderer>();
_renderer.Model = model;
// ⚠️ ONCE PER GUN AND NEVER FOR THE KNIFE (2026-10-05) — see `_loggedHold`. After a missing-hand warning it is always said.
if ( _shownPath == KnifeViewModel.ModelPath || (_shownPath == _loggedHold && !_warned) ) return;
_loggedHold = _shownPath;
Log.Info( $"[nz-3p] '{GameObject.Name}' is now holding {Leaf( _shownPath )}"
+ $" — body bone '{_bone}' ← weapon bone '{_anchor ?? "⚠️ none, mesh origin"}'"
+ ( _warned ? $" (found on '{body.Model?.ResourceName}' after the warning above)" : "" ) );
}
protected override void OnDestroy()
{
Drop();
// ⚠️ AND STOP LISTENING TO THE BODY'S TAGS (2026-10-05, `WatchTags`): the body can outlive this component.
if ( _tagBody.IsValid() ) _tagBody.OnAnimTagEvent -= OnAnimTag;
}
/// <summary>
/// `nz_3p` — WHAT EVERY PLAYER IS HOLDING, AND WHETHER IT FOUND A HAND.
///
/// ⛔ THIS FEATURE FAILS IN FOUR PLACES AND THREE OF THEM ARE SILENT: the owner never
/// published, the value never arrived, the model would not load, or no hand bone matched. Only
/// a line per player with all four columns tells them apart — and the bone list is a GUESS
/// about a retargeted rig, so "which bone" is the column most likely to be the answer.
///
/// Client output routes to the host, so one capture holds both sides.
/// </summary>
[ConCmd( "nz_3p" )]
public static void Report()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
void Tell( string line )
{
if ( NZGame.IsHost || !Networking.IsActive ) Log.Info( line );
else NZNet.Say( line );
}
var who = NZGame.IsHost ? "HOST " : "CLIENT";
foreach ( var go in PlayerSpawner.AllBodies() )
{
var np = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( !np.IsValid() ) continue;
var tp = go.Components.Get<ThirdPersonWeapon>( FindMode.EverythingInSelf );
var body = tp.IsValid() ? tp.Body : null;
// ⚠️ EVERY CANDIDATE, NOT JUST THE ONE THAT WON. If none matched, the list of what was
// tried against the model that was actually loaded is the entire diagnosis.
var found = body.IsValid()
? HandBones.Where( b => body.TryGetBoneTransform( b, out _ ) ).ToList()
: new System.Collections.Generic.List<string>();
Tell( $"[nz-3p] {who} '{go.Name}' mine={PlayerPresence.Mine( go ),-5}"
+ $" holdtype={(HoldTypes)np.HoldTypeId}"
+ $" model={(string.IsNullOrEmpty( np.WorldModelPath ) ? "⛔ NONE PUBLISHED" : Leaf( np.WorldModelPath ))}" );
Tell( $"[nz-3p] {who} body={(body.IsValid() ? Leaf( body.Model?.ResourceName ) : "⛔ no renderer")}"
+ $" · hand bones present: {(found.Count == 0 ? "⛔ NONE OF " + string.Join( "/", HandBones ) : string.Join( ", ", found ))}"
+ $" · gun object {(tp.IsValid() && tp._go.IsValid() ? "yes" : "no")}"
+ $" · grip {(tp.IsValid() && tp._anchor is not null ? $"'{tp._anchor}' → '{tp._bone}'" : "⚠️ mesh origin")}"
+ $" {(tp.IsValid() && tp._anchorLocal is not null ? $"@ {tp._anchorLocal.Value.Position}" : "⚠️ NOT MEASURED YET")}"
+ (tp.IsValid() ? "" : " ⛔ NO ThirdPersonWeapon COMPONENT ON THIS BODY") );
// ⛔ THE THREE POSITIONS THAT SAY WHETHER THE SOLVE WORKED, and they are the only thing
// that does. A gun 300 units from its owner and a gun 3 units out look identical in every
// column above: same bones found, same offset measured, same object alive. Only the
// distance between where the grip ENDED UP and where the hand IS separates "the
// arithmetic is wrong" from "the arithmetic is right and the mesh is authored oddly".
if ( tp.IsValid() && tp._go.IsValid() && body.IsValid() && tp._bone is not null
&& body.TryGetBoneTransform( tp._bone, out var handAt ) )
{
var gripAt = tp._anchor is not null && tp._renderer.IsValid()
&& tp._renderer.TryGetBoneTransform( tp._anchor, out var gw )
? gw.Position
: tp._go.WorldPosition;
Tell( $"[nz-3p] {who} hand at {handAt.Position} grip at {gripAt}"
+ $" → {gripAt.Distance( handAt.Position ):0.#}u apart"
+ $" (object {tp._go.WorldPosition.Distance( handAt.Position ):0.#}u out)" );
}
}
}
/// <summary>
/// `nz_3p_bones <model path> [filter]` — WHAT BONES DOES THIS MODEL ACTUALLY HAVE.
///
/// ⛔ THE HAND BONE LIST IN THIS FILE IS A GUESS, AND SO IS EVERY ASSUMPTION ABOUT WHAT IS IN
/// A VIEWMODEL. `TryGetBoneTransform` answers "is this exact name here", which can only ever
/// confirm a name already guessed — it cannot show a name nobody thought of. That is exactly the
/// blind spot INSTRUCTIONS pattern 25 is about, and this is the command that has no blind spot.
///
/// ⚠️ IT WORKS ON ANY MODEL, not just weapons: `nz_3p_bones models/citizen/citizen.vmdl hand`
/// answers the body half of the same question.
/// </summary>
/// <summary>
/// `nz_anim [param]` — fire a body gesture on YOUR player, broadcast like the real thing.
/// Default `b_reload`; `nz_anim b_attack` for the knife swing.
///
/// ⛔ A GESTURE NOBODY ELSE IS AROUND TO SEE CANNOT BE CHECKED, which is the whole reason this
/// exists. Reload and swing animations are the definition of a thing that only matters on
/// SOMEBODY ELSE'S screen, and the bug they were added for went unnoticed for months precisely
/// because solo play never shows it. This fires the identical broadcast the weapon does, so a
/// second account is needed to confirm the fix but not to confirm the plumbing.
///
/// ⚠️ IT TAKES THE PARAMETER NAME so a wrong one can be RULED OUT rather than argued about.
/// `holdtype` proved a bad name compiles and animates nothing; `b_deploy` is the third the
/// engine documents if a draw gesture is ever wanted.
/// </summary>
[ConCmd( "nz_anim" )]
public static void AnimCmd( string param = "b_reload" )
{
var np = NZPlayer.Local;
if ( !np.IsValid() )
{
Log.Info( "[nz-3p] no local player" );
return;
}
NZNet.PlayerAnim( np.GameObject.Id, param );
Log.Info( $"[nz-3p] sent '{param}' for {np.GameObject.Name} — every machine should play it" );
}
[ConCmd( "nz_3p_bones" )]
public static void Bones( string path, string filter = "" )
{
if ( string.IsNullOrWhiteSpace( path ) )
{
Log.Info( "[nz-3p] nz_3p_bones <model path> [filter] e.g. nz_3p_bones weapons/m1911/v_m1911.vmdl" );
return;
}
var model = Model.Load( path );
if ( model is null || model.IsError )
{
Log.Warning( $"[nz-3p] '{path}' will not load" );
return;
}
var all = model.Bones?.AllBones?.Select( b => b.Name ).ToList()
?? new System.Collections.Generic.List<string>();
var shown = string.IsNullOrEmpty( filter )
? all
: all.Where( n => n.Contains( filter, StringComparison.OrdinalIgnoreCase ) ).ToList();
// ⛔ WHERE THE MESH IS, WHICH IS THE QUESTION EVERY BONE NAME IS A PROXY FOR. Anchoring is
// only ever trying to put the GEOMETRY in a hand; a bone is just a handle on it, and two
// plausible handles — the viewmodel's own wrist and `tag_weapon1` — have now each been
// tried and each left the pistol somewhere else. `Bounds` cannot be posed, renamed or
// merged into a second skeleton, so it is the one reading that settles it.
var b = model.Bounds;
Log.Info( $"[nz-3p] {Leaf( path )} · {all.Count} bones · mesh centre {b.Center} size {b.Size}"
+ (string.IsNullOrEmpty( filter ) ? "" : $", {shown.Count} matching '{filter}'") );
// ⚠️ WHICH BONES ARE NEAR THE MESH, when nothing was filtered for. The bone the gun is
// actually built around is the one sitting inside its own geometry.
if ( string.IsNullOrEmpty( filter ) )
{
var near = model.Bones.AllBones
.OrderBy( x => x.LocalTransform.Position.Distance( b.Center ) )
.Take( 8 )
.Select( x => $"{x.Name} ({x.LocalTransform.Position.Distance( b.Center ):0.#}u)" );
Log.Info( "[nz-3p] nearest the mesh centre: " + string.Join( " ", near ) );
}
if ( shown.Count == 0 )
{
Log.Info( "[nz-3p] (none)" );
return;
}
// ⚠️ A FEW MATCHES GET THEIR BIND POSE PRINTED, many get listed. Once a filter has
// narrowed it to the bone actually in question, WHERE that bone sits is the next thing
// wanted every single time — and both readings are printed because `Bone.LocalTransform` is
// undocumented as to whether it is parent-relative or already model-space, and guessing
// wrong there would silently misplace every gun.
if ( shown.Count <= 4 )
{
foreach ( var name in shown )
{
var bone = model.Bones.AllBones.First( b => b.Name == name );
// ⚠️ `LocalTransform` IS MODEL SPACE, despite the name, and this is the reading that
// proved it: `ValveBiped_Bip01_R_Hand` reads (-22.69, 2.35, 40.77) here and the live
// renderer measured the identical figure against the object it was parented to.
// Composing the parent chain — which the name invites — gave (80.7, -168.8, -11.9),
// a point fourteen feet from a pistol, and that number is why this line prints the
// distance to the mesh rather than another transform to be misread.
Log.Info( $"[nz-3p] {name}"
+ $" parent={(bone.Parent is null ? "—" : bone.Parent.Name)}"
+ $" at {bone.LocalTransform.Position}"
+ $" → {bone.LocalTransform.Position.Distance( b.Center ):0.#}u from the mesh centre" );
}
return;
}
// ⚠️ IN ROWS, because a 200-bone rig one name per line buries everything after it and the
// console buffer this session reads from holds a fixed number of ENTRIES, not lines.
for ( var i = 0; i < shown.Count; i += 6 )
Log.Info( "[nz-3p] " + string.Join( " ", shown.Skip( i ).Take( 6 ) ) );
}
static string Leaf( string path )
=> string.IsNullOrEmpty( path ) ? "?" : path[(path.LastIndexOf( '/' ) + 1)..];
}