Weapons/SckPartsRig.cs

Editor/runtime component that reads an SCK parts manifest and draws individual weapon parts as separate GameObjects for tuning and live sights. It loads a JSON manifest, creates per-part models, computes transforms matching the SCK bake, provides console commands to inspect and tweak whole-weapon, sight and hands offsets, and applies per-frame placement and bone overrides.

File AccessExternal Download
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using SWB.Base;
using SWB.Shared;

namespace NZombies;

/// <summary>
/// One placement out of an SCK weapon's element table.
/// </summary>
///
/// ⚠️ THE FIELD NAMES ARE THE JSON'S, lower case, matched case-insensitively on load. They are the
/// same names `Tools/sck_parse.py` writes and `Tools/sck_bake.py` reads, so a number tuned here can
/// be pasted straight back into the bake without translation.
public sealed class SckPart
{
	public string Name { get; set; } = "";
	public string Model { get; set; } = "";
	public string Rel { get; set; } = "";
	public float[] Pos { get; set; } = new float[3];
	public float[] Ang { get; set; } = new float[3];
	public float[] Size { get; set; } = new float[] { 1f, 1f, 1f };
	public bool Visible { get; set; } = true;

	/// <summary>
	/// A correction to the MESH's own axes, in degrees, as Rz · Ry · Rx.
	/// </summary>
	///
	/// ⚠️ NOT A PLACEMENT. The chain matches GMod to 0.008 units and the pieces still sat wrong,
	/// because the fault is in how each mesh came out of the .mdl → SMD → Blender → DMX pipeline.
	/// Correcting the placement to compensate would bury a mesh bug inside numbers that are verified.
	public float[] Fix { get; set; }

	/// <summary>The mesh correction as a rotation, or identity when there is none.</summary>
	///
	/// ⚠️ KEPT, BUT NO LONGER APPLIED HERE. The per-placement models carry their fix baked into
	/// the geometry, which is what makes the rig exact; a manifest that still lists `fix` alongside
	/// those models would be describing the same correction twice. This stays so an older manifest
	/// still parses, and so the value is readable when tuning.
	///
	/// ⚠️ `Rotation.From` is (pitch, yaw, roll) = Rz(yaw)·Ry(pitch)·Rx(roll), and the fix is written
	/// as Rz(z)·Ry(y)·Rx(x) — so the arguments are deliberately out of order here.
	public Rotation FixRotation
		=> Fix is { Length: 3 } f ? Rotation.From( f[1], f[2], f[0] ) : Rotation.Identity;

	/// <summary>
	/// Drawn only while aiming.
	/// </summary>
	///
	/// ⚠️ A SEPARATE FIELD FROM `visible`, WHICH STILL MEANS "THIS PIECE IS DRAWN". The weapon shows
	/// ten pieces from the hip and sixteen down the sights: `:Think()` fades six of them between
	/// alpha 0 and 255 and `scifi_render.lua` skips anything under 1. Folding that into `visible`
	/// would leave the editor's Show control and the pose switch fighting over one field — and a
	/// tuned Print would then emit `visible: true` and start drawing the sight from the hip.
	[JsonPropertyName( "ads_only" )]
	public bool AdsOnly { get; set; }

	/// <summary>The element's own colour, 0-255 RGBA, or null for white.</summary>
	///
	/// ⚠️ THE ELEMENT TABLE HAS ALWAYS CARRIED THIS AND THE PORT HAS ALWAYS DROPPED IT. It matters
	/// first for the sight, which GMod draws as pale blue `debugwhite` rings: with the colour gone
	/// they arrive grey-plastic against a grey gun, which reads as "my placement is wrong" rather
	/// than "the material is not ported yet" — and telling those two apart is the whole job here.
	[JsonPropertyName( "color" )]
	public int[] ColorBytes { get; set; }

	/// <summary>`ColorBytes` as a tint, white when the manifest does not say.</summary>
	public Color Tint => ColorBytes is { Length: >= 3 } c
		? new Color( c[0] / 255f, c[1] / 255f, c[2] / 255f, c.Length > 3 ? c[3] / 255f : 1f )
		: Color.White;

	public Vector3 Position => new( Pos[0], Pos[1], Pos[2] );
	public Vector3 Scale => new( Size[0], Size[1], Size[2] );
}

public sealed class SckManifest
{
	public string Bone { get; set; } = "ValveBiped.Bip01_R_Hand";

	/// <summary>
	/// One multiplier over every part's size.
	/// </summary>
	///
	/// ⚠️ A SEPARATE NUMBER RATHER THAN SEVENTEEN EDITED ONES. Scaling the gun by hand means
	/// retyping every size, and then being unable to tell later which of them were the weapon's own
	/// values and which were a correction. The sizes came out of the lua, so keeping them intact is
	/// the difference between a port and a rebuild.
	///
	/// ⛔ IT SCALES SIZE ONLY, NOT POSITION, because that is what the element `size` field does.
	/// Scaling the offsets too would push pieces apart as they grew, which is a different edit and
	/// not one the element table can express.
	public float Scale { get; set; } = 1f;

	public List<SckPart> Parts { get; set; } = new();
}

/// <summary>
/// Draws an SCK weapon as SEPARATE models, one per element, so each can be moved on its own.
/// </summary>
///
/// ⛔ THIS EXISTS BECAUSE THE BAKED GUN CANNOT BE EDITED. `sck_bake.py` welds every placement into
/// one mesh, which is right for shipping and useless for tuning: a single mesh is rigid by
/// construction, so nothing inside it can be nudged. Here each element is its own GameObject, which
/// is also how SCK itself draws them — a ClientsideModel per element, positioned every frame.
///
/// ⚠️ SO THIS IS THE AUTHORITY AND THE BAKE IS THE OPTIMISATION. Tune here, press Print, paste the
/// result into the overrides file, re-bake. The two must agree, which is why both sides read the
/// same manifest and compute the same transform.
///
/// ⛔ THE TRANSFORM IS NOT `Rotation.From( pitch, yaw, roll )`, AND THAT IS THE WHOLE TRAP. SCK
/// never converts an element angle to a matrix; `scifi_render.lua` walks the axes by hand, and
/// `Angle:Right()` is −Y. See `Local` below — it has to match `place_matrix` in `sck_bake.py`
/// exactly or the editor lies about what the bake will produce.
public sealed class SckPartsRig : Component
{
	/// <summary>The manifest to draw, relative to the mounted asset root.</summary>
	[Property] public string ManifestPath { get; set; } = "weapons/prisma/prisma_parts.json";

	/// <summary>Hide the weapon's own baked mesh while this rig is drawing the loose parts.</summary>
	///
	/// ⚠️ WITHOUT THIS YOU SEE THE GUN TWICE — the baked one and the loose one, overlapping and
	/// almost aligned, which reads as "the parts are slightly wrong" rather than "there are two guns".
	[Property] public bool HideBaked { get; set; } = true;

	/// <summary>Name of the part to tint, so you can tell which one the sliders are moving.</summary>
	public static string Highlight { get; set; } = "";

	/// <summary>
	/// Is the rig drawing at all.
	/// </summary>
	///
	/// ⛔ OFF BY DEFAULT, AND OFF IS THE SHIPPING STATE. `v_prisma.vmdl` already contains the whole
	/// gun as ONE baked mesh; the loose placements exist only so a piece can be tuned. While the rig
	/// draws it HIDES that baked mesh — so a rig left running after the panel was closed meant the
	/// single model could not be seen at all, and looked like the bake had failed.
	///
	/// ⛔ AND TURNING IT OFF RELEASES THE AIM HOLD, WHICH IS A BUG FIX WRITTEN AS A SETTER. The
	/// part editor closed by clearing `Visible` and `Active` and nothing else, so a panel closed
	/// while ADS was on left `AimHold` true — and `Weapon.IsAiming` reads that flag FIRST, ahead
	/// of every other clause. The weapon stayed at the sights with no button held, no panel on
	/// screen and nothing saying why. Reported as "my gun is stuck on ads due to the parts ui".
	///
	/// ⚠️ IN THE SETTER RATHER THAN IN EACH PANEL'S CLOSE. There are two panels that hold the
	/// pose and three ways out of them — the X, `nz_parts 0` and `nz_sight 0` — and the sight
	/// editor already cleared it by hand while the part editor did not. One place cannot drift, and
	/// a third panel gets the fix for free.
	///
	/// ⚠️ ON THE TRANSITION ONLY, so `nz_ads_hold` still works on its own: `EnsureRig` never
	/// sets `Active`, so holding the pose with no rig never touches this and never gets cleared
	/// by it.
	public static new bool Active
	{
		get => _active;
		set
		{
			if ( _active == value ) return;
			_active = value;

			if ( value ) return;

			AimHold = false;
			Ads = false;
		}
	}

	/// <summary>
	/// Backing for <see cref="Active"/>.
	/// </summary>
	///
	/// ⚠️ A PLAIN FIELD, DEFAULTING TO FALSE, exactly as the auto-property did — a static's
	/// value survives a hotload either way (INSTRUCTIONS.md §1) and nothing here relies on an
	/// initialiser re-running.
	static bool _active;

	/// <summary>
	/// Draw the weapon as it is down the sights — sixteen placements rather than ten.
	/// </summary>
	///
	/// ⛔ NOT A SECOND MANIFEST. A `prisma_parts_ads.json` would hold its own copy of the ten hip
	/// placements, and the next round of hip tuning would quietly leave that copy behind — the
	/// weapon would then change shape the moment you aimed. The ten are IDENTICAL in both poses;
	/// only the six sight pieces come and go, so one file describes both and cannot drift.
	public static bool Ads { get; set; }

	/// <summary>
	/// Hold the weapon in its aim pose, without keeping a mouse button down.
	/// </summary>
	///
	/// ⛔ IT FORCES THE REAL AIM FLAG RATHER THAN COPYING THE POSE. `Weapon.IsAiming` is the single
	/// gate — the viewmodel offset, the sensitivity, the spread and the scope all derive from it —
	/// so setting that one flag shows exactly what the game shows. Reproducing `AimAnimData` here
	/// would be a second implementation of aiming, and then the thing being tuned would be the
	/// editor rather than the weapon.
	///
	/// ⚠️ AND IT IS NEEDED AT ALL because the aim button is the RIGHT MOUSE BUTTON, which cannot be
	/// held while dragging a slider with the same mouse.
	public static bool AimHold { get; set; }

	/// <summary>
	/// Draw the aiming-only placements on the real weapon, in real gameplay, while it is aimed.
	/// </summary>
	///
	/// ⛔ THIS IS THE SHIPPING PATH, AND IT IS NOT THE EDITOR. Everything else in this class
	/// exists to TUNE: `Active` replaces the baked gun with 26 loose objects so a piece can be
	/// moved. That is the wrong shape for a fight — it hides a model that already works and
	/// costs 26 draw calls to redraw what one was drawing.
	///
	/// ⛔ AND IT IS WHY THERE IS NO SECOND BAKE. The obvious answer to "the sights do not show in
	/// game" is to bake `v_prisma_ads.vmdl` and swap `Model` on aim — but the ADS delta is EIGHT
	/// small pieces, 156 triangles for the two custom ones, against a 135,000-poly weapon. Baking
	/// a second copy of the whole gun to add a front post is the wrong trade twice over: it
	/// doubles the asset, and assigning `Model` rebuilds the renderer and RESETS THE ANIMATION —
	/// on a draw or a reload that is a visible snap every time the player aims.
	///
	/// ⚠️ SO THE BAKED GUN KEEPS DRAWING and only the `ads_only` pieces ride on top of it. The
	/// ten hip placements are already in `v_prisma.vmdl`; drawing them again would be z-fighting
	/// with itself.
	///
	/// ⚠️ AND THE PLACEMENT STAYS LIVE. These read `prisma_parts.json` every load, so a sight
	/// tuned in the editor is the sight the game draws — no re-bake between the two.
	public static bool LiveSights { get; set; } = true;

	/// <summary>
	/// The panel replaces the whole weapon with loose placements, instead of only adding sights.
	/// </summary>
	///
	/// ⛔ OFF BY DEFAULT NOW, AND THAT IS A BUG FIX, NOT A PREFERENCE. Opening the editor used to
	/// hide `v_prisma.vmdl` and rebuild the gun from eighteen separate objects — reported as
	/// *"the gun gets squashed and weird"*, because the loose reconstruction does NOT match the
	/// bake. Everything tuned against it inherits that error: it is why the sights sat correctly
	/// in the placement page and needed a fourteen-unit correction to sit on the real weapon.
	///
	/// ✅ WITH IT OFF, THE SLIDERS EDIT THE SIGHTS ON THE ACTUAL GUN. The baked model keeps
	/// drawing, the two custom pieces are added on top of it, and what is dragged into place is
	/// therefore in the shipped weapon's own space — so the numbers need no correction afterwards.
	///
	/// ⚠️ ON, IT IS THE OLD BEHAVIOUR, which is still the only way to tune a HIP placement: those
	/// ten are inside the bake and cannot be moved without taking it apart. `nz_parts_full 1`.
	public static bool FullRig { get; set; }

	/// <summary>
	/// A correction applied to the live sights only, in the anchor bone's frame.
	/// </summary>
	///
	/// ⛔ IT EXISTS BECAUSE THE TUNING RIG AND THE SHIPPED MODEL ARE NOT PROVEN TO SHARE A SPACE.
	/// Placements are tuned against the LOOSE rig (and against the placement page, which rebuilds
	/// the gun from those same placements), while the live sights are drawn against the BAKED
	/// `v_prisma.vmdl`. Both are anchored to the same bone and in principle they coincide — but
	/// "in principle" is doing real work in that sentence, and the sights came out visibly to one
	/// side in game while sitting correctly on the gun in the page.
	///
	/// ⚠️ LIVE MODE ONLY, WHICH IS THE POINT. Applying it in the tuning rig too would move the
	/// pieces under the very editor used to place them, and the two corrections would chase each
	/// other. Tune placement in the page; correct the space here.
	///
	/// ⚠️ IF THIS EVER SETTLES ON A LARGE, STABLE VALUE, IT IS HIDING A REAL BUG. A few tenths
	/// is a mesh-origin rounding difference; several units is the bake and the manifest genuinely
	/// disagreeing, and the fix for that is in `sck_bake.py`, not here.
	public static Vector3 SightFix { get; set; }

	/// <summary>The same correction's rotation.</summary>
	public static Angles SightFixTurn { get; set; }

	/// <summary>
	/// Which weapon the live sights belong to, by `ClassName`.
	/// </summary>
	///
	/// ⛔ WITHOUT THIS EVERY GUN GROWS A PRISMA SIGHT. The manifest is one weapon's parts list and
	/// the rig binds to whatever viewmodel is in front of it, so an unguarded live path bolts a
	/// glowing aperture onto every CODOL rifle in the pack the moment somebody aims.
	public static string SightWeapon { get; set; } = "nz_prisma";

	/// <summary>
	/// True while the frame is drawing the shipping sights rather than the tuning rig.
	/// </summary>
	///
	/// ⚠️ IT CHANGES WHAT `Shown` MEANS, which is why it is a field and not a parameter: the
	/// panel and the rig both ask `Shown` what is on screen and the two have to agree.
	public static bool LiveOnly { get; private set; }

	/// <summary>
	/// The whole assembly, turned about the ANCHOR BONE.
	/// </summary>
	///
	/// ⛔ THE BONE, NOT THE GUN'S OWN CENTRE, AND THE DIFFERENCE MATTERS TWICE OVER. First because
	/// "the gun is rotated wrong in my hand" means rotate it about the hand — that is the joint it
	/// pivots on when you actually hold it. Second because a centroid pivot has to be MEASURED from
	/// the mesh bounds, and every consumer then has to agree on that measurement; the rotator page
	/// pivots on its centroid and reproducing that number offline has already been a bug twice. The
	/// bone origin needs no measuring and cannot drift.
	public static Angles GunTurn { get; set; } = Angles.Zero;

	/// <summary>The whole assembly, moved in bone axes: +X forward, +Y left, +Z up.</summary>
	///
	/// ⚠️ NO Y FLIP HERE. The element table measures `pos.y` along Forward/Right/Up where Right is
	/// −Y, which is why `Local` negates it — but this is a plain offset in the bone's own frame and
	/// the flip would only be a second trap.
	public static Vector3 GunMove { get; set; } = Vector3.Zero;

	/// <summary>
	/// The hands, moved and turned relative to the weapon.
	/// </summary>
	///
	/// ⚠️ A SEPARATE CONTROL FROM `GunTurn`/`GunMove`, EVEN THOUGH THE RELATIVE RESULT IS THE
	/// SAME. Moving the gun changes where the GUN sits on screen; moving the hands changes where the
	/// HANDS sit. Once the weapon is where you want it, fixing the grip has to move the other one —
	/// otherwise every grip correction knocks the weapon back out of place.
	public static Vector3 HandsMove { get; set; } = Vector3.Zero;

	/// <summary>The hands, turned relative to the weapon.</summary>
	public static Angles HandsTurn { get; set; } = Angles.Zero;

	/// <summary>Where the hands offset is being applied, or why it is not.</summary>
	public static string HandsStatus { get; private set; } = "";

	/// <summary>One multiplier over every part's size. `nz_parts_scale`.</summary>
	public float SizeScale
	{
		get => _manifest?.Scale ?? 1f;
		set { if ( _manifest is not null ) _manifest.Scale = value; }
	}

	public static SckPartsRig Current { get; private set; }

	/// <summary>
	/// Where the rig got to on the last frame, in one line.
	/// </summary>
	///
	/// ⛔ THIS EXISTS BECAUSE THE FIRST VERSION FAILED SILENTLY AND I COULD NOT TELL WHICH WAY.
	/// User: *"i dont see it change anything about the weapon"* — and "no viewmodel", "manifest
	/// unreadable", "bone missing" and "every model failed to load" all looked exactly the same
	/// from outside: nothing. Each of them says so now, on screen and in `nz_parts_status`.
	public static string Status { get; private set; } = "not running";

	/// <summary>How many parts actually found a model and drew, last frame.</summary>
	public static int Drawn { get; private set; }

	/// <summary>Parts whose model would not load — almost always an uncompiled .vmdl.</summary>
	public static int MissingModels { get; private set; }

	public List<SckPart> Parts => _manifest?.Parts ?? new List<SckPart>();

	SckManifest _manifest;

	/// <summary>
	/// The manifest exactly as it is on disk, never edited.
	///
	/// ⚠️ DESERIALISED A SECOND TIME RATHER THAN COPIED. A shallow copy would share the very
	/// float[] the sliders write into, so "what changed" would always answer "nothing" — the
	/// failure mode being an empty Print after an hour of dragging.
	/// </summary>
	public SckManifest Pristine { get; private set; }
	readonly Dictionary<string, GameObject> _objects = new();
	SkinnedModelRenderer _boundTo;

	protected override void OnAwake()
	{
		Current = this;
		Load();
	}

	protected override void OnDestroy()
	{
		if ( Current == this ) Current = null;
		Clear();
	}

	/// <summary>
	/// Read the manifest off disk.
	/// </summary>
	///
	/// ⚠️ RE-READABLE AT ANY TIME, which is what makes `nz_parts_reload` a real revert: the file on
	/// disk is the last saved state, so a session of dragging can always be thrown away.
	public void Load()
	{
		Clear();

		try
		{
			var text = FileSystem.Mounted.ReadAllText( ManifestPath );
			var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
			_manifest = JsonSerializer.Deserialize<SckManifest>( text, opts );
			Pristine = JsonSerializer.Deserialize<SckManifest>( text, opts );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[nz-parts] could not read {ManifestPath}: {e.Message}" );
			_manifest = null;
			return;
		}

		Log.Info( $"[nz-parts] {ManifestPath}: {Parts.Count} part(s), "
			+ $"{Parts.Count( p => p.Visible )} visible, bone '{_manifest.Bone}'" );
	}

	void Clear()
	{
		foreach ( var o in _objects.Values )
			if ( o.IsValid() ) o.Destroy();

		_objects.Clear();
		_boundTo = null;
	}

	/// <summary>
	/// One placement's local transform, matching `place_matrix` in `sck_bake.py`.
	/// </summary>
	///
	/// ⛔ PITCH IS NEGATED AND Y IS FLIPPED. `scifi_render.lua` rotates about `ang:Right()`, which is
	/// −Y, so pitching by p is a right-hand turn about (0,−1,0) — that is `Ry(−pitch)`, where
	/// `Rotation.From` (Source's `AngleMatrix`) uses `Ry(+pitch)`. The offset is measured along
	/// Forward/Right/Up too, and Right is −Y, so `pos.y` changes sign with it.
	///
	/// ⚠️ SCALE IS NOT IN HERE. `GetBoneOrientation` never reads `size`; the base applies it to the
	/// one model through `EnableMatrix( "RenderMultiply" )`, so a child never inherits its parent's
	/// scale. It goes on the object's LocalScale instead, at the end of the chain.
	public static Transform Local( SckPart p )
		=> new( new Vector3( p.Pos[0], -p.Pos[1], p.Pos[2] ),
				Rotation.From( -p.Ang[0], p.Ang[1], p.Ang[2] ) );

	/// <summary>
	/// Pieces held back while the sight is built up one at a time.
	/// </summary>
	///
	/// ⛔ NOT THE MANIFEST'S `visible`, AND THAT IS DELIBERATE. `visible` is what the file on disk
	/// says the weapon draws; this is a tuning session's "not yet". Writing the session into the
	/// manifest field would make Print emit `visible: false` for a piece that the weapon really
	/// does draw — the editor would have edited the weapon by being open.
	public static readonly HashSet<string> Held = new();

	/// <summary>Is this placement drawn in the pose the rig is showing right now.</summary>
	///
	/// ⚠️ ONE RULE, READ BY BOTH THE RIG AND THE PANEL. The list of pieces you can pick from and the
	/// set of pieces on screen have to be the same set, or you end up tuning something invisible.
	public static bool Shown( SckPart p )
		=> LiveOnly
			// ⚠️ THE AIM PIECES AND NOTHING ELSE. `Held` is a tuning session's "not yet" and has
			// no meaning in a fight, so it is deliberately not consulted here.
			? p.Visible && p.AdsOnly
			: p.Visible && (!p.AdsOnly || Ads) && !Held.Contains( p.Name );

	/// <summary>Where a part sits relative to the anchor bone, following `rel` up the chain.</summary>
	public Transform Resolve( SckPart p, int depth = 0 )
	{
		// ⚠️ DEPTH-CAPPED because `rel` comes out of a hand-written lua table and a cycle there
		// would hang the game rather than draw something wrong.
		if ( depth > 16 ) return Local( p );

		if ( string.IsNullOrEmpty( p.Rel ) ) return Local( p );

		var parent = Parts.FirstOrDefault( q => q.Name == p.Rel );
		if ( parent is null ) return Local( p );

		return Resolve( parent, depth + 1 ).ToWorld( Local( p ) );
	}

	/// <summary>
	/// ⚠️ `OnPreRender`, NOT `OnUpdate`. The bone has to be read AFTER the animation has run for
	/// this frame, or every part trails the hand by one frame — which looks like jitter and reads
	/// as a placement bug. It is also the only place a `RenderType` set by `ViewModelHandler.OnUpdate`
	/// can be overridden without the two fighting.
	/// </summary>
	protected override void OnPreRender()
	{
		// ⚠️ FOUND BY COMPONENT, NOT BY PLAYER. A viewmodel only ever exists for the machine that
		// owns it, so there is no "which player" question here and no FirstOrDefault over players.
		var handler = Scene?.GetAllComponents<ViewModelHandler>()
			.FirstOrDefault( h => h.IsValid() && h.ViewModelRenderer.IsValid() );

		var vm = handler?.ViewModelRenderer;

		// ⛔ BEFORE EVERY EARLY-OUT, NOT AFTER THE PARTS LOOP. The hands offset has nothing to do
		// with whether the loose placements are drawing — you adjust the grip against the BAKED gun
		// just as often, which is precisely when the rig is off. Sitting at the end of the loop it
		// was skipped in exactly the case it was most wanted, and reported an empty status that
		// looked like the control doing nothing.
		ApplyHands( handler );

		// ⛔ `ShouldDraw` OUTRANKS EVERY STATE BELOW, AND WITHOUT THIS THE KNIFE STOPPED WORKING.
		// `Knife.ShowGuns( false )` sets the handler's `ShouldDraw`, and `ViewModelHandler.OnUpdate`
		// turns that into `RenderType = ShadowsOnly`. This method runs in `OnPreRender` — AFTER
		// that, deliberately, so the bone is read post-animation — and then writes `vm.RenderType`
		// itself from its own three states, which do not know what `ShouldDraw` is. The handler hid
		// the gun and the rig put it straight back, every frame of the swing.
		//
		// ⚠️ REPORTED AS *"knifing no longer hides the weapon"*, and the knife was blameless: it
		// re-asserts the hide every frame precisely so a weapon deploying mid-swing cannot reappear.
		// It was being out-written one component later in the same frame.
		//
		// ⚠️ THE LOOSE PARTS HAVE TO GO TOO, NOT JUST THE BAKED MODEL. They are separate objects
		// with their own renderers and nothing else switches them off; hiding only the viewmodel
		// would leave a Prisma's eighteen pieces hanging in front of the knife.
		if ( handler.IsValid() && !handler.ShouldDraw )
		{
			foreach ( var o in _objects.Values )
				if ( o.IsValid() ) o.Enabled = false;

			if ( vm.IsValid() ) vm.RenderType = ModelRenderer.ShadowRenderType.ShadowsOnly;

			Status = "hidden — something else owns the screen (knife, grenade, scope)";
			return;
		}

		// ⛔ THREE STATES, NOT TWO, AND THE MIDDLE ONE IS THE USEFUL ONE.
		//
		//   closed            nothing of ours draws; the baked gun is the weapon.
		//   panel, sights     the baked gun KEEPS drawing and the sight pieces are added to it.
		//                     Sliders edit them against the real weapon. The default.
		//   panel, full rig   the baked gun is hidden and all eighteen placements are drawn.
		//                     The only way to move a hip piece, and the thing that looked wrong.
		//
		// ⚠️ IN SIGHT MODE THE PIECES DRAW WHETHER OR NOT THE WEAPON IS AIMED, because the point
		// of having the panel open is to look at them.
		LiveOnly = Active ? !FullRig : LiveWanted( handler );

		if ( !Active && !LiveOnly )
		{
			// ⚠️ HAND THE VIEWMODEL BACK. Anything the rig hid has to be un-hidden here, or turning
			// the rig off leaves the player holding nothing at all.
			foreach ( var o in _objects.Values )
				if ( o.IsValid() ) o.Enabled = false;

			if ( vm.IsValid() ) vm.RenderType = ModelRenderer.ShadowRenderType.Off;

			Status = "off — the weapon's own baked model is drawing";
			return;
		}

		if ( _manifest is null )
		{
			Status = $"manifest not loaded — {ManifestPath} missing or unreadable";
			foreach ( var o in _objects.Values )
				if ( o.IsValid() ) o.Enabled = false;
			return;
		}

		if ( !vm.IsValid() )
		{
			Status = "no viewmodel — hold a weapon in first person";
			foreach ( var o in _objects.Values )
				if ( o.IsValid() ) o.Enabled = false;
			return;
		}

		// A different weapon (or a respawned viewmodel) means the old part objects are orphans.
		if ( _boundTo != vm )
		{
			Clear();
			_boundTo = vm;
		}

		// ⛔ THE BAKED GUN IS ONLY HIDDEN ONCE SOMETHING REPLACED IT. Hiding it unconditionally —
		// which the first version did — means a rig that cannot load a single model leaves the
		// player holding NOTHING, and "no weapon at all" is a worse failure than "two guns".
		// ⛔ ONLY THE FULL RIG HIDES IT. The baked gun IS the weapon in both other states; the
		// sights are an addition to it, not a replacement. Only the mode that redraws all ten hip
		// pieces itself has any business taking the model away.
		vm.RenderType = !LiveOnly && HideBaked && Drawn > 0
			? ModelRenderer.ShadowRenderType.ShadowsOnly
			: ModelRenderer.ShadowRenderType.Off;

		if ( !TryBone( vm, out var bone, out var used ) )
		{
			// ⛔ LOUD ONCE, NOT EVERY FRAME. A missing bone means every part would stack at the
			// world origin, and silence there is how the first bake shipped pointing at nothing.
			Status = $"bone '{_manifest.Bone}' is not on {vm.Model?.ResourceName} "
				+ "(neither spelling) — nothing can be placed";

			if ( !_warnedBone )
			{
				_warnedBone = true;
				Log.Warning( $"[nz-parts] {Status}" );
			}
			return;
		}

		_warnedBone = false;
		_usedBone = used;

		var drew = 0;
		var missing = 0;

		foreach ( var p in Parts )
		{
			if ( !Shown( p ) ) continue;

			var obj = Ensure( p, vm.GameObject );
			if ( !obj.IsValid() ) { missing++; continue; }

			drew++;

			var local = Resolve( p );

			obj.Enabled = true;

			// ✅ A PURELY RIGID TRANSFORM, AND THAT IS WHY THIS IS NOW EXACT. Each placement's model
			// already carries its own size and its mesh fix — baked in by `sck_bake.py --only` — so
			// nothing here has to express `frame · size · fix`, which shears under a non-uniform size
			// and which a position/rotation/scale GameObject cannot represent. What is left is a
			// position and a rotation, which it represents perfectly.
			//
			// ⚠️ SO THE MANIFEST'S `size` IS 1 FOR THESE MODELS. Applying the real size here as well
			// would square it, and `1b` at 0.351 × 0.203 × 0.204 would vanish.
			// ⚠️ THE GUN TRANSFORM SITS BETWEEN THE BONE AND THE PLACEMENT: bone · gun · placement.
			// Outside the placement so it moves the finished weapon, inside the bone so it rides the
			// hand — which is what makes it the right handle for "the gun sits wrong in my hand".
			var placed = new Transform( GunMove, GunTurn.ToRotation() ).ToWorld( local );

			// ⚠️ OUTSIDE THE PLACEMENT AND INSIDE THE BONE, the same slot the whole-weapon
			// transform occupies — so it moves the finished sight rather than re-interpreting the
			// numbers the page produced.
			if ( LiveOnly )
				placed = new Transform( SightFix, SightFixTurn.ToRotation() ).ToWorld( placed );

			obj.WorldPosition = bone.PointToWorld( placed.Position );
			obj.WorldRotation = bone.Rotation * placed.Rotation;
			obj.LocalScale = p.Scale * SizeScale;

			if ( obj.Components.Get<ModelRenderer>() is { } r && r.IsValid() )
				r.Tint = p.Name == Highlight ? new Color( 1f, 0.45f, 0.1f ) : p.Tint;
		}

		Drawn = drew;
		MissingModels = missing;

		Status = missing > 0
			? $"{drew} drawn, {missing} MODEL(S) WOULD NOT LOAD — the .vmdl files under "
				+ "Assets/weapons/prisma/parts/ have no .vmdl_c, so the editor has not compiled "
				+ "them yet. Reload the project."
			: $"{drew} part(s) drawing on {_usedBone} — "
				+ (LiveOnly
					? (Active ? "SIGHTS on the real gun" : "live sights")
					: Ads ? "FULL RIG, aiming" : "FULL RIG, hip");
	}

	/// <summary>
	/// `nz_gun [pitch yaw roll] [x y z]` — turn and move the whole weapon about the anchor bone.
	/// </summary>
	///
	/// ⚠️ Called with no arguments it REPORTS rather than resets, because a command that silently
	/// zeroed a tuning session would be the most expensive keystroke in the tool.
	[ConCmd( "nz_gun" )]
	public static void GunCmd( float pitch = float.NaN, float yaw = 0f, float roll = 0f,
		float x = float.NaN, float y = 0f, float z = 0f )
	{
		if ( !float.IsNaN( pitch ) ) GunTurn = new Angles( pitch, yaw, roll );
		if ( !float.IsNaN( x ) ) GunMove = new Vector3( x, y, z );

		Log.Info( $"[nz-parts] gun turn {GunTurn.pitch:0.##},{GunTurn.yaw:0.##},{GunTurn.roll:0.##}"
			+ $"  move {GunMove.x:0.###},{GunMove.y:0.###},{GunMove.z:0.###}  (about the bone)" );
	}

	/// <summary>
	/// `nz_hands [x y z] [pitch yaw roll]` — move the hands relative to the weapon.
	/// </summary>
	///
	/// ⚠️ Bare, it REPORTS. Same reason as `nz_gun`: a command that silently cleared a tuning
	/// session would be the most expensive thing in the tool to type by accident.
	[ConCmd( "nz_hands" )]
	public static void HandsCmd( float x = float.NaN, float y = 0f, float z = 0f,
		float pitch = 0f, float yaw = 0f, float roll = 0f )
	{
		EnsureRig();

		// ⚠️ THE HELD WEAPON, AND SAVED. This used to write two statics shared by every gun in
		// the pack and forgotten on restart; it now writes this weapon's `HandsOffset` and puts it
		// through `WeaponPlacement`, the same store the four pose slots use.
		var w = Game.ActiveScene?.GetAllComponents<SWB.Base.Weapon>()
			.FirstOrDefault( q => q.IsValid() && q.Active );

		if ( !w.IsValid() ) { Log.Warning( "[nz-parts] hold a weapon first" ); return; }

		if ( !float.IsNaN( x ) )
		{
			w.HandsOffset = new AngPos
			{
				Pos = new Vector3( x, y, z ),
				Angle = new Angles( pitch, yaw, roll ),
			};

			WeaponPlacement.Save( w, WeaponPlacement.Hands, w.HandsOffset );
		}

		// ⚠️ THE STATUS IS FROM THE PREVIOUS FRAME AND THE LABEL NOW SAYS SO. `ApplyHands` runs
		// in `OnPreRender`, so at the instant this prints it has not yet seen the value just set —
		// which made the log read as though the offset and the result disagreed, and cost real
		// time during the bone-merge hunt. Run it bare to see the settled answer.
		Log.Info( $"[nz-parts] {w.ClassName} hands move {w.HandsOffset.Pos}"
			+ $"  turn {w.HandsOffset.Angle}  — last frame: {HandsStatus}" );
	}

	/// <summary>
	/// Make sure a rig exists to run `OnPreRender`, without switching the loose placements on.
	/// </summary>
	///
	/// ⛔ A CODE-CREATED GameObject DOES NOT SURVIVE A HOTLOAD, so every edit to this file silently
	/// destroys the rig — and with it the only thing calling `ApplyHands`. The hands offset then
	/// reports nothing at all, which reads as a dead control rather than an absent host. It is also
	/// wrong to need the PARTS rig for a hands tweak: `Active` stays false here, so this creates the
	/// component without turning the loose placements on.
	/// <summary>
	/// Make sure a rig exists. Idempotent, and cheap when one already does.
	/// </summary>
	///
	/// ⛔ PUBLIC BECAUSE THE SHIPPING SIGHTS NEED IT AND THE CONSOLE CANNOT BE THE ONLY CALLER.
	/// Every command here called this first, so a rig only ever existed once somebody had typed
	/// `nz_parts` — which was fine while the rig was purely a tuning tool and is not fine now
	/// that `LiveSights` draws the weapon's sights through it. `Weapon.CreateViewModelHandler`
	/// calls it, so a rig exists exactly when a viewmodel does.
	public static void EnsureRig()
	{
		if ( Current.IsValid() ) return;

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var host = scene.CreateObject();
		host.Name = "SCK Parts Rig";
		host.Flags |= GameObjectFlags.NotSaved;
		host.Components.Create<SckPartsRig>();

		// ⚠️ ONCE PER SCENE, NOT PER WEAPON — the guard above sees to that. It is worth saying
		// because a rig that failed to appear is otherwise indistinguishable from sights that
		// failed to place.
		Log.Info( "[nz-parts] rig ready (tuning placements off; live sights follow the manifest)" );
	}

	/// <summary>`nz_hands_reset` — put the hands back on the weapon.</summary>
	[ConCmd( "nz_hands_reset" )]
	public static void HandsResetCmd()
	{
		HandsMove = Vector3.Zero;
		HandsTurn = Angles.Zero;
		Log.Info( "[nz-parts] hands offset cleared" );
	}

	/// <summary>
	/// `nz_ads [0|1]` — show the weapon down the sights: the six extra pieces, and the aim pose.
	/// </summary>
	///
	/// ⚠️ IT DOES BOTH BECAUSE EITHER ALONE IS USELESS. The sight pieces are rings half a unit
	/// across sitting behind the barrel; placing one means looking THROUGH it at the middle of the
	/// screen, which only happens in the aim pose. Showing the pieces without the pose, or the pose
	/// without the pieces, each leaves the question unanswerable.
	[ConCmd( "nz_ads" )]
	public static void AdsCmd( int on = -1 )
	{
		EnsureRig();

		if ( on >= 0 )
		{
			Ads = on != 0;
			AimHold = Ads;

			// ⛔ THE SIGHT EXISTS ONLY IN THE LOOSE RIG. `v_prisma.vmdl` is the baked TEN-piece gun,
			// so with the rig off this command would hold the aim pose in front of a weapon that has
			// no sight on it at all — the control would look broken rather than empty. Turning it on
			// is the only way for the six to appear; turning ADS back off leaves the rig where it
			// was, because `nz_parts 0` is the one thing that should put the baked gun back.
			if ( Ads ) Active = true;
		}

		var n = Current.IsValid() ? Current.Parts.Count( Shown ) : 0;
		Log.Info( $"[nz-parts] {(Ads ? "AIMING" : "hip")} — {n} piece(s) drawn"
			+ $", viewmodel {(AimHold ? "held at the aim offset" : "free")}" );
	}

	/// <summary>
	/// `nz_ads_hold [0|1]` — the aim pose on its own, without changing which pieces draw.
	/// </summary>
	///
	/// ⚠️ SEPARATE FROM `nz_ads` FOR ONE REAL CASE: seeing where the sight OUGHT to land. Holding
	/// the hip pose at the aim offset shows the empty space the rings have to fill.
	[ConCmd( "nz_ads_hold" )]
	public static void AdsHoldCmd( int on = -1 )
	{
		EnsureRig();

		if ( on >= 0 ) AimHold = on != 0;

		Log.Info( $"[nz-parts] viewmodel {(AimHold ? "held at the aim offset" : "free")}" );
	}

	/// <summary>
	/// `nz_sights [0|1]` — whether the aim pieces draw on the real weapon in normal play.
	/// </summary>
	///
	/// ⚠️ ON IS THE SHIPPING STATE, unlike everything else in this file. Off leaves the weapon
	/// exactly as it was before the sights existed, which is the thing to try first if aiming ever
	/// looks wrong.
	///
	/// ⚠️ IT DOES NOT NEED THE EDITOR. No rig, no panel, no `nz_parts` — aim the Prisma and the
	/// sights are there.
	[ConCmd( "nz_sights" )]
	public static void SightsCmd( int on = -1 )
	{
		if ( on >= 0 ) LiveSights = on != 0;

		var n = Current.IsValid()
			? Current.Parts.Count( p => p.Visible && p.AdsOnly )
			: 0;

		Log.Info( $"[nz-parts] live sights {(LiveSights ? "ON" : "off")}"
			+ $" · {n} aim piece(s) on '{SightWeapon}' while aiming"
			+ $" · drawing now: {LiveOnly}" );
	}

	/// <summary>
	/// `nz_sight_probe` — where the drawn gun is, where the bone is, and what sits between them.
	/// </summary>
	///
	/// ⛔ IT EXISTS TO SETTLE ONE QUESTION AND NOT TO FIX ANYTHING: does the bone the sights hang
	/// off already carry the weapon's own view-model offset? If it does, adding that offset to the
	/// sights doubles it; if it does not, they need it. The two are indistinguishable from a
	/// screenshot — both look like "the sights are off to the side" — and guessing between them
	/// has already cost a round.
	///
	/// ⚠️ RUN IT WHILE AIMING. The aim offset is the large one (`AimAnimData`), and it is only
	/// applied while `IsAiming`, so a reading taken from the hip answers a different question.
	///
	/// ⚠️ THE NUMBER THAT MATTERS IS `bone - renderer`. If the sights are displaced by the same
	/// vector as the weapon's offset, the bone is not carrying it. If that delta stays put while
	/// the offset changes, it is.
	/// <summary>The previous probe's bone position, in the weapon's own frame.</summary>
	///
	/// ⚠️ A STATIC SO IT SURVIVES BETWEEN TWO CONSOLE CALLS, which is the whole mechanism —
	/// and surviving a hotload does no harm here because the next reading overwrites it.
	static Vector3? _lastProbe;

	[ConCmd( "nz_sight_probe" )]
	public static void ProbeCmd()
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-probe] no scene" ); return; }

		var handler = scene.GetAllComponents<ViewModelHandler>()
			.FirstOrDefault( h => h.IsValid() && h.ViewModelRenderer.IsValid() );

		if ( handler is null ) { Log.Warning( "[nz-probe] no viewmodel — hold a weapon" ); return; }

		var vm = handler.ViewModelRenderer;
		var w = handler.Weapon;

		Log.Info( $"[nz-probe] weapon '{(w.IsValid() ? w.ClassName : "?")}'"
			+ $" aiming {(w.IsValid() && w.IsAiming)}"
			+ $" · aimhold {AimHold}" );

		if ( w.IsValid() )
		{
			Log.Info( $"[nz-probe]   ViewModelOffset pos {w.ViewModelOffset.Pos} ang {w.ViewModelOffset.Angle}" );
			Log.Info( $"[nz-probe]   AimAnimData    pos {w.AimAnimData.Pos} ang {w.AimAnimData.Angle}" );
		}

		Log.Info( $"[nz-probe]   handler GO {handler.WorldPosition}" );
		Log.Info( $"[nz-probe]   renderer GO {vm.GameObject.WorldPosition}" );

		// ⚠️ `out var`, AND THE TYPE IS DELIBERATELY NOT NAMED. Inside a static member of a
		// `Component`, the bare name `Transform` binds to the instance property `Component.Transform`
		// rather than to the struct, so declaring the variable up front does not compile and
		// qualifying it guesses at a namespace. Letting the call infer it sidesteps both.
		if ( !Current.IsValid() || !Current.TryBone( vm, out var bone, out _ ) )
		{
			Log.Info( "[nz-probe]   bone NOT FOUND — nothing can be placed" );
			return;
		}

		// ⛔ IN THE RENDERER'S OWN FRAME, WHICH IS THE ONLY COMPARABLE NUMBER. A world-space
		// delta cannot be checked against the offsets because those are expressed along the
		// weapon's forward/right/up, and the player is facing an arbitrary direction. The
		// renderer sits at the camera with the camera's rotation, so its local frame IS that
		// basis: x forward, y left, z up.
		var local = vm.WorldTransform.PointToLocal( bone.Position );

		Log.Info( $"[nz-probe]   bone {bone.Position}" );
		Log.Info( $"[nz-probe]   bone in weapon frame = fwd {local.x:0.###}"
			+ $"  left {local.y:0.###}  up {local.z:0.###}" );

		// ⛔ THIS IS THE NUMBER THAT ACTUALLY DISCRIMINATES, and the earlier differential was not.
		// Measuring the bone relative to the renderer cancels the offset out: the pose is written
		// onto the renderer's own transform, so the bone moves WITH it and the relative reading is
		// constant whether the offset is carried or not.
		//
		// ⚠️ THE BAKED GUN'S BOUNDS, AGAINST THE BONE, IN THE BONE'S FRAME. The placements run
		// from about x -5 to x 31 along that axis, so if the drawn model's extent does not span
		// roughly the same range the bake and the manifest are in different spaces — which is the
		// one explanation left that fits "correct on the page, off to the side in game".
		var b = vm.Bounds;
		var lo = bone.PointToLocal( b.Mins );
		var hi = bone.PointToLocal( b.Maxs );

		Log.Info( $"[nz-probe]   baked bounds in BONE frame: "
			+ $"x {MathF.Min( lo.x, hi.x ):0.#}..{MathF.Max( lo.x, hi.x ):0.#}  "
			+ $"y {MathF.Min( lo.y, hi.y ):0.#}..{MathF.Max( lo.y, hi.y ):0.#}  "
			+ $"z {MathF.Min( lo.z, hi.z ):0.#}..{MathF.Max( lo.z, hi.z ):0.#}"
			+ "   ← placements span x -5..31" );

		// ⚠️ THE ANSWER IS THE CHANGE, NOT THE VALUE. There is no "natural" hand position to
		// compare a single reading against — but if the bone carries the weapon's offsets then
		// this number MUST move when they do, and if it does not carry them it cannot. So the
		// probe remembers the last reading and prints the difference: run it once from the hip,
		// once while aiming, and the second line answers the question outright.
		if ( _lastProbe.HasValue )
		{
			var d = local - _lastProbe.Value;

			Log.Info( $"[nz-probe]   CHANGE since last probe: fwd {d.x:0.###}"
				+ $"  left {d.y:0.###}  up {d.z:0.###}"
				+ (d.Length < 0.05f
					? "   ← UNCHANGED: the bone does NOT carry the offsets"
					: "   ← it moved: the bone DOES carry them") );
		}
		else
		{
			Log.Info( "[nz-probe]   (first reading — now aim, and run it again)" );
		}

		_lastProbe = local;

		// ⚠️ AND WHERE A SIGHT ACTUALLY LANDS, so the report answers "by how much" and not only
		// "which transform".
		if ( Current.Parts.FirstOrDefault( p => p.Name == "ads_rear" ) is { } part )
		{
			var placed = new Transform( GunMove, GunTurn.ToRotation() ).ToWorld( Current.Resolve( part ) );
			Log.Info( $"[nz-probe]   ads_rear drawn at {bone.PointToWorld( placed.Position )}" );
		}

		Log.Info( $"[nz-probe]   gun transform move {GunMove} turn {GunTurn} · size×{(Current.IsValid() ? Current.SizeScale : 1f):0.###}" );
	}

	/// <summary>
	/// `nz_sight_offset [x y z] [pitch yaw roll]` — nudge the in-game sights onto the gun.
	/// </summary>
	///
	/// ⚠️ x IS ALONG THE BARREL, y IS SIDEWAYS, z IS UP — the bone's own frame, the same axes
	/// the manifest uses, NOT the screen. And on this weapon up is −z.
	///
	/// ⚠️ ABSOLUTE, NOT CUMULATIVE, so a second call replaces the first rather than stacking:
	/// finding a correction by bisection is a lot easier when the number on screen is the number
	/// in effect. Called with nothing it reports.
	///
	/// ⚠️ IT AFFECTS THE LIVE SIGHTS ONLY. The placement page and the tuning rig are untouched,
	/// so whatever is dialled here cannot corrupt the values already tuned there.
	[ConCmd( "nz_sight_offset" )]
	public static void SightOffsetCmd( float x = float.NaN, float y = 0f, float z = 0f,
		float pitch = float.NaN, float yaw = 0f, float roll = 0f )
	{
		if ( !float.IsNaN( x ) ) SightFix = new Vector3( x, y, z );
		if ( !float.IsNaN( pitch ) ) SightFixTurn = new Angles( pitch, yaw, roll );

		Log.Info( $"[nz-parts] live sight offset {SightFix} turn {SightFixTurn}"
			+ $" · drawing now: {LiveOnly}" );
	}

	/// <summary>
	/// `nz_sight_turn &lt;front|rear&gt; [pitch yaw roll]` — rotate ONE sight piece in place.
	/// </summary>
	///
	/// ⚠️ THE PIECE, NOT THE ASSEMBLY. `nz_sight_offset` turns both sights together about the
	/// bone, which is the right handle for "the whole sight is in the wrong place" and the wrong
	/// one for "that post is lying on its side".
	///
	/// ⚠️ IT LEAVES THE POSITION ALONE, unlike `nz_part`, which takes all six numbers at once
	/// and so needs the position retyping to change an angle.
	///
	/// ⚠️ ABSOLUTE AND IN THE MANIFEST'S OWN TERMS, so whatever lands here can be pasted
	/// straight into `prisma_parts.json` — remembering that these meshes are authored +z up on a
	/// weapon whose up is −z, which is why both start at roll 180.
	[ConCmd( "nz_sight_turn" )]
	public static void SightTurnCmd( string which = "", float pitch = float.NaN,
		float yaw = 0f, float roll = 0f )
	{
		if ( !Current.IsValid() ) { Log.Warning( "[nz-parts] no rig" ); return; }

		var name = which.ToLowerInvariant() switch
		{
			"front" or "ads_front" => "ads_front",
			"rear" or "ads_rear" => "ads_rear",
			_ => which,
		};

		var part = Current.Parts.FirstOrDefault( p => p.Name == name );

		if ( part is null )
		{
			Log.Info( "[nz-parts] nz_sight_turn <front|rear> [pitch yaw roll] — custom pieces: "
				+ string.Join( ", ", Current.Parts.Where( p => p.Name.StartsWith( "ads_" ) )
					.Select( p => p.Name ) ) );
			return;
		}

		if ( !float.IsNaN( pitch ) )
		{
			part.Ang[0] = pitch;
			part.Ang[1] = yaw;
			part.Ang[2] = roll;
		}

		Log.Info( $"[nz-parts] {part.Name} ang {part.Ang[0]:0.##},{part.Ang[1]:0.##},{part.Ang[2]:0.##}"
			+ $" · pos {part.Pos[0]:0.###},{part.Pos[1]:0.###},{part.Pos[2]:0.###}"
			+ $" · size {part.Size[0]:0.###}" );
	}

	/// <summary>
	/// `nz_parts_full [0|1]` — let the panel replace the whole weapon, not just add the sights.
	/// </summary>
	///
	/// ⚠️ YOU WANT THIS OFF unless a HIP placement needs moving. On, the baked gun is hidden and
	/// rebuilt from eighteen loose objects, and that reconstruction does not match the bake — it
	/// is the "squashed and weird" gun, and anything tuned against it inherits the error.
	[ConCmd( "nz_parts_full" )]
	public static void FullRigCmd( int on = -1 )
	{
		if ( on >= 0 ) FullRig = on != 0;

		Log.Info( $"[nz-parts] panel mode: {(FullRig ? "FULL RIG — the baked gun is replaced" : "sights only — the baked gun keeps drawing")}" );
	}

	/// <summary>`nz_gun_reset` — put the whole-weapon transform back to nothing.</summary>
	[ConCmd( "nz_gun_reset" )]
	public static void GunResetCmd()
	{
		GunTurn = Angles.Zero;
		GunMove = Vector3.Zero;
		Log.Info( "[nz-parts] whole-weapon transform cleared" );
	}

	/// <summary>`nz_parts_scale [x]` — multiply every part's size at once.</summary>
	[ConCmd( "nz_parts_scale" )]
	public static void ScaleCmd( float x = float.NaN )
	{
		if ( !Current.IsValid() ) { Log.Info( "[nz-parts] no rig — run nz_parts" ); return; }

		if ( !float.IsNaN( x ) ) Current.SizeScale = x;
		Log.Info( $"[nz-parts] size multiplier x{Current.SizeScale:0.####}" );
	}

	/// <summary>
	/// `nz_parts_status` — say exactly where the rig stops.
	/// </summary>
	[ConCmd( "nz_parts_status" )]
	public static void StatusCmd()
	{
		if ( !Current.IsValid() ) { Log.Info( "[nz-parts] no rig — run nz_parts" ); return; }

		Log.Info( $"[nz-parts] {(Active ? "ON" : "OFF")} · {Status}" );
		Log.Info( $"[nz-parts] hands: {HandsStatus}" );
		Log.Info( $"[nz-parts] pose: {(Ads ? "AIMING — 16 pieces" : "hip — 10 pieces")}"
			+ $", viewmodel {(AimHold ? "held at the aim offset" : "free")}" );
		Log.Info( $"[nz-parts] manifest '{Current.ManifestPath}', {Current.Parts.Count} part(s), "
			+ $"{Current.Parts.Count( p => p.Visible )} visible, size x{Current.SizeScale:0.####}, "
			+ $"baked {(Current.HideBaked ? "hidden" : "shown")}" );

		foreach ( var p in Current.Parts.Where( p => p.Visible ) )
			Log.Info( $"    {p.Name,-6} {(Model.Load( p.Model ) is null ? "NO MODEL" : "ok      ")} {p.Model}" );
	}

	/// <summary>
	/// Push the hands off the weapon by the held weapon's `HandsOffset`.
	/// </summary>
	///
	/// ⛔ DERIVED FROM THE VIEWMODEL EVERY FRAME, NEVER FROM THE HANDS' OWN CURRENT TRANSFORM.
	/// Reading where the hands are and nudging them from there looks equivalent and accumulates:
	/// if anything fails to rewrite that transform on a later frame, the offset is applied on top of
	/// itself and the hands walk off screen over a few seconds. Anchoring to the viewmodel makes the
	/// result a pure function of the offset, so it is the same whether it ran once or a thousand times.
	///
	/// ⛔ AND THAT PARAGRAPH WAS FICTION UNTIL THE HANDS GOT THEIR OWN GAMEOBJECT. Both renderers
	/// were components on ONE object, so `vm.WorldTransform` and `hands.GameObject` were the SAME
	/// transform — this was exactly the read-then-nudge pattern it warns against, and the only
	/// reason the hands did not walk off screen is that `ViewModelHandler` rewrites that transform
	/// from the camera earlier in the frame. What the user saw instead was the GUN moving, because
	/// the write landed on the object the gun renders from.
	///
	/// ⚠️ SO AN OFFSET SAVED BEFORE THAT SPLIT MEANT SOMETHING ELSE. It moved the whole
	/// viewmodel, gun and hands together; it now moves the hands alone. Any weapon tuned under the
	/// old behaviour has to be re-tuned from zero rather than nudged.
	///
	/// ⛔ AND THE ANSWER WAS THAT THE OBJECT NEVER MATTERED AT ALL. The merge does not overwrite
	/// the hands' transform — it IGNORES it, positioning the mesh entirely from the gun's bone
	/// world transforms. Measured: `nz_hands 0 0 23` moved the GameObject the full 23 units, the
	/// old check confirmed the write had landed, and nothing on screen moved by a pixel.
	///
	/// ⚠️ SO THE OFFSET HAS TO HAPPEN WHERE THE MESH ACTUALLY LIVES, in the bones. This now
	/// rewrites every shared bone as `gun bone × offset` through `SceneModel.SetBoneOverride`,
	/// which is the one mechanism that can separate a merged mesh from its target.
	///
	/// ⚠️ THE HANDS STILL ANIMATE. The override is recomputed from the gun's live pose every
	/// frame rather than frozen, so the grip follows reloads and inspects exactly as before — it
	/// just sits somewhere else while it does.
	///
	/// ⚠️ AND IT MAY NOT TAKE. The hands are bone-merged (`BoneMergeTarget`), so depending on how
	/// the merge drives them the object transform can simply be overwritten. `HandsStatus` says which
	/// happened rather than leaving a dead control on screen.
	void ApplyHands( ViewModelHandler handler )
	{
		var hands = handler?.ViewModelHandsRenderer;
		var vm = handler?.ViewModelRenderer;

		if ( !hands.IsValid() || !vm.IsValid() )
		{
			HandsStatus = "no hands renderer";
			return;
		}

		// ⛔ THE WEAPON'S OWN OFFSET, NOT A GLOBAL ONE. Hands that sit wrong are this model's
		// hands against this model's grip; a static shared by every gun in the pack would drag
		// the other 495 off theirs to fix one. `Weapon.HandsOffset`, saved through
		// `WeaponPlacement` like the poses.
		var w = handler?.Weapon;
		var move = w.IsValid() ? w.HandsOffset.Pos : Vector3.Zero;
		var turn = w.IsValid() ? w.HandsOffset.Angle : Angles.Zero;

		var hsm = hands.SceneModel;
		var gsm = vm.SceneModel;

		if ( !hsm.IsValid() || !gsm.IsValid() ) { HandsStatus = "no scene model yet"; return; }

		// ⚠️ ZERO MEANS "HAND THE BONES BACK TO THE MERGE", not "put the hands at the origin".
		// There is no per-bone clear, so the release has to be all of them at once — and only when
		// something was actually overridden, or this clears the merge's own work every frame.
		if ( move.Length < 0.0001f && turn == Angles.Zero )
		{
			if ( _handsOverridden )
			{
				hsm.ClearBoneOverrides();

				// ⚠️ THE MERGE GOES BACK ON, and it is set from `vm` rather than from a cached
				// copy of what it used to be. A cache would be one more thing to go stale across
				// a weapon swap, and there is only ever one right answer: the gun this hands
				// renderer belongs to, which is the same thing `CreateViewModel` assigns.
				hands.BoneMergeTarget = vm;
				_handsOverridden = false;
			}

			HandsStatus = "neutral";
			return;
		}

		EnsureBoneMap( hands, vm );

		if ( _boneMap.Length == 0 )
		{
			HandsStatus = "no bone names shared with the gun skeleton — nothing to drive";
			return;
		}

		// ⛔ THE MERGE OUTRANKS BONE OVERRIDES, AND THAT IS WHY THE FIRST VERSION DID NOTHING.
		// With `BoneMergeTarget` set, the renderer's own bone pipeline is bypassed — the mesh is
		// posed straight from the target's skeleton and `SetBoneOverride` is never consulted.
		// Measured: 39 bones driven, an offset of **522 units**, and not a pixel of movement.
		//
		// ⚠️ SO THE MERGE COMES OFF WHILE WE DRIVE, AND NOTHING IS LOST BY IT. What the merge
		// does is copy the gun's bone transforms onto the hands by name — which is exactly what
		// the loop below already does, from the same source, with the offset folded in. This is
		// not replacing the merge with an approximation of it; it is the merge plus a transform.
		//
		// ⚠️ AND IT GOES BACK ON AT ZERO, so a weapon nobody has tuned keeps the stock path and
		// never pays for any of this.
		if ( hands.BoneMergeTarget.IsValid() ) hands.BoneMergeTarget = null;

		var vmT = vm.WorldTransform;
		var offsetT = new Transform( move, turn.ToRotation() );
		var handsT = hsm.Transform;

		foreach ( var (hi, gi) in _boneMap )
		{
			// ⛔ READ THE GUN, NEVER THE HANDS. `GetBoneWorldTransform` answers with the pose
			// AFTER overrides, so reading the bone we are about to write feeds our own offset back
			// in and it DOUBLES every frame — `ModelTuner` measured that exact mistake reaching
			// z 116,818,000 on a 70-unit body. The gun is the merge's source and we never write to
			// it, so reading it is a fixed point: the same answer whether this ran once or all
			// night.
			var bone = gsm.GetBoneWorldTransform( gi );

			// the offset is expressed in the VIEWMODEL's frame, which is the frame the person
			// typing `nz_hands` is looking at — x down the barrel, whichever way they face
			var want = vmT.ToWorld( offsetT.ToWorld( vmT.ToLocal( bone ) ) );

			// ⚠️ THE SETTER TAKES MODEL SPACE AND THE GETTER ANSWERS IN WORLD. Two spaces behind
			// two names that do not say so; the renderer's own transform is what converts.
			var local = handsT.ToLocal( want );
			hsm.SetBoneOverride( hi, in local );
		}

		_handsOverridden = true;

		// ⚠️ COUNTED, NOT ASSUMED — AND THE OLD CHECK LIED. It compared the hands GAMEOBJECT
		// against where it had just been told to go, which always matched, so it reported
		// "applied" for an offset of 23 units while nothing on screen moved at all. The object was
		// never what positioned the mesh. What is worth reporting is how many bones are actually
		// being driven.
		HandsStatus = $"offset {move} driving {_boneMap.Length} bone(s)";
	}

	bool _handsOverridden;
	Model _mapHands, _mapGun;
	(int Hand, int Gun)[] _boneMap = Array.Empty<(int, int)>();

	/// <summary>
	/// Pair up the hands' bones with the gun's by name, once per pair of models.
	/// </summary>
	///
	/// ⛔ EVERY SHARED BONE, NOT JUST A ROOT. An override replaces one bone's final transform and
	/// nothing else; the merge writes each of the hands' bones independently from the gun, so a
	/// child does NOT follow an overridden parent. Offsetting the wrist alone moves the wrist and
	/// leaves the fingers behind it.
	///
	/// ⚠️ BOTH SPELLINGS OF THE DOT, the same trap `TryBone` and `ThirdPersonWeapon.GunAnchors`
	/// already carry: Source writes `ValveBiped.Bip01_R_Hand` and a model that has been through
	/// Blender Source Tools comes back as `ValveBiped_Bip01_R_Hand`. Matching on the raw string
	/// would pair up zero bones between a ported gun and stock hands and look like the hands
	/// simply having no skeleton.
	void EnsureBoneMap( SkinnedModelRenderer hands, SkinnedModelRenderer vm )
	{
		if ( _mapHands == hands.Model && _mapGun == vm.Model ) return;

		_mapHands = hands.Model;
		_mapGun = vm.Model;
		_boneMap = Array.Empty<(int, int)>();

		var hb = hands.Model?.Bones?.AllBones;
		var gb = vm.Model?.Bones?.AllBones;
		if ( hb is null || gb is null ) return;

		var byName = new Dictionary<string, int>();
		foreach ( var b in gb ) byName[Flatten( b.Name )] = b.Index;

		var pairs = new List<(int, int)>();
		foreach ( var b in hb )
			if ( byName.TryGetValue( Flatten( b.Name ), out var gi ) )
				pairs.Add( (b.Index, gi) );

		_boneMap = pairs.ToArray();

		Log.Info( $"[nz-parts] hands rig: {pairs.Count} of {hb.Count()} bone(s)"
			+ $" matched to the gun skeleton" );
	}

	/// <summary>Bone names, with the dot/underscore difference taken out.</summary>
	static string Flatten( string s )
		=> string.IsNullOrEmpty( s ) ? "" : s.Replace( '.', '_' ).ToLowerInvariant();

	bool _warnedBone;
	string _usedBone = "";

	/// <summary>
	/// Find the anchor bone, trying BOTH spellings of the dot.
	/// </summary>
	///
	/// ⛔ BLENDER SANITISES THE DOT AND NOTHING SAYS SO. Source calls it
	/// `ValveBiped.Bip01_R_Hand`; the model that comes back out of Blender Source Tools has
	/// `ValveBiped_Bip01_R_Hand`. The manifest is generated from the weapon's lua, so it carries the
	/// DOTTED name and must keep carrying it — rewriting the manifest to match the compiled model
	/// would make it disagree with the source it was read from.
	///
	/// ⚠️ `ThirdPersonWeapon.GunAnchors` ALREADY CARRIES BOTH SPELLINGS FOR THIS EXACT REASON, and
	/// the third-person grip silently fell back to the mesh origin before it did. Same trap, second
	/// place it has bitten, so this resolves it rather than asking anyone to know about it.
	/// <summary>Should the shipping sights be drawing on this viewmodel, this frame.</summary>
	///
	/// ⚠️ `IsAiming` RATHER THAN THE AIM BUTTON, because that flag is the single gate everything
	/// else already derives from — including `AimHold`, so the sights appear in the editor's held
	/// pose too, which is the whole point of being able to tune them there.
	static bool LiveWanted( ViewModelHandler handler )
	{
		if ( !LiveSights ) return false;

		var w = handler?.Weapon;
		if ( !w.IsValid() ) return false;

		// ⛔ THE OFFSET EDITOR COUNTS AS AIMING, AND WITHOUT THIS IT CANNOT BE USED ON A SIGHT.
		// `swb_editor_offsets` holds the weapon at a pose through `EditorOffset` and never touches
		// `IsAiming` — so the one panel whose entire job is to line the gun up had the sights
		// invisible while doing it. `Commands.OpenOffsetsEditor` already sets `EditorMode` on the
		// handler for exactly this kind of question; nothing new had to be plumbed.
		//
		// ⚠️ IT IS NOT AN EXTRA "SHOW THEM ANYWAY" FLAG. The editor genuinely is an aiming
		// context: it exists to position the weapon as the player will see it down the sights.
		if ( !w.IsAiming && !handler.EditorMode ) return false;

		return string.IsNullOrEmpty( SightWeapon )
			|| string.Equals( w.ClassName, SightWeapon, StringComparison.OrdinalIgnoreCase );
	}

	internal bool TryBone( SkinnedModelRenderer vm, out Transform bone, out string used )
	{
		foreach ( var name in new[] { _manifest.Bone,
									  _manifest.Bone.Replace( '.', '_' ),
									  _manifest.Bone.Replace( '_', '.' ) } )
		{
			if ( !string.IsNullOrEmpty( name ) && vm.TryGetBoneTransform( name, out bone ) )
			{
				used = name;
				return true;
			}
		}

		bone = default;
		used = "";
		return false;
	}

	GameObject Ensure( SckPart p, GameObject viewModel )
	{
		if ( _objects.TryGetValue( p.Name, out var have ) && have.IsValid() )
			return have;

		var model = Model.Load( p.Model );
		if ( model is null )
		{
			Log.Warning( $"[nz-parts] part '{p.Name}' has no model at '{p.Model}'" );
			return null;
		}

		var obj = Scene.CreateObject();
		obj.Name = $"sck part {p.Name}";
		obj.Flags |= GameObjectFlags.NotSaved;

		// ⛔ THE VIEWMODEL IS SELECTED BY TAG, NOT BY A RENDER FLAG, and guessing otherwise is how
		// the first version of this drew the parts into the world at map scale. `CreateViewModel`
		// tags its object `TagsHelper.ViewModel` and the viewmodel camera keys off that — so a
		// loose part has to carry the same tag, and hang off the same object, to be drawn with it.
		obj.SetParent( viewModel, false );
		obj.Tags.Add( TagsHelper.ViewModel );
		obj.NetworkMode = NetworkMode.Never;

		var r = obj.Components.Create<ModelRenderer>();
		r.Model = model;
		r.RenderType = ModelRenderer.ShadowRenderType.Off;

		_objects[p.Name] = obj;
		return obj;
	}

	/// <summary>
	/// The tuned values, in the shape `sck_bake.py --overrides` expects.
	/// </summary>
	///
	/// ⚠️ ONLY THE PARTS THAT MOVED. A dump of all seventeen would bury the four numbers that
	/// actually changed, and the overrides file is meant to be read by a person.
	public string Dump( SckManifest original )
	{
		var lines = new List<string>();

		foreach ( var p in Parts )
		{
			var was = original?.Parts?.FirstOrDefault( q => q.Name == p.Name );
			if ( was is null ) continue;

			var moved = !Same( p.Pos, was.Pos );
			var turned = !Same( p.Ang, was.Ang );
			var resized = !Same( p.Size, was.Size );
			if ( !moved && !turned && !resized ) continue;

			var bits = new List<string>();
			if ( moved ) bits.Add( $"\"pos\": [{F( p.Pos[0] )}, {F( p.Pos[1] )}, {F( p.Pos[2] )}]" );
			if ( turned ) bits.Add( $"\"ang\": [{F( p.Ang[0] )}, {F( p.Ang[1] )}, {F( p.Ang[2] )}]" );
			if ( resized ) bits.Add( $"\"size\": [{F( p.Size[0] )}, {F( p.Size[1] )}, {F( p.Size[2] )}]" );

			lines.Add( $"    \"{p.Name}\": {{ {string.Join( ", ", bits )} }}" );
		}

		// ⚠️ THE MULTIPLIER IS EMITTED EVEN WHEN NOTHING ELSE MOVED, because on its own it is
		// a complete and very likely edit: "the whole gun is the wrong size" is one number, not
		// seventeen.
		var scale = MathF.Abs( SizeScale - ( original?.Scale ?? 1f ) ) > 0.0005f
			? "  \"scale\": " + F( SizeScale ) : "";

		var open = "{" + '\n';
		var close = '\n' + "}";

		if ( lines.Count == 0 )
			return scale.Length > 0 ? open + scale + close : open + "  \"parts\": {}" + close;

		return open
			+ ( scale.Length > 0 ? scale + "," + '\n' : "" )
			+ "  \"parts\": {" + '\n'
			+ string.Join( "," + '\n', lines )
			+ '\n' + "  }" + close;
	}

	static string F( float v ) => v.ToString( "0.###" );

	static bool Same( float[] a, float[] b )
	{
		if ( a is null || b is null || a.Length != b.Length ) return false;
		for ( var i = 0; i < a.Length; i++ )
			if ( MathF.Abs( a[i] - b[i] ) > 0.0005f ) return false;
		return true;
	}
}