A UI tool component for live-tuning body-proportion bone offsets on ported character models. It opens a non-networked overlay, finds ported SkinnedModelRenderer instances, resolves grouped bone indices, and applies combined per-group Z offsets as per-slider values by setting bone overrides each frame; it can clear, zero, print flags and accept console commands to set values or probe transforms.
using Sandbox;
using Sandbox.UI;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Live body-proportion tuning for the ported characters: `nz_tune`.
///
/// ⛔ IT EXISTS BECAUSE THE ALTERNATIVE IS A SIX MINUTE ROUND TRIP. The drops live in
/// `Tools/retarget_to_human.py` and are baked into the mesh, so judging a value meant
/// re-retargeting eight characters, recompiling eight models and looking — per guess. Three rounds
/// of "a bit lower" cost most of an afternoon. This drives the same names on the bodies already on
/// screen, live, and PRINT writes them back out as the `--drop` argument the retarget takes.
///
/// ⚠️ THE PREVIEW OVERRIDES BONES; THE BAKE MOVES MESH. Not the same operation, but the same
/// result for the thing being judged: dropping `spine_2` by d puts the chest exactly where
/// translating its chunk by d does. The difference is at the boundaries, where an override blends
/// through the skin weights and the bake tears rigidly — so the preview is the FLATTERING one. A
/// value that looks right here is the value to bake; the baked seams will be slightly harder.
///
/// ⛔ AND IT MOVES WHOLE SUBTREES, NOT SINGLE BONES. `SetBoneOverride` replaces a bone's FINAL
/// transform, so children do NOT follow it — dropping `clavicle_L` by itself detaches the shoulder
/// cap from an upper arm that has not moved. Every bone under a group's roots is collected and
/// moved together, which is what the retarget does with the same table.
/// </summary>
public partial class ModelTuner
{
/// <summary>
/// The parts, and which target bones each covers: (roots, include everything under them).
///
/// ⛔ THE SAME TABLE AS `GROUPS` IN `retarget_to_human.py`, AND IT HAS TO STAY THAT WAY. The
/// whole point of the panel is that a number found here can be pasted into the bake; the moment
/// one side covers a bone the other does not, it is a preview of something else.
///
/// ⛔ THEY NEST ON PURPOSE AND THE OFFSETS ADD. `hands` sits inside `arms`, so a finger belongs
/// to both and gets both. Taking the last group to mention a bone would silently make one of
/// the two knobs do nothing on exactly the bones where they overlap.
///
/// ⚠️ `hips` AND `neck` ARE SINGLE BONES, NOT SUBTREES. Everything hangs off the pelvis, so a
/// pelvis subtree would move the entire character and read as nothing having happened.
/// </summary>
static readonly (string Name, string[] Roots, bool Deep)[] Groups =
{
( "head", new[] { "head" }, true ),
( "neck", new[] { "neck_0", "neck_1" }, false ),
( "chest", new[] { "spine_2" }, false ),
( "waist", new[] { "spine_0", "spine_1" }, false ),
( "arms", new[] { "clavicle_L", "clavicle_R" }, true ),
( "hands", new[] { "hand_L", "hand_R" }, true ),
( "hips", new[] { "pelvis" }, false ),
( "legs", new[] { "leg_upper_L", "leg_upper_R" }, true ),
};
// ⚠️ ONE PROPERTY PER PART rather than a dictionary, because `Value:bind` on a slider needs
// something to bind TO. Signed: positive lowers, negative raises.
//
// ⛔ THEY START AT ZERO, AND ZERO MEANS "THE MODEL AS IT SHIPS" — not "no drop anywhere". This
// panel offsets a body that has ALREADY been baked with its own values, so what it shows is a
// DELTA on top of them and never the bake's own numbers.
//
// ⛔ IT USED TO OPEN AT THE RETARGET'S DEFAULTS, chest and arms at 0.010, on the theory that
// that matched the model. It did the opposite: those were applied on top of a body that already
// had them, so the chest was dropped TWICE the moment the panel appeared and every judgement
// after that was made against a body nobody had asked for. The first tuned line to come back
// had chest and arms conspicuously absent — which is what noticing this looks like from the
// other end.
public float Head { get; set; }
public float Neck { get; set; }
public float Chest { get; set; }
public float Waist { get; set; }
public float Arms { get; set; }
public float Hands { get; set; }
public float Hips { get; set; }
public float Legs { get; set; }
float ValueOf( string group ) => group switch
{
"head" => Head,
"neck" => Neck,
"chest" => Chest,
"waist" => Waist,
"arms" => Arms,
"hands" => Hands,
"hips" => Hips,
"legs" => Legs,
_ => 0f,
};
void SetValue( string group, float v )
{
switch ( group )
{
case "head": Head = v; break;
case "neck": Neck = v; break;
case "chest": Chest = v; break;
case "waist": Waist = v; break;
case "arms": Arms = v; break;
case "hands": Hands = v; break;
case "hips": Hips = v; break;
case "legs": Legs = v; break;
}
}
/// <summary>Open or close the tuner: `nz_tune`.</summary>
///
/// ⛔ THE SCENE IS THE SOURCE OF TRUTH, NOT A STATIC — INSTRUCTIONS §1. A `static GameObject`
/// remembering the open panel survives a hotload by NAME while the object it points at may not,
/// and the reverse: after one recompile the console had TWO 'Model Tuner' screen panels stacked,
/// one at the old z-index, because the toggle had closed the one the static remembered and the
/// other was already orphaned. Asking the scene what exists cannot drift.
///
/// ⛔ ITS OWN ScreenPanel, NOT A CHILD OF WHATEVER PANEL WAS FOUND FIRST. Hosting it on an
/// existing PanelComponent is what the weapon offset editor does and it laid out correctly —
/// and drew UNDER the lobby, invisible, while `ui_panel_dump` reported it present at the right
/// rect. A panel you cannot see but can measure is the worst kind of working.
[ConCmd( "nz_tune" )]
public static void Toggle()
{
var scene = Game.ActiveScene;
if ( scene is null ) { Log.Warning( "[nz-tune] no scene" ); return; }
var live = scene.GetAllComponents<ModelTuner>().Where( t => t.IsValid() ).ToList();
if ( live.Count > 0 )
{
foreach ( var t in live )
t.GameObject?.Destroy();
Log.Info( $"[nz-tune] closed{(live.Count > 1 ? $" ({live.Count} were open)" : "")}" );
return;
}
var host = scene.CreateObject();
host.Name = "Model Tuner";
host.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;
// ⚠️ LOCAL ONLY. An authoring overlay on one machine has no business existing on anybody
// else's — the lobby preview stage carries the same note for the same reason.
host.NetworkMode = NetworkMode.Never;
var screen = host.Components.Create<ScreenPanel>();
// ⚠️ ABOVE THE LOBBY, NOT LEVEL WITH IT. The lobby's own ScreenPanel is at 100 and its
// `.content` is a FULLSCREEN panel with pointer-events all — so at a tie the card can render
// on top and still have every click swallowed by the menu underneath it.
screen.ZIndex = 200;
host.Components.Create<ModelTuner>();
Mouse.Visibility = MouseVisibility.Visible;
Log.Info( "[nz-tune] open. Drag the sliders; PRINT writes the retarget flags to the console." );
}
/// <summary>
/// Set one part, or all of them, from the console: `nz_tune_set chest -0.02`, `nz_tune_set all 0`.
///
/// ⚠️ THE PROJECT RULE IS THAT EVERY CONTROL GETS A COMMAND, and a slider is the case that
/// needs it most: it is the one control that cannot be driven from anywhere except a mouse on
/// the machine running the game, so without this the panel cannot be tested remotely at all.
///
/// ⛔ NAMED, NOT POSITIONAL, AND THE OLD SHAPE WAS A TRAP. It used to be `nz_tune_set <chest>
/// [arm]` with `-1f` meaning "not given" — fine while the range started at zero, a bug the
/// moment the sliders went negative: `nz_tune_set 0.02 -0.01` read the negative arm value as
/// "no second argument" and copied the chest instead. A sentinel inside the valid range is not
/// a sentinel, and with eight parts a positional list would be unreadable anyway.
/// </summary>
[ConCmd( "nz_tune_set" )]
public static void Set( string part = "", float value = 0f )
{
var t = Game.ActiveScene?.GetAllComponents<ModelTuner>().FirstOrDefault( x => x.IsValid() );
if ( t is null ) { Log.Warning( "[nz-tune] not open — run nz_tune first" ); return; }
part = part.Trim().ToLowerInvariant();
if ( part == "all" )
{
foreach ( var g in Groups )
t.SetValue( g.Name, value );
}
else if ( Groups.Any( g => g.Name == part ) )
{
t.SetValue( part, value );
}
else
{
Log.Info( "[nz-tune] nz_tune_set <part|all> <value>" );
Log.Info( "[nz-tune] parts: " + string.Join( ", ", Groups.Select( g => g.Name ) ) );
Log.Info( $"[nz-tune] now: {t.Flags}" );
return;
}
t.StateHasChanged();
Log.Info( $"[nz-tune] {t.Flags}" );
}
/// <summary>
/// What space each bone accessor actually answers in: `nz_tune_probe`.
///
/// ⚠️ IT EXISTS BECAUSE THE NAMES DISAGREE WITH EACH OTHER. `GetBoneWorldTransform` reads as
/// world and `SetBoneOverride` documents its input as "local coordinates based on the
/// SceneModel's transform"; guessing which one is true cost two collapsed models on screen.
/// Print the body's world position beside a bone's and the answer is one line.
/// </summary>
[ConCmd( "nz_tune_probe" )]
public static void Probe()
{
var r = Game.ActiveScene?.GetAllComponents<SkinnedModelRenderer>()
.FirstOrDefault( IsPorted );
if ( !r.IsValid() ) { Log.Warning( "[nz-tune] no ported body in the scene" ); return; }
var sm = r.SceneModel;
var b = r.Model?.Bones?.GetBone( "spine_2" );
if ( !sm.IsValid() || b is null ) { Log.Warning( "[nz-tune] no scene model / spine_2" ); return; }
Log.Info( $"[nz-tune] body world {r.WorldPosition}" );
Log.Info( $"[nz-tune] SceneModel.Trans {sm.Transform.Position}" );
Log.Info( $"[nz-tune] bone 'spine_2' GetBoneWorldTransform {sm.GetBoneWorldTransform( b.Index ).Position}" );
Log.Info( $"[nz-tune] model bounds {r.Model.Bounds.Size}" );
}
/// <summary>One body being driven, with each group's bone indices resolved once.</summary>
class Rig
{
public SkinnedModelRenderer R;
public float Height = 1f;
/// <summary>Group name -> the bone indices it covers on THIS model.</summary>
public readonly Dictionary<string, int[]> Bones = new();
/// <summary>Bone index -> its transform in MODEL space, read once while nothing was
/// overriding it. See `Apply` for why it is read exactly once.</summary>
public readonly Dictionary<int, Transform> Rest = new();
}
readonly List<Rig> _rigs = new();
/// <summary>How many bodies the sliders are driving, for the panel to show.</summary>
public int Driving => _rigs.Count;
/// <summary>
/// Is this one of ours?
///
/// ⚠️ BY MODEL PATH, NOT BY BONE NAMES. The citizen has `spine_2` and `clavicle_L` too — it IS
/// the target rig — so a bone test would drive the engine's own body as well and make it look
/// like the tuner had broken something it does not own.
/// </summary>
static bool IsPorted( SkinnedModelRenderer r )
=> r.IsValid() && r.Model is not null
&& r.Model.ResourcePath is string p
&& p.Contains( "models/player/", StringComparison.OrdinalIgnoreCase )
&& p.Contains( "_rigged", StringComparison.OrdinalIgnoreCase );
static void Collect( BoneCollection.Bone b, List<int> into )
{
into.Add( b.Index );
foreach ( var c in b.Children )
Collect( c, into );
}
void Refresh()
{
var live = Game.ActiveScene?.GetAllComponents<SkinnedModelRenderer>()
.Where( IsPorted ).ToList() ?? new List<SkinnedModelRenderer>();
_rigs.RemoveAll( g => !g.R.IsValid() || !live.Contains( g.R ) );
foreach ( var r in live )
{
if ( _rigs.Any( g => g.R == r ) ) continue;
var bones = r.Model?.Bones;
if ( bones is null ) continue;
var rig = new Rig
{
R = r,
// ⛔ THE MODEL'S OWN BOUNDS, NOT THE RENDERER'S. `r.Bounds` is the LIVE bounds and
// an override that goes wrong corrupts it — measured at 116 million units on a
// 70-unit body — so reading the height from it makes one bad frame permanent.
Height = MathF.Max( 1f, r.Model.Bounds.Size.z ),
};
foreach ( var g in Groups )
{
var idx = new List<int>();
foreach ( var root in g.Roots )
{
var b = bones.GetBone( root );
if ( b is null ) continue;
if ( g.Deep ) Collect( b, idx );
else idx.Add( b.Index );
}
rig.Bones[g.Name] = idx.ToArray();
}
// ⚠️ A BODY WITH NOTHING TO DRIVE IS NOT A FAILURE, it is a model that has not finished
// loading, or one that is not on the human rig. Skipped, and picked up on a later tick.
if ( rig.Bones.Values.All( v => v.Length == 0 ) ) continue;
_rigs.Add( rig );
}
}
/// <summary>
/// Push the current slider values onto one body.
///
/// ⛔ THE REST POSE IS READ EXACTLY ONCE, AND EVERY FRAME AFTER THAT ASSIGNS FROM IT.
/// `GetBoneWorldTransform` answers with the pose AFTER overrides and `SetBoneOverride` takes
/// MODEL space, so a read-modify-write loop feeds the body's own world transform back into
/// itself every frame and the bone position DOUBLES. Measured on the lobby stage, which sits at
/// z 20000: `spine_2` reached z 116,818,000 within seconds and the character rendered as a
/// vertical smear. `ClearBoneOverrides` first does not save it — the clear does not reach the
/// read in the same frame.
///
/// ⚠️ THE CLEAR IS STILL NEEDED, for the other direction: a part dragged back to zero has to be
/// RELEASED, and there is no per-bone clear. It is safe here precisely because nothing is read
/// afterwards.
///
/// ⚠️ SO THE MOVED PARTS ARE FROZEN WHILE THE TUNER IS OPEN. That is the trade: the panel
/// exists to judge a standing character's proportions, and a body animating under it would need
/// the animation's answer every frame — which is the read that cannot be had safely.
/// </summary>
void Apply( Rig g )
{
if ( !g.R.IsValid() ) return;
var sm = g.R.SceneModel;
if ( !sm.IsValid() ) return;
sm.ClearBoneOverrides();
// ⛔ SUMMED ACROSS GROUPS BEFORE ANYTHING IS WRITTEN. A finger is in `arms` and in `hands`;
// writing one override per group would leave it wherever the last group put it, so the
// hands knob would do nothing whenever the arms knob was also set.
var total = new Dictionary<int, float>();
foreach ( var grp in Groups )
{
var amount = ValueOf( grp.Name );
if ( amount == 0f ) continue;
foreach ( var i in g.Bones[grp.Name] )
total[i] = total.GetValueOrDefault( i ) + amount;
}
foreach ( var (index, amount) in total )
{
if ( !g.Rest.TryGetValue( index, out var rest ) )
{
// ⚠️ MODEL SPACE BOTH WAYS. The getter answers in WORLD and the setter takes "local
// coordinates based on the SceneModel's transform" — two spaces behind two names
// that do not say so. The body's own transform is what converts between them.
rest = sm.Transform.ToLocal( sm.GetBoneWorldTransform( index ) );
g.Rest[index] = rest;
}
var t = rest;
t.Position += new Vector3( 0f, 0f, -amount * g.Height );
// ⚠️ `in`, NOT `ref`. The parameter is `in`, so `ref` compiled and meant exactly the
// same thing — but it reads as "this call may write back to `t`", which it cannot.
sm.SetBoneOverride( index, in t );
}
}
/// <summary>Put every body back the way the animation left it.</summary>
void Clear()
{
foreach ( var g in _rigs.Where( g => g.R.IsValid() && g.R.SceneModel.IsValid() ) )
g.R.SceneModel.ClearBoneOverrides();
_rigs.Clear();
}
protected override void OnUpdate()
{
// ⛔ EVERY FRAME, NOT ONCE ON OPEN. Setting it in the command only works if nothing else
// touches the cursor afterwards, and leaving the lobby, entering creative or pressing L all
// set it back to `Auto` — which locks the pointer to the game and makes the card a picture
// of some sliders. Nothing writes `Auto` on a per-frame basis, so holding it here is enough
// and does not fight the lobby for it.
Mouse.Visibility = MouseVisibility.Visible;
Refresh();
foreach ( var g in _rigs )
Apply( g );
}
/// <summary>
/// Back to the model as it ships — every offset off.
///
/// ⚠️ THERE IS NO SECOND "BAKED" BUTTON ANY MORE, and there should not be: zero IS baked. The
/// two buttons used to mean different things and only one of them was true.
/// </summary>
void OnZero()
{
foreach ( var g in Groups )
SetValue( g.Name, 0f );
StateHasChanged();
}
/// <summary>
/// Write the values out, and say what they are.
///
/// ⛔ THEY ARE AN OFFSET, NOT A SPEC, and printing them as a bare `--drop` line invited exactly
/// the wrong thing: pasted over a character that already carries its own entry they would
/// DISCARD that entry rather than adjust it. The second line is the instruction.
/// </summary>
void OnPrint()
{
Log.Info( $"[nz-tune] offset from baked: {Flags}" );
Log.Info( "[nz-tune] ADD these to the character's TUNING entry in character_port.py"
+ " (or pass as --drop if it has none)" );
Log.Info( $"[nz-tune] driving {Driving} bod{(Driving == 1 ? "y" : "ies")}" );
}
void OnClose() => Toggle();
protected override void OnDestroy()
{
Clear();
}
/// <summary>
/// The line the panel shows and PRINT writes — one source for both.
///
/// ⚠️ ONLY THE PARTS THAT MOVED, because an untouched part is not an instruction and a line of
/// eight values, six of them zero, hides the two that matter.
/// </summary>
// ⚠️ `new` BECAUSE THIS HIDES `Component.Flags`, which is a `GameObjectFlags` and nothing to do
// with the diagnostic string below. Deliberate, and now stated.
public new string Flags
{
get
{
var set = Groups.Where( g => ValueOf( g.Name ) != 0f )
.Select( g => $"{g.Name}={ValueOf( g.Name ):0.###}" )
.ToList();
return set.Count == 0 ? "(as baked)" : string.Join( ",", set );
}
}
protected override int BuildHash()
=> System.HashCode.Combine(
System.HashCode.Combine( Head, Neck, Chest, Waist ),
System.HashCode.Combine( Arms, Hands, Hips, Legs ),
Driving );
}