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.
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 <front|rear> [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;
}
}