Player/KnifeViewModel.cs

A component that renders a separate first-person knife viewmodel for the player. It builds and manages skinned model renderers for the blade and hands, drives a viewmodel camera, plays and times animation sequences, and exposes console commands to position, inspect, show, hide and list animations.

File Access
using Sandbox;
using SWB.Base;
using SWB.Shared;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// The knife's own viewmodel — the BO1 S.O.G. knife, shown only while swinging.
///
/// ⛔ NOT AN SWB WEAPON AND NOT IN THE INVENTORY. The obvious build is to make the
/// knife a real <see cref="Weapon"/> and swap slots, which is what GMod does — but
/// GMod does it because a SWEP is its ONLY way to change the viewmodel. We have two
/// weapon slots that the player owns, a wall-buy that reads them, Pack-a-Punch that
/// writes them and an inventory that persists them; routing a 0.75s animation
/// through all of that means every one of those systems has to learn about a weapon
/// that is never carried, bought or upgraded. This is a second renderer instead.
///
/// ⚠️ IT BORROWS SWB's CAMERA rather than making one. `Owner.ViewModelCamera` is
/// created once per player and shared by every weapon (`Weapon.CreateViewModel`);
/// a second camera at the same position with the same tag renders the viewmodel
/// layer twice, which reads as z-fighting on the blade rather than as a duplicate.
/// </summary>
public sealed class KnifeViewModel : Component
{
	/// <summary>
	/// Where the compiled knife viewmodel lives.
	///
	/// ⚠️ `knife`, NOT `nz_knife`. The porter strips the `nz_` prefix when it lays
	/// the folder down, so `--name nz_knife` produces `weapons/knife/v_knife.vmdl`.
	/// Exactly one of the 32 ported weapons kept its prefix (the MPL), and assuming
	/// the prefix survives has already cost this project an invisible gun in the
	/// mystery box.
	/// </summary>
	/// ⛔ THE BO2 KNIFE, NOT THE ARC9 SOG ONE. The SOG model is still in the project
	/// at `weapons/knife/v_knife.vmdl` with its nine sequences — point this back at it
	/// and set `Knife.SwingAnim` to "swipe" to revert in one step.
	///
	/// ⚠️ THE TWO MODELS DO NOT SHARE SEQUENCE NAMES. SOG has
	/// idle/idle_long/draw/draw_fast/holster/holster_fast/swipe/stab/sprint_loop;
	/// this has draw/melee/stick. Swapping the path WITHOUT swapping `SwingAnim`
	/// leaves the knife silently animation-less — `Show` looks the name up and finds
	/// nothing rather than erroring.
	public const string ModelPath = "weapons/knife_bo2/v_knife_bo2.vmdl";

	/// <summary>
	/// Placement offset, live-tunable — right / forward / up, then pitch/yaw/roll.
	///
	/// ⚠️ STATIC AND CONSOLE-DRIVEN for the same reason `nz_vm_pos` is: this is a
	/// hunt for six numbers, and a property on a prefab costs a restart per attempt.
	/// Bake them into the defaults once they are right.
	/// </summary>
	public static Vector3 Offset { get; set; } = new( 0f, 0f, 0f );
	public static Angles Rot { get; set; } = new( 0f, 0f, 0f );

	/// <summary>
	/// Field of view for the knife specifically.
	///
	/// ⚠️ The Lua says `ViewModelFOVBase = 75`, and ARC9's viewmodel FOV is the same
	/// quantity SWB's is — so this is a copied value, not a guessed one.
	/// </summary>
	public static float Fov { get; set; } = 75f;

	/// <summary>
	/// The arms, bonemerged onto the knife.
	///
	/// ⛔ THE KNIFE MESH IS BLADE-ONLY. ARC9's `c_` model carries 85 bones — the arm
	/// skeleton is all there and the swipe animates it — but the only MESH in the QC
	/// is `sog_knife`, because ARC9 bonemerges its own arms at runtime. Ported
	/// as-is, the result is a knife swinging through the air by itself. SWB solves
	/// this with `ViewModelHands` and every gun in the project already points at
	/// this same file.
	/// </summary>
	public const string HandsPath = "weapons/hands/v_hands.vmdl";

	SkinnedModelRenderer _renderer;
	SkinnedModelRenderer _hands;
	GameObject _go;
	bool _failed;

	/// <summary>Is the knife on screen right now?</summary>
	public bool Visible => _renderer.IsValid() && _renderer.Enabled;

	/// <summary>The sequences the compiled model actually carries, for the log.</summary>
	public string[] Sequences
	{
		get
		{
			try { return _renderer?.Sequence?.SequenceNames?.ToArray() ?? Array.Empty<string>(); }
			catch ( Exception ) { return Array.Empty<string>(); }
		}
	}

	IPlayerBase Owner => Components.Get<NZPlayer>() as IPlayerBase;

	/// <summary>
	/// Build the renderer on first use.
	///
	/// ⚠️ RETURNS FALSE RATHER THAN THROWING WHEN THE MODEL IS MISSING, and says so
	/// exactly once. The knife's damage, sound and scoring do not depend on having a
	/// model, so a missing asset must degrade to the invisible swing we already had
	/// — not take the V key down with it while the asset is still being ported.
	/// </summary>
	bool Ensure()
	{
		if ( _renderer.IsValid() ) return true;
		if ( _failed ) return false;

		// ⛔ NEVER ON SOMEBODY ELSE'S BODY. A viewmodel is a FIRST-PERSON object: SWB, the knife
		// and the grenade all parent theirs to the OWNER'S BODY rather than to a camera, so yours
		// sits where your camera is and looks right — and one built on a remote player's body sits
		// at THEIR feet, out in the world, drawn by your viewmodel camera. Measured, one session,
		// the two machines at the same instant:
		//
		//   HOST   (nikolai)  'Viewmodel - knife' under 'Player (Milhouse)'  hands=nikolai_arms
		//   CLIENT (dempsey)  'Viewmodel - knife' under 'Player (Cifosi)'    hands=dempsey_arms
		//
		// Each machine had built one on the OTHER player, wearing its OWN character's arms. The
		// user's description was exact: *"the other arms are really the other player's arms, they
		// share the same model and are in the same place."*
		//
		// ⚠️ IT HAPPENS BECAUSE A SWING IS SEEN BY EVERYONE. `Knife.ViewModel` is
		// `GetOrCreate<KnifeViewModel>()`, so any machine that runs the swing on a remote body
		// builds a viewmodel there too. The guard belongs HERE, at the one place the object is
		// created, rather than at each caller.
		if ( PlayerPresence.Theirs( GameObject ) ) return false;

		var model = Model.Load( ModelPath );

		// ⚠️ `Model.Load` returns the ERROR MODEL, never null, when the path does not
		// resolve — checking for null here would pass and put a checkerboard box on
		// screen. `IsError` is the real test.
		if ( model is null || model.IsError )
		{
			_failed = true;
			Log.Warning( $"[nz] knife viewmodel '{ModelPath}' not found — "
				+ "swinging without one. Port the model and this lights up on its own." );
			return false;
		}

		var owner = Owner;
		if ( owner is null ) return false;

		_go = new GameObject( true, "Viewmodel - knife" );
		_go.SetParent( GameObject, false );
		_go.Tags.Add( TagsHelper.ViewModel );
		_go.NetworkMode = NetworkMode.Never;

		_renderer = _go.Components.Create<SkinnedModelRenderer>();

		// ⛔ ANIMGRAPH OFF FOR A PORTED MODEL — the same rule the guns needed. With
		// the graph on, `Sequence.Name` is ignored and the knife never animates,
		// silently, because neither route reports an unknown name.
		if ( model.AnimGraph is null ) _renderer.UseAnimGraph = false;

		_renderer.Model = model;
		_renderer.Enabled = false;

		// ⚠️ The arms ride the KNIFE's skeleton via BoneMergeTarget — they have no
		// animation of their own, so if the merge silently matches nothing they sit
		// at bind pose, which for these arms is spread wide and below the camera.
		// That reads as "the hands did not load" rather than as a failed merge.
		// ⚠️ THE CHARACTER'S HANDS IF THEY HAVE ONE. The knife and the grenade build their own
		// viewmodels rather than going through SWB's, so each needs the override explicitly —
		// otherwise picking Takeo changes your gun hands and not the ones holding a grenade.
		var handsModel =
			PlayerCharacters.HandsFor( Owner as NZPlayer )
			?? Model.Load( HandsPath );

		if ( handsModel is not null && !handsModel.IsError )
		{
			_hands = _go.Components.Create<SkinnedModelRenderer>();
			_hands.Model = handsModel;
			_hands.BoneMergeTarget = _renderer;
			_hands.Enabled = false;
		}
		else
		{
			Log.Warning( $"[nz] knife hands '{HandsPath}' not found — blade will "
				+ "swing on its own" );
		}

		Log.Info( $"[nz] knife viewmodel ready — animations={model.AnimationCount}"
			+ $"  hands={(_hands is null ? "NO" : "yes")}  (sequences: nz_knife_anims)" );

		return true;
	}

	/// <summary>
	/// Show the knife and start a clip.
	///
	/// ⛔ ENABLED FIRST, THEN THE CLIP — and it must be this order. I originally set
	/// the clip first, reasoning that the first frame drawn should be frame 0 of the
	/// swipe rather than the bind pose. But `Sequence.Time` has no backing scene
	/// object while the renderer is disabled and its SETTER throws
	/// `NullReferenceException` — so every swing after the first threw, because the
	/// second swing takes the "same clip, rewind it" branch. The bind-pose flash I
	/// was avoiding was hypothetical; the crash was real and killed the whole swing.
	///
	/// ⚠️ Nothing is actually drawn until the end of the frame, so setting the clip
	/// immediately after enabling still presents frame 0 — the flash never existed.
	/// </summary>
	public void Show( string sequence, float fitSeconds = 0f, bool loop = false )
	{
		if ( !Ensure() ) return;

		_renderer.Enabled = true;
		if ( _hands.IsValid() ) _hands.Enabled = true;

		Play( sequence, loop );

		// ⛔ THE CLIP IS STRETCHED TO THE GAMEPLAY WINDOW, NOT THE OTHER WAY ROUND.
		// `swipe` measures 1.25s on the live renderer, while the swing window is
		// ARC9's tuned 0.25 + 0.5 = 0.75s. Playing it at natural speed would leave
		// the blade still travelling for a quarter second after the knife has already
		// hit and the player can act again, which reads as input lag. The TIMING is
		// the designed value and the animation serves it.
		//
		// ⚠️ AFTER `Play`, never before — the duration belongs to whichever clip is
		// currently selected, so reading it first measures the OUTGOING one.
		if ( fitSeconds > 0f )
		{
			var d = Duration;
			if ( d > 0f ) _renderer.PlaybackRate = d / fitSeconds;
		}

		_renderer.Enabled = true;
	}

	/// <summary>Take it off screen.</summary>
	public void Hide()
	{
		if ( _renderer.IsValid() ) _renderer.Enabled = false;
		if ( _hands.IsValid() ) _hands.Enabled = false;
	}

	/// <summary>
	/// Play a clip by name, validating against the model first.
	///
	/// ⛔ AN UNKNOWN NAME DROPS THE RENDERER TO THE BIND POSE — it does not error and
	/// does not hold the current pose. `Weapon.PlaySequence` carries the same guard
	/// and the same scar: a fallback asking for a name the model lacked left a gun
	/// permanently sideways.
	/// </summary>
	public void Play( string name, bool loop = false )
	{
		if ( !_renderer.IsValid() || string.IsNullOrEmpty( name ) ) return;

		try
		{
			var names = _renderer.Sequence.SequenceNames;
			if ( names is not null && !names.Contains( name ) )
			{
				Log.Warning( $"[nz] knife has no sequence '{name}' — model has: "
					+ string.Join( ", ", names ) );
				return;
			}
		}
		catch ( Exception )
		{
			// ⚠️ `SequenceNames` THROWS before the renderer has a scene object, which
			// is the case on the frame it is created. Validation is a nicety and must
			// never be able to stop the thing it checks.
		}

		// ⚠️ Rewind when it is already current — assigning the name it already holds
		// does nothing, so a second swing would sit frozen on the last frame.
		//
		// ⛔ THE REWIND THROWS ON A DISABLED RENDERER. `Sequence.Time`'s setter
		// dereferences a scene object that does not exist until the renderer is
		// enabled, and an NRE here takes the entire swing with it — no knife, no
		// strike, no gun restored. Callers enable first; this guard is for the ones
		// that forget, since a viewmodel that fails to rewind is a cosmetic bug and
		// one that throws is a gameplay bug.
		try
		{
			if ( _renderer.Sequence.Name == name )
				_renderer.Sequence.Time = 0f;
			else
				_renderer.Sequence.Name = name;

			// ⚠️ AFTER the name is set, not before — the accessor describes whichever
			// clip is currently selected, so setting Looping first configures the
			// OUTGOING animation.
			_renderer.Sequence.Looping = loop;
		}
		catch ( Exception )
		{
			// Not ready this frame. The next Show sets it again.
		}

		_renderer.PlaybackRate = 1f;
	}

	/// <summary>
	/// How far through the current clip we are, 0-1. Used to time the trace to the
	/// blade rather than to a stopwatch.
	/// </summary>
	public float Progress
	{
		get
		{
			if ( !_renderer.IsValid() ) return 1f;
			try
			{
				var d = _renderer.Sequence.Duration;
				return d > 0f ? MathX.Clamp( _renderer.Sequence.Time / d, 0f, 1f ) : 1f;
			}
			catch ( Exception ) { return 1f; }
		}
	}

	/// <summary>Duration of the clip currently loaded, or 0 if unknown.</summary>
	public float Duration
	{
		get
		{
			if ( !_renderer.IsValid() ) return 0f;
			try { return _renderer.Sequence.Duration; }
			catch ( Exception ) { return 0f; }
		}
	}

	/// <summary>
	/// The camera that draws the viewmodel layer, created if no weapon has made one.
	///
	/// ⛔ THE KNIFE CANNOT DEPEND ON HOLDING A GUN. `Weapon.CreateViewModel` builds
	/// this camera lazily and only the first weapon to deploy does it — so a player
	/// with an empty inventory, or one who is DOWNED (the knife is explicitly the one
	/// thing a crawling player still has), had nothing rendering the `ViewModel` tag
	/// at all. The knife was positioned perfectly and simply not drawn by anything.
	///
	/// ⚠️ ASSIGNED BACK TO `Owner.ViewModelCamera`, so a weapon deploying later
	/// reuses this one instead of making a second. Two cameras at the same position
	/// with the same tag render the layer twice.
	/// </summary>
	CameraComponent EnsureCamera( IPlayerBase owner )
	{
		if ( owner.ViewModelCamera.IsValid() ) return owner.ViewModelCamera;

		var go = new GameObject( true, "ViewModelCamera" );
		go.SetParent( owner.GameObject, false );

		var cam = go.Components.Create<CameraComponent>();

		// ⚠️ These five are copied from `Weapon.CreateViewModel` deliberately — a
		// viewmodel camera that clears colour paints over the world, and one at the
		// wrong priority draws under it.
		cam.ClearFlags = ClearFlags.Depth | ClearFlags.Stencil;
		cam.ZNear = 1;
		cam.Priority = 2;
		cam.TargetEye = StereoTargetEye.RightEye;
		cam.RenderTags.Add( new TagSet() { TagsHelper.ViewModel, TagsHelper.Light } );

		// ⛔ WITHOUT THIS THE MAIN CAMERA DRAWS THE KNIFE TOO — at world scale, one
		// unit from the eye, filling the screen.
		if ( owner.Camera.IsValid() )
			owner.Camera.RenderExcludeTags.Add( TagsHelper.ViewModel );

		owner.ViewModelCamera = cam;
		Log.Info( "[nz] knife created the viewmodel camera (no weapon had made one)" );

		return cam;
	}

	protected override void OnUpdate()
	{
		if ( !_renderer.IsValid() || !_renderer.Enabled ) return;

		var owner = Owner;
		if ( owner is null ) return;

		// Third person has no viewmodel to draw.
		if ( !owner.IsFirstPerson ) { Hide(); return; }

		var cam = EnsureCamera( owner );
		if ( !cam.IsValid() ) return;

		// ⛔ THE VIEWMODEL CAMERA IS DRIVEN TO THE SCENE CAMERA HERE, because nothing
		// else will when the player is holding nothing. For the guns, each weapon's
		// ViewModelHandler does this in its own OnUpdate — so an empty-handed player
		// has a viewmodel camera parked whereever it was left, and the knife renders
		// correctly into a view of somewhere else.
		cam.WorldPosition = Scene.Camera.WorldPosition;
		cam.WorldRotation = Scene.Camera.WorldRotation;
		cam.FieldOfView = Screen.CreateVerticalFieldOfView( Fov );

		// ⛔ VISIBLE, NO SHADOW. `ShadowsOnly` is the INVISIBLE state — the handler
		// uses it to hide a holstered gun — so `Off` is what draws the knife.
		_renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
		if ( _hands.IsValid() ) _hands.RenderType = ModelRenderer.ShadowRenderType.Off;

		// ⛔ NEVER WRITE `WorldPosition` HERE — THAT IS THE PLAYER. ViewModelHandler
		// does exactly that and is right to: it is a component ON the viewmodel
		// object. This one lives on the PLAYER, so the same two lines teleport the
		// player to their own eye every frame, which compounds — eye is derived from
		// the body, so the body climbs by eye height per frame and the player is in
		// orbit within a second. Copying a line from a component with a different
		// owner is how a movement bug gets written into a rendering file.
		//
		// ⚠️ THE CHILD, `_go`, IS THE ONLY THING THIS MAY MOVE.

		// ⛔ THE 90° CORRECTION APPLIES HERE TOO. Every ARC9 BO1 viewmodel is
		// authored a quarter turn from the axis SWB expects, and the knife is one of
		// them — without it the blade sits off screen entirely, which reads as "the
		// model failed to load" rather than as a rotation.
		//
		// ⚠️ COMPOSED as a rotation, never summed into a euler. Adding to the angles
		// re-maps what the other offsets mean; that mistake is written up at length
		// in ViewModelHandler.
		var rot = Scene.Camera.WorldRotation
			* Rotation.From( Rot.pitch, Rot.yaw, Rot.roll )
			* Rotation.From( ViewModelHandler.VMPitch, ViewModelHandler.VMYaw,
				ViewModelHandler.VMRoll );

		_go.WorldRotation = rot;

		// Position AFTER rotation — the offset is in the weapon's own frame.
		_go.WorldPosition = Scene.Camera.WorldPosition
			+ Offset.x * rot.Right
			+ Offset.y * rot.Forward
			+ Offset.z * rot.Up;
	}

	// ── commands ─────────────────────────────────────────────────────────────

	static KnifeViewModel Of()
	{
		var player = NZPlayer.Local;
		return player.IsValid() ? player.Components.GetOrCreate<KnifeViewModel>() : null;
	}

	/// <summary>Nudge the placement: `nz_knife_vm &lt;x&gt; &lt;y&gt; &lt;z&gt; [pitch] [yaw] [roll]`.</summary>
	[ConCmd( "nz_knife_vm" )]
	public static void SetPlacement( float x = 0f, float y = 0f, float z = 0f,
		float pitch = 0f, float yaw = 0f, float roll = 0f )
	{
		Offset = new Vector3( x, y, z );
		Rot = new Angles( pitch, yaw, roll );
		Log.Info( $"[nz] knife vm: pos {x:0.##} {y:0.##} {z:0.##}"
			+ $"  ang {pitch:0.##} {yaw:0.##} {roll:0.##}   fov {Fov:0.#}" );
	}

	/// <summary>
	/// NUDGE all six: `nz_knife_vm_add &lt;x&gt; &lt;y&gt; &lt;z&gt; [p] [y] [r]`.
	/// The one you want while converging — typing absolutes means doing the sum in
	/// your head every attempt.
	/// </summary>
	[ConCmd( "nz_knife_vm_add" )]
	public static void AddPlacement( float x = 0f, float y = 0f, float z = 0f,
		float pitch = 0f, float yaw = 0f, float roll = 0f )
	{
		// ⚠️ Component-wise rather than `Rot += new Angles(...)`. Angles is a struct
		// whose operators are not worth a guess in a file that cannot be compiled
		// right now — this reads the same and cannot fail to resolve.
		SetPlacement(
			Offset.x + x, Offset.y + y, Offset.z + z,
			Rot.pitch + pitch, Rot.yaw + yaw, Rot.roll + roll );
	}

	/// <summary>What the compiled knife can actually play: `nz_knife_anims`.</summary>
	[ConCmd( "nz_knife_anims" )]
	public static void ListAnims()
	{
		var vm = Of();
		if ( vm is null ) { Log.Warning( "[nz] no player" ); return; }

		// ⚠️ Force the build — the list is empty until the renderer exists, and
		// "no sequences" would otherwise be indistinguishable from "never shown".
		vm.Ensure();

		// ⛔ ENABLED BEFORE THE LIST IS READ. `SequenceNames` throws while the
		// renderer has no scene object, and the getter swallows that into an empty
		// array — so this reported "no sequences (model missing)" on a model with
		// nine, and the message blamed the asset for a state problem. Same root as
		// the `Sequence.Time` crash: the sequence table does not exist until the
		// renderer does.
		bool wasEnabled = vm._renderer.Enabled;
		string current = null;

		vm._renderer.Enabled = true;

		var names = vm.Sequences;
		if ( names.Length == 0 )
		{
			vm._renderer.Enabled = wasEnabled;
			Log.Info( "[nz] knife: no sequences — the model really does carry none" );
			return;
		}

		Log.Info( $"[nz] knife sequences ({names.Length}):" );

		// ⚠️ MEASURED, NOT READ OFF THE QC. The QC declares 30fps and a frame count,
		// but the live figure is what the resolver actually plays — `swipe` counts as
		// 31 frames at 30fps (1.0s) and reports 1.25s. Only one of those is the truth
		// the timing has to fit, and it is this one.
		//
		// ⛔ The current clip is saved and put back — measuring must not disturb a
		// swing in progress.
		try
		{
			current = vm._renderer.Sequence.Name;

			foreach ( var n in names )
			{
				vm._renderer.Sequence.Name = n;
				Log.Info( $"[nz]   {n,-14} {vm._renderer.Sequence.Duration:0.00}s" );
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"[nz] could not measure: {e.Message}" );
		}
		finally
		{
			try { if ( current is not null ) vm._renderer.Sequence.Name = current; }
			catch ( Exception ) { }
			vm._renderer.Enabled = wasEnabled;
		}
	}

	/// <summary>
	/// Hold a clip on screen to look at it: `nz_knife_show [sequence]`.
	///
	/// ⚠️ Exists because a 0.75s swing cannot be judged while it is happening — the
	/// placement hunt needs the blade to sit still.
	///
	/// ⛔ LOOPS, WHICH IS THE ENTIRE POINT. Played once, a clip finishes in about a
	/// second and FREEZES ON ITS LAST FRAME — and the last frame of `swipe` is the
	/// blade already withdrawn. I dialled placement against that frozen end pose once
	/// and was measuring a position the knife only ever holds when the swing is over.
	/// Judge placement against a LOOPING `idle`; that is the pose the model is
	/// authored around.
	/// </summary>
	[ConCmd( "nz_knife_show" )]
	public static void ShowCmd( string sequence = "idle" )
	{
		var vm = Of();
		if ( vm is null ) { Log.Warning( "[nz] no player" ); return; }

		vm.Show( sequence, loop: true );
		Log.Info( $"[nz] knife showing '{sequence}' ({vm.Duration:0.##}s, looping) — "
			+ "nz_knife_hide to put it away" );
	}

	/// <summary>
	/// Where the blade actually IS: `nz_knife_where`.
	///
	/// ⛔ MEASURED IN THE CAMERA'S OWN FRAME — right / forward / up — not in world
	/// axes. "The knife is at 4521,3579,1185" answers nothing; "the mesh is 41 units
	/// forward and 30 up from the eye" is the correction, already in the units
	/// `nz_knife_vm` takes. The mystery box needed exactly this and for the same
	/// reason: a world-Z reading was meaningless for a box mounted on a wall.
	///
	/// ⚠️ REPORTS THE MESH CENTRE, NOT THE OBJECT ORIGIN. A ported `c_` viewmodel is
	/// not authored around its origin — the ASP and Uzi meshes sit ~50 units above
	/// theirs — so an origin sitting exactly on the camera still draws the blade
	/// across the room. The two numbers are printed side by side because their
	/// DIFFERENCE is the whole diagnosis.
	/// </summary>
	[ConCmd( "nz_knife_where" )]
	public static void Where()
	{
		var vm = Of();
		if ( vm is null ) { Log.Warning( "[nz] no player" ); return; }
		if ( !vm.Ensure() ) return;

		var cam = Game.ActiveScene?.Camera;
		if ( !cam.IsValid() ) { Log.Warning( "[nz] no scene camera" ); return; }

		var r = vm._renderer;
		var b = r.Bounds;
		var rot = cam.WorldRotation;

		Vector3 Local( Vector3 world )
		{
			var d = world - cam.WorldPosition;
			return new Vector3( d.Dot( rot.Right ), d.Dot( rot.Forward ), d.Dot( rot.Up ) );
		}

		var origin = Local( vm._go.WorldPosition );
		var centre = Local( b.Center );

		Log.Info( $"[nz] knife — enabled={r.Enabled} seq='{r.Sequence.Name}' "
			+ $"rate={r.PlaybackRate:0.##}" );
		Log.Info( $"[nz]   origin  right {origin.x:0.#}  fwd {origin.y:0.#}  up {origin.z:0.#}" );
		Log.Info( $"[nz]   MESH    right {centre.x:0.#}  fwd {centre.y:0.#}  up {centre.z:0.#}"
			+ $"   (size {b.Size.Length:0.#}u)" );
		Log.Info( $"[nz]   offset now {Offset}  ang {Rot}  —  to centre the mesh on the "
			+ $"eye: nz_knife_vm {Offset.x - centre.x:0.#} {Offset.y - centre.y:0.#} "
			+ $"{Offset.z - centre.z:0.#}" );

		// ⛔ THE DECIDING TEST FOR THE ARMS. A BoneMergeTarget that matches NOTHING
		// does not error — the arms simply stay at bind pose, which for this set is
		// spread wide and below the camera, i.e. invisible rather than visibly wrong.
		// "hands=yes" only proves the MODEL loaded. If the merge took, the hands'
		// bounds sit on the knife's; if it silently no-oped, they are metres apart.
		// `nz_wep_hands` learned this the hard way for the guns.
		if ( vm._hands.IsValid() )
		{
			var h = vm._hands.Bounds;
			var gap = h.Center.Distance( b.Center );

			Log.Info( $"[nz]   hands   enabled={vm._hands.Enabled} "
				+ $"merged={(vm._hands.BoneMergeTarget is null ? "NO TARGET" : "target set")} "
				+ $"gap-to-blade {gap:0.#}u "
				+ $"— {(gap < 40f ? "MERGED" : "BIND POSE, merge matched nothing")}" );
		}
		else
		{
			Log.Info( "[nz]   hands   NONE — blade swings on its own" );
		}
	}

	/// <summary>Put it away again: `nz_knife_hide`.</summary>
	[ConCmd( "nz_knife_hide" )]
	public static void HideCmd()
	{
		Of()?.Hide();
		Log.Info( "[nz] knife hidden" );
	}
}