Search the source of every open source package.
2690 results
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Twitch Poop" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "twitchpoop" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "garry" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "garry.twitchpoop" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "6/6/2026 7:39:31 PM" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "25" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.128.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.128.0")]using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Sandbox;
namespace SFXR;
[Title( "SFXR Component" )]
[Category( "SFXR" )]
[Icon( "volume_up" )]
public sealed class SFXRComponent : Component
{
/// <summary>
/// The base Waveform
/// (Default: Square)
/// </summary>
[Property, Group( "Sound" )]
public Waveform Waveform { get; set; } = Waveform.Square;
/// <summary>
/// The sample rate of the sound
/// </summary>
[Property, Group( "Sound" )]
public SampleRate SampleRate { get; set; } = SampleRate.Hz44100;
/// <summary>
/// The bit depth of the sound
/// </summary>
// [Property, Group( "Sound" )]
public BitDepth BitDepth { get; set; } = BitDepth.Bit16;
/// <summary>
/// The length of the sound in seconds
/// </summary>
[Property, Group( "Sound" ), Range( 0f, 20f, 0.01f )]
public float Length { get; set; } = 0.5f;
/// <summary>
/// The volume of the sound
/// (Default: 0.5)
/// </summary>
[Property, Group( "Sound" ), Range( 0f, 1f, 0.01f )]
public float MasterVolume { get; set; } = 0.5f;
[Property, Group( "Frequency" ), Range( 0, 3000f, 1f )]
float StartFrequency
{
get => Frequency.Start;
set => Frequency.Start = value;
}
[Property, Group( "Frequency" ), Range( -3000f, 3000f, 1f )]
float Slide
{
get => Frequency.Slide;
set => Frequency.Slide = value;
}
[Property, Group( "Frequency" ), Range( -3000f, 3000f, 1f )]
float SlideDelta
{
get => Frequency.DeltaSlide;
set => Frequency.DeltaSlide = value;
}
/// <summary>
/// The random seed
/// </summary>
[Property, Group( "Controls" )]
public long Seed { get; set; } = 0;
[Property, Group( "Controls" )]
public SFXRControls Controls { get; set; } = new SFXRControls();
public SFXRFrequency Frequency { get; set; } = new SFXRFrequency();
Random _random = new Random();
List<SFXRNote> NotesPlaying = new();
/// <summary>
/// Plays the sound defined by the component
/// </summary>
/// <returns>The sound handle of the sound. This can be used to change position, pitch, ect</returns>
public SoundHandle PlaySound()
{
var sfx = Generate( (int)(Length * (int)SampleRate) );
var handle = sfx.Play();
// DestroyStream(sfx, Length);
return handle;
}
/// <summary>
/// Plays the sound defined by the component (Via a frequency trigger. This will play indefinitely until released)
/// </summary>
/// <param name="frequency">The frequency of the sound</param>
/// <param name="volume">The volume of the trigger </param>
public void TriggerNotePress( float frequency, float volume = 1f )
{
foreach ( var note in NotesPlaying.Where( x => x.Frequency == frequency ) )
{
note.Release();
}
var newNote = new SFXRNote( this, frequency, volume );
newNote.Trigger();
NotesPlaying.Add( newNote );
}
/// <summary>
/// Releases a note playing at the given frequency
/// </summary>
/// <param name="frequency">The frequency of the sound</param>
public void TriggerNoteRelease( float frequency )
{
foreach ( var note in NotesPlaying.Where( x => x.Frequency == frequency ) )
{
note.Release();
}
}
/// <summary>
/// Releases all notes playing
/// </summary>
public void TriggerReleaseAll()
{
foreach ( var note in NotesPlaying )
{
note.Release();
}
}
/// <summary>
/// Generates a sound stream from the component
/// </summary>
/// <param name="sampleCount">How many samples the stream should be filled with</param>
/// <returns></returns>
public SoundStream Generate( int sampleCount )
{
List<SFXREffect> effects = new();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect || !effect.Enabled ) continue;
effects.Add( effect );
}
return Generate( sampleCount, effects );
}
/// <summary>
/// Generates a sound stream from the component with the given effects
/// </summary>
/// <param name="sampleCount">The number of samples</param>
/// <param name="effects">A list of the effects to apply</param>
/// <returns></returns>
public SoundStream Generate( int sampleCount, List<SFXREffect> effects )
{
short[] samples = new short[sampleCount];
float t = 0;
for ( int i = 0; i < sampleCount; i++ )
{
t += 1f / (int)SampleRate;
short sampleValue = SFXR.GetWaveformSample( Waveform, t, Frequency.GetFrequency( t ) );
sampleValue = (short)((float)sampleValue * MasterVolume);
samples[i] = sampleValue;
}
foreach ( var effect in effects )
{
if ( !effect.Enabled ) continue;
samples = effect.Apply( samples, this );
}
var stream = new SoundStream( (int)SampleRate );
stream.WriteData( samples );
return stream;
}
/// <summary>
/// Randomizes the component's parameters
/// </summary>
public void Randomize()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
var waveform = Waveform;
ResetParameters();
Waveform = waveform;
Frequency.Start = _random.Next( 10, 3000 );
if ( _random.Next( 2 ) == 0 ) Frequency.Slide = _random.Next( -3000, 3000 );
if ( Frequency.Start > 2000 && Frequency.Slide > 200 ) Frequency.Slide = -Frequency.Slide;
else if ( Frequency.Start < 400 && Frequency.Slide < -50 ) Frequency.Slide = -Frequency.Slide;
if ( _random.Next( 2 ) == 0 ) Frequency.DeltaSlide = _random.Next( -3000, 3000 );
SanitizeParameters();
}
/// <summary>
/// Mutates the component's parameters slightly
/// </summary>
public void Mutate( float mutation = 0.05f )
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
Frequency.Start += _random.Float( -mutation, mutation ) * 1000;
if ( Frequency.Start > 2000 && Frequency.Slide > 200 ) Frequency.Slide = -Frequency.Slide;
else if ( Frequency.Start < 400 && Frequency.Slide < -50 ) Frequency.Slide = -Frequency.Slide;
Frequency.Slide += _random.Float( -mutation, mutation ) * 1000;
Frequency.DeltaSlide += _random.Float( -mutation, mutation ) * 1000;
if ( Frequency.Slide < -3000 ) Frequency.Slide = -3000;
if ( Frequency.Slide > 3000 ) Frequency.Slide = 3000;
SanitizeParameters();
}
public void RandomizePickup()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
Waveform = (Waveform)_random.Int( 0, 2 );
Frequency.Start = _random.Float( 0.4f, 0.9f ) * 3000;
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Decay = _random.Float( 0.1f, 0.3f );
envelope.Sustain = _random.Float( 0f, 0.1f );
envelope.Release = _random.Float( 0.1f, 0.3f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
}
public void RandomizeLaser()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
var highpass = Components.GetOrCreate<SFXRHighPass>();
Waveform = (Waveform)_random.Int( 0, 2 );
if ( Waveform == Waveform.Sine && _random.Next( 2 ) == 0 ) Waveform = (Waveform)_random.Int( 0, 1 );
Frequency.Start = _random.Float( 0.6f, 0.75f ) * 3000;
Frequency.Slide = _random.Float( -0.25f, -0.15f ) * 3000;
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Decay = _random.Float( 0f, 0.4f );
envelope.Sustain = _random.Float( 0.1f, 0.3f );
envelope.Release = _random.Float( 0.25f, 0.3f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
if ( _random.Next( 2 ) == 0 )
{
highpass.Enabled = true;
highpass.Cutoff = _random.Float( 0f, 0.3f );
}
}
public void RandomizeExplosion()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
var vibrato = Components.GetOrCreate<SFXRVibrato>();
Waveform = Waveform.Noise;
if ( _random.Next( 2 ) == 0 )
{
Frequency.Start = _random.Float( 0.025f, 0.15f ) * 3000;
Frequency.Slide = _random.Float( -0.1f, -0.01f ) * 3000;
}
else
{
Frequency.Start = _random.Float( 0.1f, 0.2f ) * 3000;
Frequency.Slide = _random.Float( -0.6f, 0.6f ) * 3000;
}
if ( _random.Next( 4 ) == 0 ) Frequency.Slide = 0;
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Sustain = _random.Float( 0.1f, 0.4f );
envelope.Release = _random.Float( 0.1f, 0.3f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
if ( _random.Next( 2 ) == 0 )
{
vibrato.Enabled = true;
vibrato.Depth = _random.Float( 0f, 0.7f );
vibrato.Speed = _random.Float( 0f, 60f );
}
else
{
vibrato.Enabled = false;
}
if ( -Frequency.Slide > Frequency.Start )
{
Frequency.Slide = -Frequency.Start;
}
}
public void RandomizePowerup()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
var vibrato = Components.GetOrCreate<SFXRVibrato>();
if ( _random.Next( 2 ) == 0 )
{
Waveform = Waveform.Sawtooth;
}
if ( _random.Next( 2 ) == 0 )
{
Frequency.Start = _random.Float( 0.2f, 0.5f ) * 3000;
Frequency.Slide = _random.Float( 0.1f, 0.5f ) * 3000;
}
else
{
Frequency.Start = _random.Float( 0.25f, 0.5f ) * 3000;
Frequency.Slide = _random.Float( 0.05f, 0.25f ) * 3000;
if ( _random.Next( 2 ) == 0 )
{
vibrato.Enabled = true;
vibrato.Depth = _random.Float( 0, 0.7f );
vibrato.Speed = _random.Float( 0, 60f );
}
else
{
vibrato.Enabled = false;
}
}
if ( -Frequency.Slide > Frequency.Start )
{
Frequency.Slide = -Frequency.Start;
}
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Sustain = _random.Float( 0f, 0.4f );
envelope.Release = _random.Float( 0.1f, 0.5f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
}
public void RandomizeHit()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
var highpass = Components.GetOrCreate<SFXRHighPass>();
Waveform = (Waveform)_random.Int( 0, 3 );
if ( Waveform == Waveform.Sine )
{
Waveform = Waveform.Noise;
}
Frequency.Start = _random.Float( 0.1f, 0.5f ) * 3000;
Frequency.Slide = _random.Float( -0.7f, -0.3f ) * 3000;
if ( -Frequency.Slide > Frequency.Start )
{
Frequency.Slide = -Frequency.Start;
}
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Decay = 0;
envelope.Sustain = _random.Float( 0.025f, 0.1f );
envelope.Release = _random.Float( 0.1f, 0.3f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
if ( _random.Next( 2 ) == 0 )
{
highpass.Enabled = true;
highpass.Cutoff = _random.Float( 0f, 0.3f );
}
else
{
highpass.Enabled = false;
}
}
public void RandomizeJump()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
Waveform = Waveform.Square;
Frequency.Start = _random.Float( 0.3f, 0.6f ) * 3000;
Frequency.Slide = _random.Float( 0.1f, 0.3f ) * 3000;
if ( -Frequency.Slide > Frequency.Start )
{
Frequency.Slide = -Frequency.Start;
}
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Sustain = _random.Float( 0.1f, 0.4f );
envelope.Release = _random.Float( 0.1f, 0.3f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
}
public void RandomizeBlip()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
Waveform = Waveform.Square;
Frequency.Start = _random.Float( 0.2f, 0.6f ) * 3000;
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Decay = _random.Float( 0.1f, 0.2f );
envelope.Sustain = _random.Float( 0.025f, 0.1f );
envelope.Release = _random.Float( 0.1f, 0.3f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
}
public void ResetParameters()
{
Waveform = Waveform.Square;
SampleRate = SampleRate.Hz44100;
BitDepth = BitDepth.Bit16;
Length = 0.5f;
MasterVolume = 0.5f;
Frequency = new SFXRFrequency();
Controls = new SFXRControls();
}
void SanitizeParameters()
{
}
protected override void OnUpdate()
{
foreach ( var note in NotesPlaying )
{
note.Update();
// if (!note.IsPlaying)
// {
// note.DestroyStreams();
// }
}
NotesPlaying.RemoveAll( x => !x.IsPlaying );
}
}
using System;
using System.Collections.Generic;
using Sandbox;
namespace SFXR;
[Title( "ADSR Envelope" )]
[Category( "SFXR Effects" )]
[Icon( "mail_outline" )]
public class SFXREnvelope : SFXREffect
{
/// <summary>
/// Time the sound takes to reach its peak amplitude
/// (Default: 0)
/// </summary>
[Property, Range( 0, 10 )]
public float Attack { get; set; } = 0;
/// <summary>
/// The time taken for the sound to fade to the sustain level
/// </summary>
[Property, Range( 0, 10 )]
public float Decay { get; set; } = 0;
/// <summary>
/// The level maintained until release is triggered
/// (Default: 1)
/// </summary>
[Property, Range( 0, 1 )]
public float Sustain { get; set; } = 1f;
/// <summary>
/// The time taken for the sound to fade to zero after the sustain
/// (Default: 0.3)
/// </summary>
[Property, Range( 0, 10 )]
public float SustainTime { get; set; } = 0.3f;
/// <summary>
/// The time taken for the sound to fade to zero after the release
/// (Default: 0.4)
/// </summary>
[Property, Range( 0, 10 )]
public float Release { get; set; } = 0.4f;
/// <summary>
/// Returns the amplitude of the envelope at a given time
/// </summary>
/// <param name="time">Time in seconds</param>
/// <returns>Amplitude of the envelope at the given time</returns>
public float GetAmplitude( float time )
{
return GetCurve().Evaluate( time / GetLength() );
}
public override short[] Apply( short[] samples, SFXRComponent sound )
{
// Calculate the envelope amplitude for each sample
for ( int i = 0; i < samples.Length; i++ )
{
float t = i / (float)sound.SampleRate;
float amplitude = GetAmplitude( t );
samples[i] = (short)(samples[i] * amplitude);
}
return samples;
}
public float GetLength()
{
return Attack + Decay + SustainTime + Release;
}
public Curve GetCurve()
{
Curve curve = new();
List<Vector2> points = new();
// Add the attack curve
points.Add( new Vector2( 0, 0 ) );
points.Add( new Vector2( Attack, 1 ) );
// Add the decay curve
points.Add( new Vector2( Attack + Decay, Sustain ) );
// Add the sustain curve
points.Add( new Vector2( Attack + Decay + SustainTime, Sustain ) );
// Add the release curve
points.Add( new Vector2( Attack + Decay + SustainTime + Release, 0 ) );
// Normalize the curve to 0-1 in the x
for ( int i = 0; i < points.Count; i++ )
{
points[i] = new Vector2( points[i].x / (Attack + Decay + SustainTime + Release), points[i].y );
}
// Add the points to the curve
foreach ( var point in points )
{
curve.AddPoint( point.x, point.y );
}
return curve;
}
}using Sandbox;
public sealed class SceneTrigger : Component, Component.ITriggerListener
{
[Property] public SceneFile SceneFile { get; set; }
protected override void OnUpdate()
{
}
void ITriggerListener.OnTriggerEnter(Sandbox.Collider other)
{
if (other.GameObject.Parent.Tags.Has("player") || other.GameObject.Tags.Has("boat"))
{
Game.ActiveScene.Load(SceneFile);
}
}
void ITriggerListener.OnTriggerExit(Sandbox.Collider other)
{
}
}
using System.Collections.Generic;
namespace Sandbox;
/// <summary>
/// How to use the system:
/// <code>
/// public sealed class ExampleComponent : Component
/// {
/// // Reference to the system.
/// private FixedUpdateInputSystem _fixedInput;
///
/// protected override void Start()
/// {
/// // Get the reference like this:
/// _fixedInput = Scene.GetSystem<FixedUpdateInputSystem>();
///
/// base.OnStart();
/// }
///
/// protected override void OnFixedUpdate()
/// {
/// // Query for input like usual.
/// if( _fixedInput.Pressed("jump") )
/// {
/// Log.Info("Jumped");
/// }
///
/// base.OnFixedUpdate();
/// }
/// }
/// </code>
/// </summary>
public sealed class FixedUpdateInputSystem : GameObjectSystem
{
private struct FixedUpdateInputBuffer
{
private class State
{
public bool Held;
public bool Pressed;
public bool Released;
}
private Dictionary<string, State> _actionStates;
public FixedUpdateInputBuffer()
{
_actionStates = new Dictionary<string, State>();
foreach ( var b in Input.GetActions() )
{
_actionStates[b.Name.ToLowerInvariant()] = new State();
}
}
/// <summary>
/// Call from a <see cref="Component.OnUpdate"/> method
/// to update the states of the actions.
/// </summary>
public void OnUpdate()
{
foreach ( var (name, state) in _actionStates )
{
if ( Input.Down( name ) )
_actionStates[name].Held = true;
if ( Input.Pressed( name ) )
_actionStates[name].Pressed = true;
if ( Input.Released( name ) )
_actionStates[name].Released = true;
}
}
/// <summary>
/// Call from a <see cref="Component.OnFixedUpdate"/>
/// method to get the <see cref="State.Held"/> state of this action.
/// </summary>
/// <param name="action">The action name (case insensitive).</param>
/// <returns></returns>
///
public bool Held( string action )
{
return _actionStates[action.ToLowerInvariant()].Held;
}
/// <summary>
/// Call from a <see cref="Component.OnFixedUpdate"/>
/// method to get the <see cref="State.Pressed"/> state of this action.
/// </summary>
/// <param name="action">The action name (case insensitive).</param>
/// <returns></returns>
public bool Pressed( string action )
{
return _actionStates[action.ToLowerInvariant()].Pressed;
}
/// <summary>
/// Call from a <see cref="Component.OnFixedUpdate"/>
/// method to get the <see cref="State.Pressed"/> state of this action.
/// </summary>
/// <param name="action">The action name (case insensitive).</param>
/// <returns></returns>
public bool Released( string action )
{
return _actionStates[action.ToLowerInvariant()].Released;
}
/// <summary>
/// Call at the end of your <see cref="Component.OnFixedUpdate"/> method
/// to clear the state of the struct and reset.
/// </summary>
public void Clear()
{
foreach ( var actionName in _actionStates.Keys )
{
_actionStates[actionName].Held = false;
_actionStates[actionName].Pressed = false;
}
}
}
private FixedUpdateInputBuffer _buffer;
public FixedUpdateInputSystem( Scene scene ) : base( scene )
{
_buffer = new();
Listen( Stage.StartUpdate, int.MinValue, OnStartUpdate, "FUIB.OnStartUpdate" );
Listen( Stage.FinishFixedUpdate, int.MaxValue, OnFinishFixedUpdate, "FUIB.OnFinishFixedUpdate" );
}
private void OnStartUpdate()
{
_buffer.OnUpdate();
}
private void OnFinishFixedUpdate()
{
_buffer.Clear();
}
/// <summary>
/// Is the action currently held down?
/// </summary>
/// <param name="action">The action name (case insensitive).</param>
/// <returns></returns>
///
public bool Held( string action ) => _buffer.Held( action );
/// <summary>
/// Was the action pressed?
/// </summary>
/// <param name="action">The action name (case insensitive).</param>
/// <returns></returns>
public bool Pressed( string action ) => _buffer.Pressed( action );
/// <summary>
/// Was the action released?
/// </summary>
/// <param name="action">The action name (case insensitive).</param>
/// <returns></returns>
public bool Released( string action ) => _buffer.Released( action );
}
using System;
namespace Sandbox.Events;
/// <summary>
/// Only valid on <see cref="IGameEventHandler{T}.OnGameEvent"/> implementations. Forces this
/// event handler to be invoked before any handlers not marked as early, except if more specific
/// constraints are given (i.e., <see cref="BeforeAttribute{T}"/>, <see cref="AfterAttribute{T}"/>).
/// </summary>
[AttributeUsage( AttributeTargets.Method )]
public sealed class EarlyAttribute : Attribute
{
}
/// <summary>
/// Only valid on <see cref="IGameEventHandler{T}.OnGameEvent"/> implementations. Forces this
/// event handler to be invoked after any handlers not marked as late, except if more specific
/// constraints are given (i.e., <see cref="BeforeAttribute{T}"/>, <see cref="AfterAttribute{T}"/>).
/// </summary>
[AttributeUsage( AttributeTargets.Method )]
public sealed class LateAttribute : Attribute
{
}
internal interface IBeforeAttribute
{
Type Type { get; }
}
internal interface IAfterAttribute
{
Type Type { get; }
}
/// <summary>
/// Only valid on <see cref="IGameEventHandler{T}.OnGameEvent"/> implementations. Forces this
/// event handler to be invoked before any handlers in the specified type.
/// </summary>
[AttributeUsage( AttributeTargets.Method, AllowMultiple = true )]
public sealed class BeforeAttribute<T> : Attribute, IBeforeAttribute
{
Type IBeforeAttribute.Type => typeof(T);
}
/// <summary>
/// Only valid on <see cref="IGameEventHandler{T}.OnGameEvent"/> implementations. Forces this
/// event handler to be invoked after any handlers in the specified type.
/// </summary>
[AttributeUsage( AttributeTargets.Method, AllowMultiple = true )]
public sealed class AfterAttribute<T> : Attribute, IAfterAttribute
{
Type IAfterAttribute.Type => typeof( T );
}
using System.Collections.Generic;
using System.Linq;
namespace Sandbox.Events;
/// <summary>
/// Generate an ordering based on a set of first-most and last-most items, and
/// individual constraints between pairs of items. All first-most items will be
/// ordered before all last-most items, and any other items will be put in the
/// middle unless forced to be elsewhere by a constraint.
/// </summary>
internal class SortingHelper
{
public record struct SortConstraint( int EarlierIndex, int LaterIndex )
{
public SortConstraint Complement => new ( LaterIndex, EarlierIndex );
}
private readonly int _itemCount;
private readonly HashSet<SortConstraint> _initialConstraints = new HashSet<SortConstraint>();
private readonly HashSet<int> _first = new HashSet<int>();
private readonly HashSet<int> _last = new HashSet<int>();
public SortingHelper( int itemCount )
{
_itemCount = itemCount;
}
public void AddConstraint( int earlierIndex, int laterIndex )
{
_initialConstraints.Add( new SortConstraint( earlierIndex, laterIndex ) );
}
public void AddFirst( int earlierIndex )
{
_first.Add( earlierIndex );
}
public void AddLast( int laterIndex )
{
_last.Add( laterIndex );
}
public bool Sort( List<int> result, out SortConstraint invalidConstraint )
{
var middle = new HashSet<int>();
for ( var index = 0; index < _itemCount; ++index )
{
if ( !_first.Contains( index ) && !_last.Contains( index ) )
middle.Add( index );
}
var allConstraints = new HashSet<SortConstraint>();
var newConstraints = new Queue<SortConstraint>();
var beforeDict = new Dictionary<int, HashSet<int>>();
var afterDict = new Dictionary<int, HashSet<int>>();
bool AddWorkingConstraint( int earlierIndex, int laterIndex, out SortConstraint constraint )
{
constraint = new SortConstraint( earlierIndex, laterIndex );
if ( allConstraints.Contains( constraint.Complement ) )
return false;
if ( !allConstraints.Add( constraint ) )
return true;
newConstraints.Enqueue( constraint );
if ( !beforeDict.TryGetValue( earlierIndex, out var before ) )
beforeDict.Add( earlierIndex, before = new HashSet<int>() );
if ( !afterDict.TryGetValue( laterIndex, out var after ) )
afterDict.Add( laterIndex, after = new HashSet<int>() );
before.Add( laterIndex );
after.Add( earlierIndex );
return true;
}
// Add initial constraints
foreach ( var initialConstraint in _initialConstraints )
{
if ( !AddWorkingConstraint( initialConstraint.EarlierIndex, initialConstraint.LaterIndex, out invalidConstraint ) )
return false;
}
// Everything in _first should be before everything in _last
foreach ( var earlierIndex in _first )
{
foreach ( var laterIndex in _last )
{
if ( !AddWorkingConstraint( earlierIndex, laterIndex, out invalidConstraint ) )
return false;
}
}
// Keep propagating constraints until nothing changes
while ( newConstraints.TryDequeue( out var nextConstraint ) )
{
// if a < b, and b < c, then a < c etc
if ( beforeDict.TryGetValue( nextConstraint.LaterIndex, out var before ) )
{
foreach ( var laterIndex in before )
{
if ( !AddWorkingConstraint( nextConstraint.EarlierIndex, laterIndex, out invalidConstraint ) )
return false;
}
}
if ( afterDict.TryGetValue( nextConstraint.EarlierIndex, out var after ) )
{
foreach ( var earlierIndex in after )
{
if ( !AddWorkingConstraint( earlierIndex, nextConstraint.LaterIndex, out invalidConstraint ) )
{
return false;
}
}
}
}
// Now if we have any items that aren't using GroupOrder.First, and haven't
// determined that they are ordered before another item with GroupOrder.First,
// we can safely order them after all GroupOrder.First items. And vice versa.
foreach ( var middleIndex in middle )
{
var isBeforeAnyFirst = beforeDict.TryGetValue( middleIndex, out var before )
&& before.Any( x => _first.Contains( x ) );
var isAfterAnyLast = afterDict.TryGetValue( middleIndex, out var after )
&& after.Any( x => _last.Contains( x ) );
if ( !isBeforeAnyFirst )
{
foreach ( var earlierIndex in _first )
AddWorkingConstraint( earlierIndex, middleIndex, out invalidConstraint );
}
if ( !isAfterAnyLast )
{
foreach ( var laterIndex in _last )
AddWorkingConstraint( middleIndex, laterIndex, out invalidConstraint );
}
}
// Now lets add items to the final ordering if all items that should be sorted
// before them are already added to that ordering. We'll implement this by choosing
// items that have an empty list / don't appear in afterDict, and update that
// dictionary as we go.
var earliestRemaining = new Queue<int>();
// First, seed the queue with everything that's already not ordered after anything
for ( var index = 0; index < _itemCount; ++index )
{
if ( !afterDict.ContainsKey( index ) )
{
earliestRemaining.Enqueue( index );
}
}
result.Clear();
while ( earliestRemaining.TryDequeue( out var nextIndex ) )
{
result.Add( nextIndex );
foreach ( var laterIndex in beforeDict.TryGetValue( nextIndex, out var laterIndices )
? laterIndices : Enumerable.Empty<int>() )
{
var beforeLater = afterDict[laterIndex];
beforeLater.Remove( nextIndex );
if ( beforeLater.Count == 0 )
earliestRemaining.Enqueue( laterIndex );
}
}
invalidConstraint = default;
return result.Count == _itemCount;
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace Sandbox.Events;
/// <summary>
/// Interface for event payloads that can be listened for by <see cref="IGameEventHandler{T}"/>s.
/// </summary>
public interface IGameEvent { }
/// <summary>
/// Interface for components that handle game events with a payload of type <see cref="T"/>.
/// </summary>
/// <typeparam name="T">Event payload type.</typeparam>
public interface IGameEventHandler<in T>
where T : IGameEvent
{
/// <summary>
/// Called when an event with payload of type <see cref="T"/> is dispatched on a <see cref="GameObject"/>
/// that contains this component, including on a descendant.
/// </summary>
/// <param name="eventArgs">Event payload.</param>
void OnGameEvent( T eventArgs );
}
/// <summary>
/// Helper for dispatching game events in a scene.
/// </summary>
public static class GameEvent
{
private static Dictionary<Type, IReadOnlyDictionary<Type, int>> HandlerOrderingCache { get; } = new();
/// <summary>
/// Notifies all <see cref="IGameEventHandler{T}"/> components that are within <paramref name="root"/>,
/// with a payload of type <typeparamref name="T"/>.
/// </summary>
public static void Dispatch<T>( this GameObject root, T eventArgs )
where T : IGameEvent
{
var handlers = (root is Scene scene
? scene.GetAllComponents<IGameEventHandler<T>>() // I think this is more efficient?
: root.Components.GetAll<IGameEventHandler<T>>())
.ToArray();
if ( !HandlerOrderingCache.TryGetValue( typeof(T), out var ordering ) || handlers.Any( x => !ordering.ContainsKey( x.GetType() ) ) )
{
ordering = HandlerOrderingCache[typeof(T)] = GetHandlerOrdering<T>();
}
List<Exception>? exceptions = null;
foreach ( var handler in handlers.OrderBy( x => ordering[x.GetType()] ) )
{
try
{
handler.OnGameEvent( eventArgs );
}
catch ( Exception e )
{
exceptions ??= new();
exceptions.Add( e );
}
}
switch ( exceptions?.Count )
{
case 1:
Log.Error( exceptions[0] );
break;
case > 1:
Log.Error( new AggregateException( exceptions ) );
break;
}
}
private static bool IsImplementingMethodName( string methodName )
{
if ( methodName == nameof(IGameEventHandler<IGameEvent>.OnGameEvent) )
{
return true;
}
return methodName.StartsWith( "Sandbox.Events.IGameEventHandler<" ) && methodName.EndsWith( ">.OnGameEvent" );
}
private static MethodDescription? GetImplementation<T>( TypeDescription type )
{
foreach ( var method in type.Methods )
{
if ( method.IsStatic ) continue;
if ( method.Parameters.Length != 1 ) continue;
if ( method.Parameters[0].ParameterType != typeof( T ) ) continue;
if ( !IsImplementingMethodName( method.Name ) ) continue;
return method;
}
return null;
}
private static IReadOnlyDictionary<Type, int> GetHandlerOrdering<T>()
where T : IGameEvent
{
var types = TypeLibrary.GetTypes<IGameEventHandler<T>>().ToArray();
var helper = new SortingHelper( types.Length );
for ( var i = 0; i < types.Length; ++i )
{
var type = types[i];
var method = GetImplementation<T>( type );
if ( method is null )
{
Log.Warning( $"Can't find {nameof( IGameEventHandler<T> )}<{typeof( T ).Name}> implementation in {type.Name}!" );
continue;
}
foreach ( var attrib in method.Attributes )
{
switch ( attrib )
{
case EarlyAttribute:
helper.AddFirst( i );
break;
case LateAttribute:
helper.AddLast( i );
break;
case IBeforeAttribute before:
for ( var j = 0; j < types.Length; ++j )
{
if ( i == j ) continue;
var other = types[j];
if ( before.Type.IsAssignableFrom( other.TargetType ) )
{
helper.AddConstraint( i, j );
}
}
break;
case IAfterAttribute after:
for ( var j = 0; j < types.Length; ++j )
{
if ( i == j ) continue;
var other = types[j];
if ( after.Type.IsAssignableFrom( other.TargetType ) )
{
helper.AddConstraint( j, i );
}
}
break;
}
}
}
var ordering = new List<int>();
if ( !helper.Sort( ordering, out var invalid ) )
{
Log.Error( $"Invalid event ordering constraint between {types[invalid.EarlierIndex].Name} and {types[invalid.LaterIndex].Name}!" );
return ImmutableDictionary<Type, int>.Empty;
}
return Enumerable.Range( 0, ordering.Count )
.ToImmutableDictionary( i => types[ordering[i]].TargetType, i => i );
}
}
public delegate void GameEventAction<in T>( T eventArgs )
where T : IGameEvent;
/// <summary>
/// Base class for components that expose game events to Action Graph.
/// </summary>
public abstract class GameEventComponent<T> : Component, IGameEventHandler<T>
where T : IGameEvent
{
/// <summary>
/// Action invoked when the <typeparamref name="T"/> event is dispatched.
/// </summary>
[Property]
public GameEventAction<T>? OnEvent { get; set; }
/// <summary>
/// If this component is within a state machine, optional state to transition
/// to when this event is dispatched.
/// </summary>
[Property]
public StateComponent? NextState { get; set; }
void IGameEventHandler<T>.OnGameEvent( T eventArgs )
{
OnEvent?.Invoke( eventArgs );
if ( NextState is not null )
{
Components.GetInAncestorsOrSelf<StateMachineComponent>()?.Transition( NextState );
}
}
}
using System.Collections.Generic;
using System.Linq;
namespace Sandbox.Events;
/// <summary>
/// Generate an ordering based on a set of first-most and last-most items, and
/// individual constraints between pairs of items. All first-most items will be
/// ordered before all last-most items, and any other items will be put in the
/// middle unless forced to be elsewhere by a constraint.
/// </summary>
internal class SortingHelper
{
public record struct SortConstraint( int EarlierIndex, int LaterIndex )
{
public SortConstraint Complement => new ( LaterIndex, EarlierIndex );
}
private readonly int _itemCount;
private readonly HashSet<SortConstraint> _initialConstraints = new HashSet<SortConstraint>();
private readonly HashSet<int> _first = new HashSet<int>();
private readonly HashSet<int> _last = new HashSet<int>();
public SortingHelper( int itemCount )
{
_itemCount = itemCount;
}
public void AddConstraint( int earlierIndex, int laterIndex )
{
_initialConstraints.Add( new SortConstraint( earlierIndex, laterIndex ) );
}
public void AddFirst( int earlierIndex )
{
_first.Add( earlierIndex );
}
public void AddLast( int laterIndex )
{
_last.Add( laterIndex );
}
public bool Sort( List<int> result, out SortConstraint invalidConstraint )
{
var middle = new HashSet<int>();
for ( var index = 0; index < _itemCount; ++index )
{
if ( !_first.Contains( index ) && !_last.Contains( index ) )
middle.Add( index );
}
var allConstraints = new HashSet<SortConstraint>();
var newConstraints = new Queue<SortConstraint>();
var beforeDict = new Dictionary<int, HashSet<int>>();
var afterDict = new Dictionary<int, HashSet<int>>();
bool AddWorkingConstraint( int earlierIndex, int laterIndex, out SortConstraint constraint )
{
constraint = new SortConstraint( earlierIndex, laterIndex );
if ( allConstraints.Contains( constraint.Complement ) )
return false;
if ( !allConstraints.Add( constraint ) )
return true;
newConstraints.Enqueue( constraint );
if ( !beforeDict.TryGetValue( earlierIndex, out var before ) )
beforeDict.Add( earlierIndex, before = new HashSet<int>() );
if ( !afterDict.TryGetValue( laterIndex, out var after ) )
afterDict.Add( laterIndex, after = new HashSet<int>() );
before.Add( laterIndex );
after.Add( earlierIndex );
return true;
}
// Add initial constraints
foreach ( var initialConstraint in _initialConstraints )
{
if ( !AddWorkingConstraint( initialConstraint.EarlierIndex, initialConstraint.LaterIndex, out invalidConstraint ) )
return false;
}
// Everything in _first should be before everything in _last
foreach ( var earlierIndex in _first )
{
foreach ( var laterIndex in _last )
{
if ( !AddWorkingConstraint( earlierIndex, laterIndex, out invalidConstraint ) )
return false;
}
}
// Keep propagating constraints until nothing changes
while ( newConstraints.TryDequeue( out var nextConstraint ) )
{
// if a < b, and b < c, then a < c etc
if ( beforeDict.TryGetValue( nextConstraint.LaterIndex, out var before ) )
{
foreach ( var laterIndex in before )
{
if ( !AddWorkingConstraint( nextConstraint.EarlierIndex, laterIndex, out invalidConstraint ) )
return false;
}
}
if ( afterDict.TryGetValue( nextConstraint.EarlierIndex, out var after ) )
{
foreach ( var earlierIndex in after )
{
if ( !AddWorkingConstraint( earlierIndex, nextConstraint.LaterIndex, out invalidConstraint ) )
{
return false;
}
}
}
}
// Now if we have any items that aren't using GroupOrder.First, and haven't
// determined that they are ordered before another item with GroupOrder.First,
// we can safely order them after all GroupOrder.First items. And vice versa.
foreach ( var middleIndex in middle )
{
var isBeforeAnyFirst = beforeDict.TryGetValue( middleIndex, out var before )
&& before.Any( x => _first.Contains( x ) );
var isAfterAnyLast = afterDict.TryGetValue( middleIndex, out var after )
&& after.Any( x => _last.Contains( x ) );
if ( !isBeforeAnyFirst )
{
foreach ( var earlierIndex in _first )
AddWorkingConstraint( earlierIndex, middleIndex, out invalidConstraint );
}
if ( !isAfterAnyLast )
{
foreach ( var laterIndex in _last )
AddWorkingConstraint( middleIndex, laterIndex, out invalidConstraint );
}
}
// Now lets add items to the final ordering if all items that should be sorted
// before them are already added to that ordering. We'll implement this by choosing
// items that have an empty list / don't appear in afterDict, and update that
// dictionary as we go.
var earliestRemaining = new Queue<int>();
// First, seed the queue with everything that's already not ordered after anything
for ( var index = 0; index < _itemCount; ++index )
{
if ( !afterDict.ContainsKey( index ) )
{
earliestRemaining.Enqueue( index );
}
}
result.Clear();
while ( earliestRemaining.TryDequeue( out var nextIndex ) )
{
result.Add( nextIndex );
foreach ( var laterIndex in beforeDict.TryGetValue( nextIndex, out var laterIndices )
? laterIndices : Enumerable.Empty<int>() )
{
var beforeLater = afterDict[laterIndex];
beforeLater.Remove( nextIndex );
if ( beforeLater.Count == 0 )
earliestRemaining.Enqueue( laterIndex );
}
}
invalidConstraint = default;
return result.Count == _itemCount;
}
}
using Sandbox;
using System.Collections.Generic;
namespace EZCameraShake
{
public class CameraShaker : Component
{
/// <summary>
/// The single instance of the CameraShaker in the current scene. Do not use if you have multiple instances.
/// </summary>
public static CameraShaker Instance;
static Dictionary<string, CameraShaker> instanceList = new Dictionary<string, CameraShaker>();
/// <summary>
/// The default position influcence of all shakes created by this shaker.
/// </summary>
[Property] public Vector3 DefaultPosInfluence = new Vector3(0.15f, 0.15f, 0.15f);
/// <summary>
/// The default rotation influcence of all shakes created by this shaker.
/// </summary>
[Property] public Vector3 DefaultRotInfluence = new Vector3(1, 1, 1);
/// <summary>
/// Offset that will be applied to the camera's default (0,0,0) rest position
/// </summary>
[Property] public Vector3 RestPositionOffset = new Vector3(0, 0, 0);
/// <summary>
/// Offset that will be applied to the camera's default (0,0,0) rest rotation
/// </summary>
[Property] public Vector3 RestRotationOffset = new Vector3(0, 0, 0);
Vector3 posAddShake, rotAddShake;
List<CameraShakeInstance> cameraShakeInstances = new List<CameraShakeInstance>();
protected override void OnAwake()
{
Instance = this;
instanceList.Add(GameObject.Name, this);
}
protected override void OnUpdate()
{
posAddShake = Vector3.Zero;
rotAddShake = Vector3.Zero;
for (int i = 0; i < cameraShakeInstances.Count; i++)
{
if (i >= cameraShakeInstances.Count)
break;
CameraShakeInstance c = cameraShakeInstances[i];
if (c.CurrentState == CameraShakeState.Inactive && c.DeleteOnInactive)
{
cameraShakeInstances.RemoveAt(i);
i--;
}
else if (c.CurrentState != CameraShakeState.Inactive)
{
posAddShake += CameraUtilities.MultiplyVectors(c.UpdateShake(), c.PositionInfluence);
rotAddShake += CameraUtilities.MultiplyVectors(c.UpdateShake(), c.RotationInfluence);
}
}
Transform.LocalPosition = (posAddShake) + RestPositionOffset;
Vector3 thing = (rotAddShake / 100) + RestRotationOffset;
Transform.LocalRotation = new Angles(thing.x, thing.y, thing.z);
}
/// <summary>
/// Gets the CameraShaker with the given name, if it exists.
/// </summary>
/// <param name="name">The name of the camera shaker instance.</param>
/// <returns></returns>
public static CameraShaker GetInstance(string name)
{
CameraShaker c;
if (instanceList.TryGetValue(name, out c))
return c;
Log.Error("CameraShake " + name + " not found!");
return null;
}
/// <summary>
/// Starts a shake using the given preset.
/// </summary>
/// <param name="shake">The preset to use.</param>
/// <returns>A CameraShakeInstance that can be used to alter the shake's properties.</returns>
public CameraShakeInstance Shake(CameraShakeInstance shake)
{
cameraShakeInstances.Add(shake);
return shake;
}
/// <summary>
/// Shake the camera once, fading in and out over a specified durations.
/// </summary>
/// <param name="magnitude">The intensity of the shake.</param>
/// <param name="roughness">Roughness of the shake. Lower values are smoother, higher values are more jarring.</param>
/// <param name="fadeInTime">How long to fade in the shake, in seconds.</param>
/// <param name="fadeOutTime">How long to fade out the shake, in seconds.</param>
/// <returns>A CameraShakeInstance that can be used to alter the shake's properties.</returns>
public CameraShakeInstance ShakeOnce(float magnitude, float roughness, float fadeInTime, float fadeOutTime)
{
CameraShakeInstance shake = new CameraShakeInstance(magnitude, roughness, fadeInTime, fadeOutTime);
shake.PositionInfluence = DefaultPosInfluence;
shake.RotationInfluence = DefaultRotInfluence;
cameraShakeInstances.Add(shake);
return shake;
}
/// <summary>
/// Shake the camera once, fading in and out over a specified durations.
/// </summary>
/// <param name="magnitude">The intensity of the shake.</param>
/// <param name="roughness">Roughness of the shake. Lower values are smoother, higher values are more jarring.</param>
/// <param name="fadeInTime">How long to fade in the shake, in seconds.</param>
/// <param name="fadeOutTime">How long to fade out the shake, in seconds.</param>
/// <param name="posInfluence">How much this shake influences position.</param>
/// <param name="rotInfluence">How much this shake influences rotation.</param>
/// <returns>A CameraShakeInstance that can be used to alter the shake's properties.</returns>
public CameraShakeInstance ShakeOnce(float magnitude, float roughness, float fadeInTime, float fadeOutTime, Vector3 posInfluence, Vector3 rotInfluence)
{
CameraShakeInstance shake = new CameraShakeInstance(magnitude, roughness, fadeInTime, fadeOutTime);
shake.PositionInfluence = posInfluence;
shake.RotationInfluence = rotInfluence;
cameraShakeInstances.Add(shake);
return shake;
}
/// <summary>
/// Start shaking the camera.
/// </summary>
/// <param name="magnitude">The intensity of the shake.</param>
/// <param name="roughness">Roughness of the shake. Lower values are smoother, higher values are more jarring.</param>
/// <param name="fadeInTime">How long to fade in the shake, in seconds.</param>
/// <returns>A CameraShakeInstance that can be used to alter the shake's properties.</returns>
public CameraShakeInstance StartShake(float magnitude, float roughness, float fadeInTime)
{
CameraShakeInstance shake = new CameraShakeInstance(magnitude, roughness);
shake.PositionInfluence = DefaultPosInfluence;
shake.RotationInfluence = DefaultRotInfluence;
shake.StartFadeIn(fadeInTime);
cameraShakeInstances.Add(shake);
return shake;
}
/// <summary>
/// Start shaking the camera.
/// </summary>
/// <param name="magnitude">The intensity of the shake.</param>
/// <param name="roughness">Roughness of the shake. Lower values are smoother, higher values are more jarring.</param>
/// <param name="fadeInTime">How long to fade in the shake, in seconds.</param>
/// <param name="posInfluence">How much this shake influences position.</param>
/// <param name="rotInfluence">How much this shake influences rotation.</param>
/// <returns>A CameraShakeInstance that can be used to alter the shake's properties.</returns>
public CameraShakeInstance StartShake(float magnitude, float roughness, float fadeInTime, Vector3 posInfluence, Vector3 rotInfluence)
{
CameraShakeInstance shake = new CameraShakeInstance(magnitude, roughness);
shake.PositionInfluence = posInfluence;
shake.RotationInfluence = rotInfluence;
shake.StartFadeIn(fadeInTime);
cameraShakeInstances.Add(shake);
return shake;
}
/// <summary>
/// Gets a copy of the list of current camera shake instances.
/// </summary>
public List<CameraShakeInstance> ShakeInstances
{ get { return new List<CameraShakeInstance>(cameraShakeInstances); } }
protected override void OnDestroy()
{
instanceList.Remove(GameObject.Name);
}
}
}
public sealed class PlayerPusher : Component
{
[Property] public float Radius { get; set; } = 100;
protected override void DrawGizmos()
{
base.DrawGizmos();
Gizmo.Draw.LineSphere( Vector3.Zero, Radius );
}
public static Vector3 GetPushVector( in Vector3 position, Scene scene, GameObject ignore )
{
Vector3 vec = default;
foreach ( var pusher in scene.GetAllComponents<PlayerPusher>() )
{
if ( pusher.GameObject.IsAncestor( ignore ) )
continue;
pusher.Collect( position, ref vec );
}
return vec;
}
private void Collect( Vector3 position, ref Vector3 output )
{
var delta = (position - Transform.Position);
if ( delta.Length > Radius ) return;
delta.z = 0; // ignore z
var distanceDelta = (delta.Length / Radius);
output += delta.Normal * (1.0f - distanceDelta);
}
}
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using Sandbox;
using System.Threading.Tasks;
public sealed class WebSocketUtility : Component
{
[Property] public List<WebsocketTools> websocketToolsList { get; set; }
protected override void OnAwake()
{
foreach ( var websocketTools in websocketToolsList )
{
if ( websocketTools.url is null )
{
Log.Error( "WebsocketTools URL is null" );
return;
}
websocketTools.webSocket = new WebSocket();
ConnectToSocket( websocketTools.webSocket, websocketTools.url );
websocketTools.isConnected = true;
websocketTools.webSocket.OnMessageReceived += websocketTools.OnMessageReceivedMethod;
websocketTools.isSubscribed = true;
}
}
protected override void OnUpdate()
{
SendMessageFromList( WebsocketTools.Fetch.OnUpdate );
}
protected override void OnFixedUpdate()
{
SendMessageFromList( WebsocketTools.Fetch.OnFixedUpdate );
}
protected override void OnStart()
{
SendMessageFromList( WebsocketTools.Fetch.OnStart );
}
private async void SendMessageFromList( WebsocketTools.Fetch fetch )
{
foreach ( var websocketTools in websocketToolsList )
{
if ( websocketTools.fetch == fetch )
{
if ( websocketTools.message.UseJsonTags )
{
var jsonStrings = websocketTools.message.jsonTags.Select( tag => Json.Serialize( tag.ToString() ) );
var bigString = string.Join( "", jsonStrings );
var finalJsonString = Json.Serialize( bigString );
await websocketTools.webSocket.Send( finalJsonString );
}
else
{
var messageBytes = Encoding.UTF8.GetBytes( websocketTools.message.message );
await websocketTools.webSocket.Send( messageBytes );
}
}
}
}
[Description( "Sends a message over a websocket connection" )]
public static async Task SendAsync( WebsocketTools websocketTools )
{
if ( websocketTools.webSocket is null )
{
websocketTools.webSocket = new WebSocket();
}
if ( !websocketTools.isConnected )
{
await websocketTools.webSocket.Connect( websocketTools.url );
websocketTools.isConnected = true;
}
if ( websocketTools.message.UseJsonTags )
await websocketTools.webSocket.Send( Json.Serialize( websocketTools.message.jsonTags ) );
else
await websocketTools.webSocket.Send( websocketTools.message.message );
if ( !websocketTools.isSubscribed )
{
websocketTools.webSocket.OnMessageReceived += websocketTools.OnMessageReceivedMethod;
websocketTools.isSubscribed = true;
}
}
public static async Task SendStringAsync( string url, string message )
{
var webSocket = new WebSocket();
await webSocket.Connect( url );
await webSocket.Send( message );
}
public static void ChangeJsonTagValue( WebsocketMessage message, string tag, string value )
{
if ( message is null )
message = new WebsocketMessage();
if ( message.jsonTags is null )
message.jsonTags = new List<JsonTags>();
var jsonTag = message.jsonTags.Find( x => x.tag == tag );
if ( jsonTag is null )
{
Log.Warning( $"Tag {tag} not found in message" );
}
else
{
jsonTag.value = value;
}
}
public static void AddJsonTag( WebsocketMessage message, string tag, string value )
{
if ( message is null )
message = new WebsocketMessage();
if ( message.jsonTags is null )
message.jsonTags = new List<JsonTags>();
var jsonTag = new JsonTags
{
tag = tag,
value = value
};
message.jsonTags.Add( jsonTag );
}
private async void ConnectToSocket( WebSocket webSocket, string url )
{
await webSocket.Connect( url );
}
[ActionGraphNode( "new websocket tools" ), Pure]
public static WebsocketTools NewWebsocketTools()
{
return new WebsocketTools();
}
}
public class WebsocketTools
{
public delegate void OnMessageReceived( string message );
public OnMessageReceived onMessageReceived { get; set; }
public WebSocket webSocket { get; set; }
public string url { get; set; }
public WebsocketMessage message { get; set; } = new();
public bool isConnected { get; set; }
public bool isSubscribed { get; set; }
public string returnMessage { get; set; }
public enum Fetch
{
OnUpdate,
OnFixedUpdate,
OnStart,
}
public Fetch fetch { get; set; }
public void OnMessageReceivedMethod( string message )
{
onMessageReceived?.Invoke( message );
returnMessage = message;
}
public WebsocketTools()
{
url = "ws://localhost:8080";
fetch = Fetch.OnUpdate;
onMessageReceived = null;
message = null;
}
public WebsocketTools( string url, OnMessageReceived onMessageReceived, WebsocketMessage message, Fetch fetch = Fetch.OnUpdate )
{
this.url = url;
this.fetch = fetch;
this.onMessageReceived = onMessageReceived;
this.message = message;
}
}
[GameResource( "Message", "message", "A message to be sent over a websocket connection", Icon = "chat_bubble" )]
public class WebsocketMessage : GameResource
{
public bool UseJsonTags { get; set; }
[ShowIf( "UseJsonTags", false )] public string message { get; set; } = "";
[ShowIf( "UseJsonTags", true )] public List<JsonTags> jsonTags { get; set; } = new();
}
public class JsonTags
{
public string tag { get; set; }
public string value { get; set; }
}
public sealed class JiggleBone : TransformProxyComponent
{
JiggleBoneState state = new JiggleBoneState();
[Property]
public Vector3 StartPoint = new Vector3( 0, 0, 0 );
[Property]
public Vector3 EndPoint = new Vector3( 32, 0, 0 );
[Property, Range( 0, 2 )]
public float Speed { get; set; } = 1.0f;
[Property, Range( 0, 2 )]
public float Stiffness { get; set; } = 1.0f;
[Property, Range( 0, 2 )]
public float Damping { get; set; } = 1.0f;
[Property, Range( 0, 100 )]
public float Radius { get; set; } = 40.0f;
[Property, Range( 0, 100 )]
public float Mass { get; set; } = 1.0f;
Transform LocalJigglePosition;
protected override void OnEnabled()
{
LocalJigglePosition = Transform.Local;
base.OnEnabled();
state = new JiggleBoneState();
}
protected override void OnUpdate()
{
var oldPos = LocalJigglePosition;
using ( Transform.DisableProxy() )
{
var worldTx = Transform.World;
var startPoint = worldTx.PointToWorld( StartPoint );
var endPoint = worldTx.PointToWorld( EndPoint );
//Gizmo.Draw.LineSphere( startPoint, 1 );
//Gizmo.Draw.LineSphere( endPoint, 1 );
state.Extent = (endPoint - startPoint);
state.Stiffness = Stiffness;
state.Damping = Damping;
state.Radius = Radius;
state.Mass = Mass;
state.Update( startPoint, Time.Delta * Speed * 16.0f );
var tx = worldTx.RotateAround( startPoint, state.Rotation );
LocalJigglePosition = GameObject.Parent.Transform.World.ToLocal( tx );
}
if ( oldPos != LocalJigglePosition )
{
MarkTransformChanged();
}
}
protected override void DrawGizmos()
{
base.DrawGizmos();
if ( !Gizmo.IsSelected )
return;
using ( Transform.DisableProxy() )
{
Gizmo.Transform = Transform.World;
Gizmo.Draw.IgnoreDepth = false;
Gizmo.Draw.Color = Gizmo.Colors.Yaw.WithAlpha( 0.5f );
Gizmo.Draw.Line( StartPoint, EndPoint );
Gizmo.Draw.LineBBox( BBox.FromPositionAndSize( StartPoint, 5 ) );
Gizmo.Draw.LineBBox( BBox.FromPositionAndSize( EndPoint, 5 ) );
Gizmo.Draw.LineSphere( EndPoint, Radius * 2.0f, 4 );
}
}
public override Transform GetLocalTransform()
{
return LocalJigglePosition;
}
}
class JiggleBoneState
{
public Vector3 Extent = new Vector3( 32, 0, 0 );
public Vector3 Position { get; set; }
public Rotation Rotation { get; set; }
public float Stiffness { get; set; } = 1.0f;
public float Damping { get; set; } = 1.0f;
public float Radius { get; set; } = 10.0f;
public float Gravity { get; set; } = 1.0f;
public float Mass { get; set; } = 1.0f;
Vector3 basePosition;
Vector3 velocity;
public JiggleBoneState()
{
}
internal void Update( Vector3 position, float timeDelta )
{
basePosition = position + Extent;
// initialization
if ( Position == default )
{
Position = basePosition;
}
// Calculate spring force based on displacement from the cube
Vector3 displacement = Position - basePosition;
Vector3 springForce = -Stiffness * displacement;
// Calculate acceleration (Newton's second law)
Vector3 acceleration = springForce / Mass;
// Update velocity (integrate acceleration)
velocity += acceleration * timeDelta;
// Apply exponential damping
velocity *= (float)Math.Exp( -Damping * timeDelta );
// Update position (integrate velocity)
Position += velocity * timeDelta;
{
var diff = Position - basePosition;
var diffLen = diff.Length;
if ( diffLen > Radius )
{
Position = basePosition + diff.Normal * Radius;
//velocity = velocity.AddClamped( -diff * 2.0f, diff.Length );
}
}
// Store the rotation offset result
Rotation = Rotation.FromToRotation( basePosition - position, Position - position );
//Gizmo.Draw.IgnoreDepth = true;
//Gizmo.Draw.Line( position, Position );
//Gizmo.Draw.Line( basePosition, Position );
}
}
using Sandbox;
/// <summary>
/// This is a component - in your library!
/// </summary>
[Title( "LibraryImporter - My Component" )]
public class MyLibraryComponent : Component
{
}
using Sandbox;
public sealed class CameraMovement : Component
{
[Property] public CharacterController1 Player { get; set; }
[Property] public GameObject Body { get; set; }
[Property] public GameObject Head { get; set; }
[Property] public float Distance { get; set; } = 0f;
[Property] public float Sensitivity { get; set; } = 0.1f;
public bool IsFirstPerson => Distance == 0f;
private CameraComponent Camera;
private ModelRenderer BodyRenderer;
private Vector3 CurrentOffset = Vector3.Zero;
protected override void OnAwake()
{
base.OnAwake();
Camera = Components.Get<CameraComponent>();
BodyRenderer = Body.Components.Get<ModelRenderer>();
}
protected override void OnUpdate()
{
var eyeAngles = Head.Transform.Rotation.Angles();
eyeAngles.pitch += Input.MouseDelta.y * Sensitivity;
eyeAngles.yaw -= Input.MouseDelta.x * Sensitivity;
eyeAngles.roll = 0f;
eyeAngles.pitch = eyeAngles.pitch.Clamp( -89.9f, 89.9f );
Head.Transform.Rotation = eyeAngles.ToRotation();
var targetOffset = Vector3.Zero;
if ( Player.IsCrouching ) targetOffset += Vector3.Down * 35f;
CurrentOffset = Vector3.Lerp( CurrentOffset, targetOffset, Time.Delta * 10f );
if ( Camera is not null )
{
var camPos = Head.Transform.Position + CurrentOffset;
if ( !IsFirstPerson )
{
var camForward = eyeAngles.ToRotation().Forward;
var camTrace = Scene.Trace.Ray( camPos, camPos - (camForward * Distance) )
.WithoutTags( "player", "trigger" )
.Run();
if ( camTrace.Hit )
{
camPos = camTrace.HitPosition + camTrace.Normal;
}
else
{
camPos = camTrace.EndPosition;
}
BodyRenderer.RenderType = ModelRenderer.ShadowRenderType.On;
}
else
{
BodyRenderer.RenderType = ModelRenderer.ShadowRenderType.ShadowsOnly;
}
Log.Info( CurrentOffset );
Camera.Transform.Position = camPos;
Camera.Transform.Rotation = eyeAngles.ToRotation();
}
}
}
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
using System.Collections.Generic;
using Sandbox.Diagnostics;
namespace NPBehave
{
public class Parallel : Composite
{
public enum Policy
{
One,
All,
}
// public enum Wait
// {
// NEVER,
// ON_FAILURE,
// ON_SUCCESS,
// BOTH
// }
// private Wait waitForPendingChildrenRule;
private Policy _failurePolicy;
private Policy _successPolicy;
private int _childrenCount = 0;
private int _runningCount = 0;
private int _succeededCount = 0;
private int _failedCount = 0;
private Dictionary<Node, bool> _childrenResults;
private bool _successState;
private bool _childrenAborted;
public Parallel(Policy successPolicy, Policy failurePolicy, /*Wait waitForPendingChildrenRule,*/ params Node[] children) : base("Parallel", children)
{
_successPolicy = successPolicy;
_failurePolicy = failurePolicy;
// this.waitForPendingChildrenRule = waitForPendingChildrenRule;
_childrenCount = children.Length;
_childrenResults = new Dictionary<Node, bool>();
}
protected override void DoStart()
{
foreach (Node child in Children)
{
Assert.AreEqual(child.CurrentState, State.Inactive);
}
_childrenAborted = false;
_runningCount = 0;
_succeededCount = 0;
_failedCount = 0;
foreach (Node child in Children)
{
_runningCount++;
child.Start();
}
}
protected override void DoStop()
{
Assert.True(_runningCount + _succeededCount + _failedCount == _childrenCount);
foreach (Node child in Children)
{
if (child.IsActive)
{
child.Stop();
}
}
}
protected override void DoChildStopped(Node child, bool result)
{
_runningCount--;
if (result)
{
_succeededCount++;
}
else
{
_failedCount++;
}
_childrenResults[child] = result;
bool allChildrenStarted = _runningCount + _succeededCount + _failedCount == _childrenCount;
if (allChildrenStarted)
{
if (_runningCount == 0)
{
if (!_childrenAborted) // if children got aborted because rule was evaluated previously, we don't want to override the successState
{
if (_failurePolicy == Policy.One && _failedCount > 0)
{
_successState = false;
}
else if (_successPolicy == Policy.One && _succeededCount > 0)
{
_successState = true;
}
else if (_successPolicy == Policy.All && _succeededCount == _childrenCount)
{
_successState = true;
}
else
{
_successState = false;
}
}
Stopped(_successState);
}
else if (!_childrenAborted)
{
Assert.False(_succeededCount == _childrenCount);
Assert.False(_failedCount == _childrenCount);
if (_failurePolicy == Policy.One && _failedCount > 0/* && waitForPendingChildrenRule != Wait.ON_FAILURE && waitForPendingChildrenRule != Wait.BOTH*/)
{
_successState = false;
_childrenAborted = true;
}
else if (_successPolicy == Policy.One && _succeededCount > 0/* && waitForPendingChildrenRule != Wait.ON_SUCCESS && waitForPendingChildrenRule != Wait.BOTH*/)
{
_successState = true;
_childrenAborted = true;
}
if (_childrenAborted)
{
foreach (Node currentChild in Children)
{
if (currentChild.IsActive)
{
currentChild.Stop();
}
}
}
}
}
}
public override void StopLowerPriorityChildrenForChild(Node abortForChild, bool immediateRestart)
{
if (immediateRestart)
{
Assert.False(abortForChild.IsActive);
if (_childrenResults[abortForChild])
{
_succeededCount--;
}
else
{
_failedCount--;
}
_runningCount++;
abortForChild.Start();
}
else
{
throw new Exception("On Parallel Nodes all children have the same priority, thus the method does nothing if you pass false to 'immediateRestart'!");
}
}
}
}
using System.Collections;
using Sandbox.Diagnostics;
namespace NPBehave
{
public class RandomSequence : Composite
{
static System.Random _rng = new System.Random();
#if DEBUG
static public void DebugSetSeed( int seed )
{
_rng = new System.Random( seed );
}
#endif
private int _currentIndex = -1;
private int[] _randomizedOrder;
public RandomSequence(params Node[] children) : base("Random Sequence", children)
{
_randomizedOrder = new int[children.Length];
for (int i = 0; i < Children.Length; i++)
{
_randomizedOrder[i] = i;
}
}
protected override void DoStart()
{
foreach (Node child in Children)
{
Assert.AreEqual(child.CurrentState, State.Inactive);
}
_currentIndex = -1;
// Shuffling
int n = _randomizedOrder.Length;
while (n > 1)
{
int k = _rng.Next(n--);
(_randomizedOrder[n], _randomizedOrder[k]) = (_randomizedOrder[k], _randomizedOrder[n]);
}
ProcessChildren();
}
protected override void DoStop()
{
Children[_randomizedOrder[_currentIndex]].Stop();
}
protected override void DoChildStopped(Node child, bool result)
{
if (result)
{
ProcessChildren();
}
else
{
Stopped(false);
}
}
private void ProcessChildren()
{
if (++_currentIndex < Children.Length)
{
if (IsStopRequested)
{
Stopped(false);
}
else
{
Children[_randomizedOrder[_currentIndex]].Start();
}
}
else
{
Stopped(true);
}
}
public override void StopLowerPriorityChildrenForChild(Node abortForChild, bool immediateRestart)
{
int indexForChild = 0;
bool found = false;
foreach (Node currentChild in Children)
{
if (currentChild == abortForChild)
{
found = true;
}
else if (!found)
{
indexForChild++;
}
else if (found && currentChild.IsActive)
{
if (immediateRestart)
{
_currentIndex = indexForChild - 1;
}
else
{
_currentIndex = Children.Length;
}
currentChild.Stop();
break;
}
}
}
public override string ToString()
{
return $"{base.ToString()}[{_currentIndex}]";
}
}
}
namespace NPBehave
{
public class Succeeder : Decorator
{
public Succeeder(Node decoratee) : base("Succeeder", decoratee)
{
}
protected override void DoStart()
{
Decoratee.Start();
}
protected override void DoStop()
{
Decoratee.Stop();
}
protected override void DoChildStopped(Node child, bool result)
{
Stopped(true);
}
}
}using System;
namespace NPBehave
{
public class Exception : System.Exception
{
public Exception(string message) : base(message)
{
}
}
}namespace NPBehave
{
public class Repeater : Decorator
{
private int _loopCount = -1;
private int _currentLoop;
/// <param name="loopCount">number of times to execute the decoratee. Set to -1 to repeat forever, be careful with endless loops!</param>
/// <param name="decoratee">Decorated Node</param>
public Repeater(int loopCount, Node decoratee) : base("Repeater", decoratee)
{
_loopCount = loopCount;
}
/// <param name="decoratee">Decorated Node, repeated forever</param>
public Repeater(Node decoratee) : base("Repeater", decoratee)
{
}
protected override void DoStart()
{
if (_loopCount != 0)
{
_currentLoop = 0;
Decoratee.Start();
}
else
{
Stopped(true);
}
}
protected override void DoStop()
{
Clock.RemoveTimer(RestartDecoratee);
if (Decoratee.IsActive)
{
Decoratee.Stop();
}
else
{
Stopped(false);
}
}
protected override void DoChildStopped(Node child, bool result)
{
if (result)
{
if (IsStopRequested || (_loopCount > 0 && ++_currentLoop >= _loopCount))
{
Stopped(true);
}
else
{
Clock.AddTimer(0, 0, RestartDecoratee);
}
}
else
{
Stopped(false);
}
}
protected void RestartDecoratee()
{
Decoratee.Start();
}
}
}global using Sandbox;
global using System.Collections.Generic;
global using System.Linq;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
global using Sandbox;
global using System.Collections.Generic;
global using System.Linq;
using System.Threading.Tasks;
using System.Threading;
using System;
namespace Duccsoft;
/// <summary>
/// Provides a handy asynchronous wrapper for loading a VideoPlayer and waiting
/// until its video and audio are both loaded.
/// </summary>
public class AsyncVideoLoader
{
public AsyncVideoLoader()
{
_videoPlayer = new VideoPlayer();
}
public AsyncVideoLoader( VideoPlayer player )
{
_videoPlayer = player ?? new VideoPlayer();
}
public bool IsLoading { get; private set; }
private VideoPlayer _videoPlayer;
private Action _onLoaded;
private Action _onAudioReady;
public async Task<VideoPlayer> LoadFromUrl( string url, CancellationToken cancelToken = default )
{
void Play( VideoPlayer player ) => player.Play( url );
await Load( Play, cancelToken );
return _videoPlayer;
}
public async Task<VideoPlayer> LoadFromFile( BaseFileSystem fileSystem, string path, CancellationToken cancelToken )
{
void Play( VideoPlayer player ) => player.Play( fileSystem, path );
await Load( Play, cancelToken );
return _videoPlayer;
}
private async Task Load( Action<VideoPlayer> playAction, CancellationToken cancelToken = default )
{
// Attempting to play a video from a thread would throw an exception.
await GameTask.MainThread( cancelToken );
if ( IsLoading )
{
throw new InvalidOperationException( "Another video was already being loaded. Check IsLoading or create a new instance of AsyncVideoLoader." );
}
IsLoading = true;
bool videoLoaded = false;
bool audioLoaded = false;
// Assign private members instead of named methods to the invocation lists of the
// VideoPlayer delegates to break reference equality between runs.
_onLoaded = () => videoLoaded = true;
_onAudioReady = () => audioLoaded = true;
_videoPlayer.OnLoaded = _onLoaded;
_videoPlayer.OnAudioReady = _onAudioReady;
playAction?.Invoke( _videoPlayer );
// Non-blocking spin until video and audio are loaded.
while ( !videoLoaded || !audioLoaded )
{
// If OnLoaded or OnAudioReady are changed externally before we're finished
// loading, the video will likely never load. Abort to avoid spinning forever.
var callbacksChanged = _onLoaded != _videoPlayer.OnLoaded || _onAudioReady != _videoPlayer.OnAudioReady;
if ( callbacksChanged || cancelToken.IsCancellationRequested )
{
IsLoading = false;
return;
}
await GameTask.Yield();
}
IsLoading = false;
}
}
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
using System.Text.Json;
using SboxMcp.Server;
using Xunit;
namespace SboxMcp.Tests;
public class ProtocolTests
{
[Fact]
public void Parse_request_with_id_and_params()
{
var req = JsonRpcRequest.Parse( """{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"x"}}""" );
Assert.False( req.IsNotification );
Assert.Equal( 7, req.Id.Value.GetInt32() );
Assert.Equal( "tools/call", req.Method );
Assert.Equal( "x", req.Params.Value.GetProperty( "name" ).GetString() );
}
[Fact]
public void Parse_notification_has_no_id()
{
var req = JsonRpcRequest.Parse( """{"jsonrpc":"2.0","method":"notifications/initialized"}""" );
Assert.True( req.IsNotification );
Assert.Equal( "notifications/initialized", req.Method );
}
[Fact]
public void Parse_invalid_json_throws()
{
Assert.Throws<JsonRpcParseException>( () => JsonRpcRequest.Parse( "{nope" ) );
}
[Fact]
public void Parse_missing_method_throws()
{
Assert.Throws<JsonRpcParseException>( () => JsonRpcRequest.Parse( """{"jsonrpc":"2.0","id":1}""" ) );
}
[Fact]
public void Writer_result_emits_envelope()
{
var id = JsonDocument.Parse( "3" ).RootElement;
var json = JsonRpcWriter.Result( id, new { protocolVersion = "2025-06-18" } );
var doc = JsonDocument.Parse( json ).RootElement;
Assert.Equal( "2.0", doc.GetProperty( "jsonrpc" ).GetString() );
Assert.Equal( 3, doc.GetProperty( "id" ).GetInt32() );
Assert.Equal( "2025-06-18", doc.GetProperty( "result" ).GetProperty( "protocolVersion" ).GetString() );
}
[Fact]
public void Writer_error_emits_code_and_message()
{
var json = JsonRpcWriter.Error( null, JsonRpcError.MethodNotFound, "no such method" );
var doc = JsonDocument.Parse( json ).RootElement;
Assert.Equal( JsonValueKind.Null, JsonKind( doc, "id" ) );
Assert.Equal( -32601, doc.GetProperty( "error" ).GetProperty( "code" ).GetInt32() );
Assert.Equal( "no such method", doc.GetProperty( "error" ).GetProperty( "message" ).GetString() );
}
[Fact]
public void Records_serialize_camel_case()
{
var schema = JsonDocument.Parse( """{"type":"object"}""" ).RootElement;
var json = JsonRpcWriter.Result( null,
McpResults.ToolsList( new[] { new McpToolDescriptor( "a_tool", "does things", schema ) } ) );
var doc = JsonDocument.Parse( json ).RootElement;
var tool = doc.GetProperty( "result" ).GetProperty( "tools" )[0];
Assert.Equal( "a_tool", tool.GetProperty( "name" ).GetString() );
Assert.Equal( "does things", tool.GetProperty( "description" ).GetString() );
Assert.Equal( "object", tool.GetProperty( "inputSchema" ).GetProperty( "type" ).GetString() );
}
[Fact]
public void Version_negotiation()
{
// only 2025-06-18 is supported (older revisions require JSON-RPC batching)
Assert.Equal( "2025-06-18", McpVersion.Negotiate( "2025-06-18" ) );
Assert.Equal( "2025-06-18", McpVersion.Negotiate( "2025-03-26" ) );
Assert.Equal( "2025-06-18", McpVersion.Negotiate( null ) );
}
[Fact]
public void Null_id_is_rejected()
{
Assert.Throws<JsonRpcParseException>( () =>
JsonRpcRequest.Parse( """{"jsonrpc":"2.0","id":null,"method":"ping"}""" ) );
}
[Fact]
public void Text_content_shape()
{
var json = JsonRpcWriter.Result( null, McpResults.TextContent( "hello", isError: true ) );
var result = JsonDocument.Parse( json ).RootElement.GetProperty( "result" );
Assert.Equal( "text", result.GetProperty( "content" )[0].GetProperty( "type" ).GetString() );
Assert.Equal( "hello", result.GetProperty( "content" )[0].GetProperty( "text" ).GetString() );
Assert.True( result.GetProperty( "isError" ).GetBoolean() );
}
[Fact]
public void Image_content_shape()
{
var json = JsonRpcWriter.Result( null, McpResults.ImageContent( "QUJD", "a screenshot" ) );
var content = JsonDocument.Parse( json ).RootElement.GetProperty( "result" ).GetProperty( "content" );
Assert.Equal( "image", content[0].GetProperty( "type" ).GetString() );
Assert.Equal( "QUJD", content[0].GetProperty( "data" ).GetString() );
Assert.Equal( "image/png", content[0].GetProperty( "mimeType" ).GetString() );
Assert.Equal( "a screenshot", content[1].GetProperty( "text" ).GetString() );
}
static JsonValueKind JsonKind( JsonElement el, string prop ) =>
el.TryGetProperty( prop, out var v ) ? v.ValueKind : JsonValueKind.Undefined;
}
using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Maths;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;
namespace HumanoidRetargeter.Cleanup;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>Tunables for the grounded-foot stance recalibration pass.</summary>
public sealed class FootGroundAlignOptions
{
/// <summary>
/// Dead zone (degrees): measured stance offsets at or below this are genuine planted
/// articulation (heel-roll bias, natural lean — measured 2–4° on well-rested rigs and
/// on citizen clips) and are left untouched, keeping the transfer byte-faithful there.
/// Only offsets beyond it are clearly rest-pose artifacts (measured 12–25° on the
/// repro rig) and get recalibrated.
/// </summary>
public float MinCorrectionDeg { get; set; } = 8f;
/// <summary>
/// Maximum mean sole deviation (degrees) a plant may show and still count as a STANCE
/// for the offset measurement. Plants beyond this are not standing on the sole (crawls,
/// kneels, prone contact — measured 60–90° there) and are excluded; genuine rest-pose
/// stance artifacts measure well below it (largest seen: 27°).
/// </summary>
public float MaxStanceDeviationDeg { get; set; } = 35f;
}
/// <summary>Per-foot results of a <see cref="FootGroundAlign.Apply"/> run.</summary>
public sealed class FootGroundAlignFootReport
{
/// <summary>Plants that contributed to the stance measurement.</summary>
public int StancePlants { get; set; }
/// <summary>Plants excluded as non-stance (mean sole deviation beyond
/// <see cref="FootGroundAlignOptions.MaxStanceDeviationDeg"/>).</summary>
public int SkippedPlants { get; set; }
/// <summary>Measured planted sole offset from the ground plane, degrees (0 when no
/// stance plants exist).</summary>
public float MeasuredOffsetDeg { get; set; }
/// <summary>Foot correction applied to every frame, degrees (0 = inside the dead zone,
/// nothing changed).</summary>
public float AppliedFootDeg { get; set; }
/// <summary>Toe correction applied to every frame, degrees.</summary>
public float AppliedToeDeg { get; set; }
}
/// <summary>Results of a <see cref="FootGroundAlign.Apply"/> run.</summary>
public sealed class FootGroundAlignReport
{
/// <summary>Left-foot results.</summary>
public required FootGroundAlignFootReport Left { get; init; }
/// <summary>Right-foot results.</summary>
public required FootGroundAlignFootReport Right { get; init; }
}
/// <summary>
/// Grounded-foot stance recalibration: measures how far the foot's SOLE sits from the ground
/// plane while planted, and — when that offset is clearly a rest-pose artifact — rotates it
/// out with one constant per foot, applied to every frame of the clip.
/// </summary>
/// <remarks>
/// <para><b>Why a cleanup pass.</b> The solver transfers feet as rest-relative deltas
/// (<see cref="Solve.RoleTransferMode.CharacterDeltaFromRest"/>), so the target keeps its own
/// ankle anatomy — correct whenever the source's rest pose is a flat-footed stance (the delta
/// is then "deviation from standing"). Some rigs ship a NON-stance rest (measured: an
/// Auto-Rig-Pro export whose rest foot sits 12–25° from its planted stance), and that constant
/// offset rides into every frame of the replay — planted feet hover toe-down/heel-up. What a
/// stance actually looks like is animation evidence (planted phases), which a per-frame
/// solver cannot see, so the recalibration lives here.</para>
/// <para><b>Measurement.</b> Per foot: over every planted frame, the sole normal = rest up
/// carried by the foot's world delta from the target bind rest (whose feet stand on the
/// ground by construction); plants whose own mean normal sits beyond
/// <see cref="FootGroundAlignOptions.MaxStanceDeviationDeg"/> are excluded (crawl/kneel/prone
/// contact is not a stance). The pooled mean normal's deviation from up is the stance
/// offset.</para>
/// <para><b>Correction.</b> Offsets inside <see cref="FootGroundAlignOptions.MinCorrectionDeg"/>
/// are genuine articulation — nothing is changed (well-rested rigs and same-rig round trips
/// stay byte-identical through this pass). Beyond it, the shortest-arc rotation taking the
/// pooled normal back to up (pitch+roll only — yaw/toe-out is pose and follows the source)
/// premultiplies the foot's world rotation on EVERY frame: a rest artifact is constant, so
/// the fix is too — within-plant heel-roll, swing styling and frame-to-frame continuity are
/// preserved exactly, and no blending is needed. The toe then receives its own residual
/// constant measured on top of the corrected foot (it neither double-rotates with the foot
/// fix nor inherits the source toe's own rest artifact). Corrections rotate bones about
/// their own joints: ankle positions are untouched, so the pass composes freely with the
/// <see cref="FootPlant"/> position pinning (which preserves foot world rotations).</para>
/// <para><b>Plant intervals come from the caller</b> (the pipeline detects them on the
/// SOURCE clip via <see cref="FootPlant.DetectPlantIntervals"/> — ground truth, immune to
/// the hip-height rescaling that can push target-side trajectories outside the cm-tuned
/// Kovar thresholds). So does the decision to run at all: the pipeline invokes this pass
/// only when the source's normalized rest is implausible as a flat stance (toe at/above
/// ankle level or asymmetric feet — see <c>Retargeter.GroundAlignFeet</c>); on plausible
/// stance rests the solver's rest-relative transfer is already faithful and planted-sole
/// deviations are genuine articulation (boxing stances, heel rolls) that must not be
/// flattened.</para>
/// </remarks>
public static class FootGroundAlign
{
/// <summary>Measures planted stance offsets and recalibrates feet whose offset is a
/// rest-pose artifact; returns what was measured and done.</summary>
/// <param name="frames">Per-frame local transforms (skeleton bone order); modified in place.</param>
/// <param name="skeleton">Bone hierarchy the frames are expressed against; its bind rest
/// is the flat-stance reference.</param>
/// <param name="left">Left leg chain bone indices.</param>
/// <param name="right">Right leg chain bone indices.</param>
/// <param name="up">World up direction of the clip's space.</param>
/// <param name="leftPlants">Left-foot plant intervals (frame indices into
/// <paramref name="frames"/>; out-of-range parts are clamped/ignored).</param>
/// <param name="rightPlants">Right-foot plant intervals.</param>
/// <param name="options">Tunables; defaults used when null.</param>
public static FootGroundAlignReport Apply(
List<XForm[]> frames,
SkeletonModel skeleton,
FootChain left,
FootChain right,
Vector3 up,
IReadOnlyList<FrameRange> leftPlants,
IReadOnlyList<FrameRange> rightPlants,
FootGroundAlignOptions? options = null)
{
ArgumentNullException.ThrowIfNull(frames);
ArgumentNullException.ThrowIfNull(skeleton);
ArgumentNullException.ThrowIfNull(left);
ArgumentNullException.ThrowIfNull(right);
ArgumentNullException.ThrowIfNull(leftPlants);
ArgumentNullException.ThrowIfNull(rightPlants);
options ??= new FootGroundAlignOptions();
var report = new FootGroundAlignReport
{
Left = new FootGroundAlignFootReport(),
Right = new FootGroundAlignFootReport(),
};
if (frames.Count == 0 || up.LengthSquared() < 1e-12f)
return report;
up = Vector3.Normalize(up);
RecalibrateFoot(frames, skeleton, left, up, leftPlants, options, report.Left);
RecalibrateFoot(frames, skeleton, right, up, rightPlants, options, report.Right);
return report;
}
private static void RecalibrateFoot(
List<XForm[]> frames, SkeletonModel skeleton, FootChain chain, Vector3 up,
IReadOnlyList<FrameRange> plants, FootGroundAlignOptions options,
FootGroundAlignFootReport report)
{
int n = frames.Count;
var foot = chain.Ankle;
var restFootRotInv = Quaternion.Conjugate(skeleton.RestWorld[foot].Rot);
var maxStanceCos = MathF.Cos(options.MaxStanceDeviationDeg * MathF.PI / 180f);
// ---- measurement: pooled planted sole normal over the stance plants ----
var pooled = Vector3.Zero;
foreach (var plant in plants)
{
int start = Math.Max(plant.Start, 0);
int end = Math.Min(plant.End, n - 1);
if (start > end)
continue;
var plantSum = Vector3.Zero;
for (int f = start; f <= end; f++)
{
var footRot = FkUtil.BoneWorld(frames[f], skeleton, foot).Rot;
plantSum += Vector3.Transform(up, MathQ.Normalize(footRot * restFootRotInv));
}
if (plantSum.LengthSquared() < 1e-8f
|| Vector3.Dot(Vector3.Normalize(plantSum), up) < maxStanceCos)
{
report.SkippedPlants++; // not standing on the sole — crawl/kneel/toe contact
continue;
}
report.StancePlants++;
pooled += plantSum; // frame-count-weighted: longer stances dominate
}
if (pooled.LengthSquared() < 1e-8f)
return;
pooled = Vector3.Normalize(pooled);
var offsetDeg = MathQ.AngleBetween(pooled, up) * (180f / MathF.PI);
report.MeasuredOffsetDeg = offsetDeg;
if (offsetDeg <= options.MinCorrectionDeg)
return; // genuine planted articulation — leave the transfer byte-faithful
// ---- correction: one constant per foot, every frame ----
var footFix = MathQ.FromTo(pooled, up);
report.AppliedFootDeg = offsetDeg;
// Toe residual measured on top of the corrected foot, same dead zone.
var toeFix = Quaternion.Identity;
if (chain.Toe is { } toe && skeleton[toe].ParentIndex == foot)
{
var restToeRotInv = Quaternion.Conjugate(skeleton.RestWorld[toe].Rot);
var toePooled = Vector3.Zero;
foreach (var plant in plants)
{
int start = Math.Max(plant.Start, 0);
int end = Math.Min(plant.End, n - 1);
for (int f = start; f <= end && f >= 0; f++)
{
var toeRot = FkUtil.BoneWorld(frames[f], skeleton, toe).Rot;
toePooled += Vector3.Transform(
up, MathQ.Normalize(footFix * toeRot * restToeRotInv));
}
}
if (toePooled.LengthSquared() > 1e-8f)
{
toePooled = Vector3.Normalize(toePooled);
var toeDeg = MathQ.AngleBetween(toePooled, up) * (180f / MathF.PI);
if (toeDeg > options.MinCorrectionDeg && Vector3.Dot(toePooled, up) >= maxStanceCos)
{
toeFix = MathQ.FromTo(toePooled, up);
report.AppliedToeDeg = toeDeg;
}
}
}
for (int f = 0; f < n; f++)
CorrectFrame(frames[f], skeleton, chain, footFix, toeFix);
}
/// <summary>Premultiplies the foot's world rotation by the constant fix (the joint
/// position is untouched — the rotation pivots the foot about its own head), then gives
/// the toe its own residual on top of the corrected foot.</summary>
private static void CorrectFrame(
XForm[] locals, SkeletonModel skeleton, FootChain chain,
Quaternion footFix, Quaternion toeFix)
{
var foot = chain.Ankle;
var parent = skeleton[foot].ParentIndex;
var parentRot = parent < 0
? Quaternion.Identity
: FkUtil.BoneWorld(locals, skeleton, parent).Rot;
var footWorld = MathQ.Normalize(parentRot * locals[foot].Rot);
var newFootWorld = MathQ.Normalize(footFix * footWorld);
locals[foot] = new XForm(
locals[foot].Pos, MathQ.Normalize(Quaternion.Conjugate(parentRot) * newFootWorld));
if (chain.Toe is { } toe && skeleton[toe].ParentIndex == foot)
{
// Desired toe world = toeFix ∘ footFix ∘ original world; re-derive its local
// against the corrected foot so it does not double-rotate with the foot fix.
var toeWorldOld = MathQ.Normalize(footWorld * locals[toe].Rot);
var desired = MathQ.Normalize(toeFix * footFix * toeWorldOld);
locals[toe] = new XForm(
locals[toe].Pos, MathQ.Normalize(Quaternion.Conjugate(newFootWorld) * desired));
}
}
}
using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Maths;
using HumanoidRetargeter.Skeleton;
using HumanoidRetargeter.Solve;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;
namespace HumanoidRetargeter.Dl;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>The z-normalization statistics shipped with the SAME checkpoint
/// (<c>ms_dict</c>): per-feature mean/std applied to every input except contact.</summary>
public sealed class SameStats
{
internal float[] LoM, LoS, GoM, GoS, QM, QS, PM, PS, RM, RS, PvM, PvS, QvM, QvS, PprevM, PprevS;
/// <summary>Reads the 16 <c>ms.*</c> arrays from a parsed weight blob.</summary>
public SameStats(SameWeights weights)
{
ArgumentNullException.ThrowIfNull(weights);
LoM = weights.Stat("lo_m"); LoS = weights.Stat("lo_s");
GoM = weights.Stat("go_m"); GoS = weights.Stat("go_s");
QM = weights.Stat("q_m"); QS = weights.Stat("q_s");
PM = weights.Stat("p_m"); PS = weights.Stat("p_s");
RM = weights.Stat("r_m"); RS = weights.Stat("r_s");
PvM = weights.Stat("pv_m"); PvS = weights.Stat("pv_s");
QvM = weights.Stat("qv_m"); QvS = weights.Stat("qv_s");
PprevM = weights.Stat("pprev_m"); PprevS = weights.Stat("pprev_s");
}
}
/// <summary>A batched per-frame source graph ready for <see cref="SameModel.Encode"/>.</summary>
public sealed class SameSourceGraph
{
/// <summary>Normalized node features, flat [FrameCount·JointCount × 32].</summary>
public required float[] X { get; init; }
/// <summary>Edge sources (bidirectional + self-loops, all frames).</summary>
public required int[] EdgeSrc { get; init; }
/// <summary>Edge destinations.</summary>
public required int[] EdgeDst { get; init; }
/// <summary>Frame id per node.</summary>
public required int[] Batch { get; init; }
/// <summary>Number of feature frames (matches the clip's frame count in production
/// mode; native frames − 2 in golden-parity mode).</summary>
public required int FrameCount { get; init; }
/// <summary>Graph joints per frame (hips subtree + end joints).</summary>
public required int JointCount { get; init; }
/// <summary>Graph node names within one frame (bone names; synthesized leaf tips get
/// a <c>_end</c> suffix). For diagnostics and parity tests.</summary>
public required string[] JointNames { get; init; }
}
/// <summary>
/// Source-side feature pipeline of the SAME port (FEASIBILITY.md "C# port work list"
/// steps 1–5): skeleton normalization, cm/Y-up/+Z-facing alignment, per-frame
/// q/p/r/pv/qv/pprev/c features in the root-facing frame, z-normalization, and the
/// bidirectional+self-loop edge list.
/// </summary>
/// <remarks>
/// <para><b>Skeleton normalization without an intermediate skeleton.</b> SAME's
/// <c>motion_normalize</c> rebuilds the rig with identity rest-local rotations and
/// re-expresses every frame against it. Algebraically the normalized motion's world
/// rotations are exactly the world-space deltas from the T-pose,
/// <c>Ĝ(j,t) = G(j,t) · G_tpose(j)⁻¹</c>, its local rotations are
/// <c>Ĝ(parent)⁻¹ · Ĝ(j)</c>, and its world positions equal the original world positions
/// — so this port computes the features directly from FK world transforms, no rebuilt
/// skeleton needed (verified against the Python pipeline by the golden-vector tests).</para>
/// <para><b>T-pose reference.</b> SAME consumes the source clip's first frame as the
/// reference; production keeps that convention but emits one feature frame per clip frame
/// (the sequence is computed over [f0, f0…fN−1] with f0 doubling as the reference — see
/// <see cref="TposeReference"/> for why the rest-pose alternative measurably loses).
/// Golden-parity mode replicates Python's frame accounting exactly (frame 0 = reference,
/// frame 1 dropped).</para>
/// <para><b>Alignment.</b> Features assume cm (guaranteed by the importers), Y-up and
/// rest facing +Z with +X to the character's left. The source is rotated by a world
/// alignment derived from the rig's rest geometry (<see cref="CharacterFrame"/> via the
/// mapping when computable, else the file's axis metadata), snapped to the nearest whole
/// axis permutation (an exact-axis rig must map to the identity — the rest-geometry tilt
/// of a few degrees otherwise leaks into every feature), and shifted so the lowest joint
/// over the clip sits on the ground plane.</para>
/// <para><b>Graph.</b> Nodes are the hips subtree (hips = mapped Hips role, else the
/// shallowest branch bone) in skeleton order — hips is always node 0, which is where the
/// root feature row lives — plus one synthesized end joint per childless leaf (BVH End
/// Sites already import as <c>_end</c> bones and are used as-is; FBX leaves get a
/// half-length continuation of their parent segment).</para>
/// </remarks>
public static class SameFeatures
{
/// <summary>How the T-pose reference (skeleton normalization + lo/go features) is chosen.</summary>
public enum TposeReference
{
/// <summary>The clip's own first frame — SAME's native convention and the
/// production default. Empirically the pretrained checkpoint tracks arms FAR
/// better against the clip's first frame than against a synthesized true T-pose,
/// even though its training references are T-poses (measured on the fixture clip:
/// mean role cosine vs the geometric solver 0.94 first-frame vs 0.57 rest-pose,
/// hands flipping negative — reproduced identically in the Python reference
/// pipeline, so it is a property of the checkpoint, not of this port).</summary>
FirstFrame,
/// <summary>Synthesize the reference from the skeleton's rest pose (the
/// FEASIBILITY suggestion; kept for experiments — see above for why it lost).</summary>
RestPose,
}
/// <summary>Options for <see cref="BuildSourceGraph"/>; defaults are production mode.</summary>
public sealed class SourceOptions
{
/// <summary>T-pose reference choice (see <see cref="TposeReference"/>).</summary>
public TposeReference Reference { get; init; } = TposeReference.FirstFrame;
/// <summary>SAME's native frame accounting: the first frame is consumed as the
/// reference and the next dropped for its undefined velocity, so the output has
/// two frames fewer than the clip. Golden-parity tests only — production emits
/// one feature frame per clip frame (the first frame doubles as the reference
/// and gets zero velocity).</summary>
public bool NativeFrameDrop { get; init; }
/// <summary>Apply the rest-geometry world alignment (Y-up, +Z facing). Disabled
/// only by golden-parity tests (Python applies none).</summary>
public bool Align { get; init; } = true;
/// <summary>Ground both the T-pose reference and the animation: the T-pose is
/// shifted so its lowest joint sits at height 0 (a BVH rest pose has its root at
/// the origin and would otherwise put the hips on the floor), and the animation is
/// shifted by its own lowest joint height over the clip (no-op for the usual
/// authored-ground-at-0 data). Disabled only by golden-parity tests (the Python
/// reference consumes data as authored).</summary>
public bool GroundShift { get; init; } = true;
}
private const float ContactHeightCm = 5f;
private const float ContactSpeedMps = 0.4f;
private const float VelocityFps = 30f;
/// <summary>
/// Builds the batched source graph for one clip: graph selection, alignment, per-frame
/// features, normalization, edges.
/// </summary>
/// <param name="scene">Imported source (cm, native axes).</param>
/// <param name="clipIndex">Clip to encode.</param>
/// <param name="map">Source mapping; used only for hips identification and the
/// rest-geometry alignment (the model itself is skeleton-agnostic). May be sparse —
/// heuristics cover missing roles.</param>
/// <param name="stats">Normalization statistics.</param>
/// <param name="options">Null = production mode.</param>
public static SameSourceGraph BuildSourceGraph(
SourceScene scene, int clipIndex, MappingResult? map, SameStats stats, SourceOptions? options = null)
{
ArgumentNullException.ThrowIfNull(scene);
ArgumentNullException.ThrowIfNull(stats);
options ??= new SourceOptions();
if (clipIndex < 0 || clipIndex >= scene.Clips.Count)
throw new ArgumentOutOfRangeException(nameof(clipIndex));
var clip = scene.Clips[clipIndex];
if (clip.FrameCount < 1)
throw new ArgumentException("Clip has no frames.", nameof(clipIndex));
if (options.NativeFrameDrop && clip.FrameCount < 3)
throw new ArgumentException("Native frame accounting needs at least 3 frames.", nameof(options));
var skeleton = scene.Skeleton;
var hips = FindHips(skeleton, map);
var nodes = GraphNodes.Build(skeleton, hips);
var align = options.Align ? ComputeAlignment(skeleton, map, scene) : Quaternion.Identity;
// T-pose reference world transforms (aligned), grounded on its own lowest joint
// (a BVH rest pose has the root at the origin — ungrounded, its hips would sit on
// the floor and every height-bearing feature would be wrong).
var tposeLocals = options.Reference == TposeReference.RestPose
? Pose.Rest(skeleton).Locals
: clip.Frames[0];
var tposeWorld = AlignedWorld(skeleton, tposeLocals, align, nodes);
if (options.GroundShift)
ShiftToGround(tposeWorld.Pos);
// The pose sequence the features run over; features are emitted for seq[1..].
var seq = new List<XForm[]>();
if (options.NativeFrameDrop)
{
for (var f = 1; f < clip.FrameCount; f++)
seq.Add(clip.Frames[f]);
}
else
{
seq.Add(clip.Frames[0]); // duplicated: gives the real first frame zero velocity
for (var f = 0; f < clip.FrameCount; f++)
seq.Add(clip.Frames[f]);
}
var frames = seq.Count - 1;
var j = nodes.Count;
// Pass 0: aligned world transforms; ground the whole clip on its lowest joint.
var worlds = new AlignedFrame[seq.Count];
for (var t = 0; t < seq.Count; t++)
worlds[t] = AlignedWorld(skeleton, seq[t], align, nodes);
if (options.GroundShift)
{
var ground = float.PositiveInfinity;
foreach (var world in worlds)
{
foreach (var p in world.Pos)
ground = MathF.Min(ground, p.Y);
}
if (float.IsFinite(ground) && ground != 0f)
{
foreach (var world in worlds)
{
for (var i = 0; i < j; i++)
world.Pos[i].Y -= ground;
}
}
}
// Pass 1: normalized-skeleton local rotations + facing per frame.
var localRots = new Quaternion[seq.Count][]; // facing-adjusted at the root row
var facing = new (float Yaw, Vector3 Pos)[seq.Count];
for (var t = 0; t < seq.Count; t++)
{
var world = worlds[t];
// Normalized-skeleton world rotations: world delta from the T-pose.
var normWorld = new Quaternion[j];
for (var i = 0; i < j; i++)
normWorld[i] = MathQ.Normalize(world.Rot[i] * Quaternion.Conjugate(tposeWorld.Rot[i]));
// Root facing: yaw (about +Y) of the normalized root rotation, at the root's
// ground-plane position.
var yaw = YawAngle(normWorld[0]);
facing[t] = (yaw, new Vector3(world.Pos[0].X, 0f, world.Pos[0].Z));
// Normalized-skeleton local rotations; root premultiplied by the inverse facing.
var locals = new Quaternion[j];
locals[0] = MathQ.Normalize(Quaternion.CreateFromAxisAngle(Vector3.UnitY, -yaw) * normWorld[0]);
for (var i = 1; i < j; i++)
{
locals[i] = MathQ.Normalize(
Quaternion.Conjugate(normWorld[nodes.Parent[i]]) * normWorld[i]);
}
localRots[t] = locals;
}
// Pass 2: feature rows.
var x = new float[frames * j * SameModel.InputDim];
for (var t = 1; t < seq.Count; t++)
{
var f = t - 1;
var (yaw, fpos) = facing[t];
var invFacing = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -yaw);
var (yawPrev, fposPrev) = facing[t - 1];
var invFacingPrev = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -yawPrev);
// r: facing delta (dθ, dx, dz) + absolute root height.
var dTheta = WrapPi(yaw - yawPrev);
var dPlanar = Vector3.Transform(fpos - fposPrev, invFacingPrev);
var rootHeight = worlds[t].Pos[0].Y;
for (var i = 0; i < j; i++)
{
var row = (f * j + i) * SameModel.InputDim;
var col = 0;
// ---- skel: lo, go (tiled per frame) -------------------------------------
Vector3 lo, go;
if (i == 0)
{
lo = new Vector3(0f, tposeWorld.Pos[0].Y, 0f);
go = lo;
}
else
{
lo = tposeWorld.Pos[i] - tposeWorld.Pos[nodes.Parent[i]];
go = tposeWorld.Pos[i] - new Vector3(tposeWorld.Pos[0].X, 0f, tposeWorld.Pos[0].Z);
}
WriteNorm3(x, row, ref col, lo, stats.LoM, stats.LoS);
WriteNorm3(x, row, ref col, go, stats.GoM, stats.GoS);
// ---- q ------------------------------------------------------------------
WriteNorm6(x, row, ref col, SixD(localRots[t][i]), stats.QM, stats.QS);
// ---- p (facing-frame-relative global position) --------------------------
var p = Vector3.Transform(worlds[t].Pos[i] - fpos, invFacing);
WriteNorm3(x, row, ref col, p, stats.PM, stats.PS);
// ---- r (root row only; other rows are the mean → zeros after norm) ------
if (i == 0)
{
x[row + col++] = (dTheta - stats.RM[0]) / stats.RS[0];
x[row + col++] = (dPlanar.X - stats.RM[1]) / stats.RS[1];
x[row + col++] = (dPlanar.Z - stats.RM[2]) / stats.RS[2];
x[row + col++] = (rootHeight - stats.RM[3]) / stats.RS[3];
}
else
{
col += 4; // already zero
}
// ---- pv (facing-frame velocity, ×30 fps) ---------------------------------
var pv = Vector3.Transform(worlds[t].Pos[i] - worlds[t - 1].Pos[i], invFacing) * VelocityFps;
WriteNorm3(x, row, ref col, pv, stats.PvM, stats.PvS);
// ---- qv (local rotation delta) -------------------------------------------
var qv = MathQ.Normalize(Quaternion.Conjugate(localRots[t - 1][i]) * localRots[t][i]);
WriteNorm6(x, row, ref col, SixD(qv), stats.QvM, stats.QvS);
// ---- pprev (previous position in the CURRENT facing frame) ---------------
var pprev = Vector3.Transform(worlds[t - 1].Pos[i] - fpos, invFacing);
WriteNorm3(x, row, ref col, pprev, stats.PprevM, stats.PprevS);
// ---- c (ground contact; not normalized) -----------------------------------
var speedMps = (worlds[t].Pos[i] - worlds[t - 1].Pos[i]).Length() * VelocityFps / 100f;
x[row + col] = worlds[t].Pos[i].Y < ContactHeightCm && speedMps < ContactSpeedMps ? 1f : 0f;
}
}
var (edgeSrc, edgeDst) = BuildEdges(nodes.Parent, frames);
var batch = new int[frames * j];
for (var f = 0; f < frames; f++)
{
for (var i = 0; i < j; i++)
batch[f * j + i] = f;
}
AssertFinite(x, "SAME source features");
return new SameSourceGraph
{
X = x,
EdgeSrc = edgeSrc,
EdgeDst = edgeDst,
Batch = batch,
FrameCount = frames,
JointCount = j,
JointNames = nodes.Names,
};
}
// ================================================================ graph topology
/// <summary>The per-frame graph node set: hips-subtree bones in skeleton order
/// (hips first) plus synthesized end joints for childless leaves.</summary>
internal sealed class GraphNodes
{
/// <summary>Skeleton bone index per node; -1 for synthesized end joints.</summary>
public required int[] Bone { get; init; }
/// <summary>Graph-parent node index; -1 for the root (node 0).</summary>
public required int[] Parent { get; init; }
/// <summary>For synthesized end joints: the rest-local offset from the leaf bone
/// (zero vector for real bones).</summary>
public required Vector3[] EndOffset { get; init; }
public required string[] Names { get; init; }
public int Count => Bone.Length;
public static GraphNodes Build(SkeletonModel skeleton, int hips)
{
// Hips subtree, skeleton order (parents precede children, hips first).
var inSubtree = new bool[skeleton.Count];
inSubtree[hips] = true;
var bones = new List<int> { hips };
for (var i = hips + 1; i < skeleton.Count; i++)
{
var parent = skeleton[i].ParentIndex;
if (parent >= 0 && inSubtree[parent])
{
inSubtree[i] = true;
bones.Add(i);
}
}
var nodeOfBone = new Dictionary<int, int>(bones.Count);
for (var n = 0; n < bones.Count; n++)
nodeOfBone[bones[n]] = n;
var hasChild = new bool[skeleton.Count];
foreach (var b in bones)
{
var parent = skeleton[b].ParentIndex;
if (parent >= 0 && inSubtree[parent])
hasChild[parent] = true;
}
var bone = new List<int>(bones);
var parentNode = new List<int>(bones.Count);
var endOffset = new List<Vector3>(bones.Count);
var names = new List<string>(bones.Count);
foreach (var b in bones)
{
var p = skeleton[b].ParentIndex;
parentNode.Add(b == hips ? -1 : nodeOfBone[p]);
endOffset.Add(Vector3.Zero);
names.Add(skeleton[b].Name);
}
// Synthesized end joints: leaves with no children anywhere in the skeleton.
// BVH End Sites already import as real `_end`/`_End` bones and ARE the end
// joints — no tip on a tip. The tip continues the parent→leaf segment at half
// length — a neutral stand-in for the unknown bone tail (FBX carries none).
foreach (var b in bones)
{
if (hasChild[b]
|| skeleton[b].Name.EndsWith("_end", StringComparison.OrdinalIgnoreCase))
continue;
var p = skeleton[b].ParentIndex;
var segment = p >= 0
? skeleton.RestWorld[b].Pos - skeleton.RestWorld[p].Pos
: Vector3.Zero;
var tip = segment.Length() > 1e-4f ? segment * 0.5f : new Vector3(0f, 2f, 0f);
// Express in the leaf's rest-local frame (applied via the leaf's world rot).
var local = Vector3.Transform(tip, Quaternion.Conjugate(skeleton.RestWorld[b].Rot));
bone.Add(-1);
parentNode.Add(nodeOfBone[b]);
endOffset.Add(local);
names.Add(skeleton[b].Name + "_end");
}
return new GraphNodes
{
Bone = bone.ToArray(),
Parent = parentNode.ToArray(),
EndOffset = endOffset.ToArray(),
Names = names.ToArray(),
};
}
}
/// <summary>Aligned world transforms of the graph nodes for one pose.</summary>
internal readonly struct AlignedFrame
{
public required Vector3[] Pos { get; init; }
public required Quaternion[] Rot { get; init; }
}
private static AlignedFrame AlignedWorld(
SkeletonModel skeleton, XForm[] locals, Quaternion align, GraphNodes nodes)
{
var world = new Pose(locals).ToWorld(skeleton);
var pos = new Vector3[nodes.Count];
var rot = new Quaternion[nodes.Count];
for (var n = 0; n < nodes.Count; n++)
{
XForm w;
if (nodes.Bone[n] >= 0)
{
w = world[nodes.Bone[n]];
}
else
{
// Synthesized end joint: rides its leaf bone (identity local rotation).
var leaf = world[nodes.Bone[nodes.Parent[n]]];
w = new XForm(leaf.TransformPoint(nodes.EndOffset[n]), leaf.Rot);
}
pos[n] = Vector3.Transform(w.Pos, align);
rot[n] = MathQ.Normalize(align * w.Rot);
}
return new AlignedFrame { Pos = pos, Rot = rot };
}
/// <summary>Bidirectional parent↔child pairs plus one self-loop per node, replicated
/// per frame with node indices offset.</summary>
internal static (int[] Src, int[] Dst) BuildEdges(int[] parent, int frames)
{
var j = parent.Length;
var nonRoot = 0;
for (var i = 0; i < j; i++)
{
if (parent[i] >= 0)
nonRoot++;
}
var perFrame = nonRoot * 2 + j;
var src = new int[perFrame * frames];
var dst = new int[perFrame * frames];
var e = 0;
for (var f = 0; f < frames; f++)
{
var offset = f * j;
for (var i = 0; i < j; i++)
{
if (parent[i] < 0)
continue;
src[e] = offset + parent[i];
dst[e] = offset + i;
e++;
src[e] = offset + i;
dst[e] = offset + parent[i];
e++;
}
for (var i = 0; i < j; i++)
{
src[e] = offset + i;
dst[e] = offset + i;
e++;
}
}
return (src, dst);
}
// ================================================================ alignment + hips
/// <summary>Mapped Hips role when available, else the shallowest bone with two or more
/// children (the hips of any humanoid: the legs/spine branch point).</summary>
internal static int FindHips(SkeletonModel skeleton, MappingResult? map)
{
if (map is not null && map.RoleToBone.TryGetValue(BoneRole.Hips, out var mapped)
&& mapped >= 0 && mapped < skeleton.Count)
return mapped;
var childCount = new int[skeleton.Count];
for (var i = 0; i < skeleton.Count; i++)
{
if (skeleton[i].ParentIndex >= 0)
childCount[skeleton[i].ParentIndex]++;
}
var best = -1;
var bestDepth = int.MaxValue;
for (var i = 0; i < skeleton.Count; i++)
{
if (childCount[i] < 2)
continue;
var depth = 0;
for (var a = skeleton[i].ParentIndex; a >= 0; a = skeleton[a].ParentIndex)
depth++;
if (depth < bestDepth)
{
best = i;
bestDepth = depth;
}
}
return best >= 0 ? best : 0;
}
/// <summary>
/// World rotation taking the rig into the canonical SAME frame (X = character left,
/// Y = up, Z = facing): rest-geometry character frame when computable from the mapping,
/// else the file's recorded axis conventions.
/// </summary>
internal static Quaternion ComputeAlignment(SkeletonModel skeleton, MappingResult? map, SourceScene? scene)
{
if (map is not null)
{
try
{
var frame = CharacterFrame.Compute(skeleton, map, skeleton.RestWorld);
return AlignFromBasis(frame.Lateral, frame.Up, frame.Forward);
}
catch (ArgumentException)
{
// fall through to axis metadata
}
}
if (scene is not null)
{
var up = AxisVector(scene.UpAxis, scene.UpAxisSign);
var forward = AxisVector(scene.FrontAxis, scene.FrontAxisSign);
if (MathF.Abs(Vector3.Dot(up, forward)) < 0.5f)
return AlignFromBasis(Vector3.Cross(up, forward), up, forward);
}
return Quaternion.Identity;
}
/// <summary>
/// Rotation mapping the given (left, up, forward) world directions onto (+X, +Y, +Z),
/// snapped to the nearest whole axis permutation when one is unambiguous: rigs authored
/// on exact axes (BVH Y-up/+Z, the s&box rig, Z-up FBX) must map by an exact
/// quarter-turn — the few degrees of rest-geometry tilt (shoulders not exactly above
/// hips) otherwise leak into every feature and measurably cost accuracy.
/// </summary>
internal static Quaternion AlignFromBasis(Vector3 left, Vector3 up, Vector3 forward)
{
var l = SnapAxis(left);
var u = SnapAxis(up);
var f = SnapAxis(forward);
if (MathF.Abs(Vector3.Dot(l, u)) > 0.5f || MathF.Abs(Vector3.Dot(l, f)) > 0.5f
|| MathF.Abs(Vector3.Dot(u, f)) > 0.5f)
{
// Genuinely oblique rig: keep the exact (orthonormalized) directions.
l = Vector3.Normalize(left);
u = Vector3.Normalize(up - l * Vector3.Dot(up, l));
f = Vector3.Cross(l, u);
}
// Row-major with rows = basis images maps +X→left, +Y→up, +Z→forward
// (System.Numerics row-vector convention); the alignment is its inverse.
var m = new Matrix4x4(
l.X, l.Y, l.Z, 0f,
u.X, u.Y, u.Z, 0f,
f.X, f.Y, f.Z, 0f,
0f, 0f, 0f, 1f);
return Quaternion.Conjugate(MathQ.Normalize(Quaternion.CreateFromRotationMatrix(m)));
}
private static Vector3 SnapAxis(Vector3 v)
{
var ax = MathF.Abs(v.X);
var ay = MathF.Abs(v.Y);
var az = MathF.Abs(v.Z);
if (ax >= ay && ax >= az)
return new Vector3(MathF.Sign(v.X), 0f, 0f);
if (ay >= az)
return new Vector3(0f, MathF.Sign(v.Y), 0f);
return new Vector3(0f, 0f, MathF.Sign(v.Z));
}
private static Vector3 AxisVector(int axis, int sign) => axis switch
{
0 => new Vector3(sign, 0f, 0f),
2 => new Vector3(0f, 0f, sign),
_ => new Vector3(0f, sign, 0f),
};
// ================================================================ small math
/// <summary>The yaw (rotation about +Y) closest to <paramref name="q"/> — fairmotion's
/// <c>Q_closest(q, identity, +Y)</c>, reproduced exactly for parity.</summary>
internal static float YawAngle(Quaternion q)
{
var alpha = Math.Atan2(q.W, q.Y);
var theta1 = -2.0 * alpha + Math.PI;
var theta2 = -2.0 * alpha - Math.PI;
var d1 = q.Y * Math.Sin(theta1 * 0.5) + q.W * Math.Cos(theta1 * 0.5);
var d2 = q.Y * Math.Sin(theta2 * 0.5) + q.W * Math.Cos(theta2 * 0.5);
return (float)(d1 > d2 ? theta1 : theta2);
}
private static void ShiftToGround(Vector3[] positions)
{
var ground = float.PositiveInfinity;
foreach (var p in positions)
ground = MathF.Min(ground, p.Y);
if (!float.IsFinite(ground) || ground == 0f)
return;
for (var i = 0; i < positions.Length; i++)
positions[i].Y -= ground;
}
internal static float WrapPi(float angle)
{
while (angle > MathF.PI)
angle -= 2f * MathF.PI;
while (angle < -MathF.PI)
angle += 2f * MathF.PI;
return angle;
}
/// <summary>6D rotation representation: the first two columns of the rotation matrix
/// (<c>R·e_x</c> then <c>R·e_y</c>).</summary>
internal static (Vector3 C0, Vector3 C1) SixD(Quaternion q)
=> (Vector3.Transform(Vector3.UnitX, q), Vector3.Transform(Vector3.UnitY, q));
private static void WriteNorm3(float[] x, int row, ref int col, Vector3 v, float[] m, float[] s)
{
x[row + col++] = (v.X - m[0]) / s[0];
x[row + col++] = (v.Y - m[1]) / s[1];
x[row + col++] = (v.Z - m[2]) / s[2];
}
private static void WriteNorm6(float[] x, int row, ref int col, (Vector3 C0, Vector3 C1) sixD, float[] m, float[] s)
{
x[row + col++] = (sixD.C0.X - m[0]) / s[0];
x[row + col++] = (sixD.C0.Y - m[1]) / s[1];
x[row + col++] = (sixD.C0.Z - m[2]) / s[2];
x[row + col++] = (sixD.C1.X - m[3]) / s[3];
x[row + col++] = (sixD.C1.Y - m[4]) / s[4];
x[row + col++] = (sixD.C1.Z - m[5]) / s[5];
}
internal static void AssertFinite(float[] values, string what)
{
foreach (var v in values)
{
if (!float.IsFinite(v))
throw new InvalidOperationException($"{what} contain non-finite values.");
}
}
}
using System.Collections.Generic;
namespace HumanoidRetargeter.Mapping;
/// <summary>
/// Built-in preset profiles, embedded as C# data (the same data is written to
/// <c>Assets/humanoid_retargeter/profiles/*.json</c> by a regenerate-and-diff test so the
/// shipped JSON can never drift from the code).
/// </summary>
public static class ProfileLibrary
{
/// <summary>Mixamo / Adobe rigs: <c>mixamorig[N]:</c> namespace, <c>LeftArm</c> /
/// <c>LeftForeArm</c> / <c>LeftHandIndex1..3</c> style names.</summary>
public static Profile Mixamo { get; } = BuildMixamo();
/// <summary>
/// Reallusion ActorCore / AccuRig / Character Creator rigs (<c>CC_Base_*</c>).
/// Empirical notes from <c>research/rig_actorcore.json</c>:
/// <list type="bullet">
/// <item><c>CC_Base_Hip</c> is the parent of BOTH <c>CC_Base_Pelvis</c> (leg branch) and
/// <c>CC_Base_Waist</c> (spine branch), i.e. the LCA of legs+spine and the true animated
/// hips root → it carries <see cref="BoneRole.Hips"/>; <c>CC_Base_Pelvis</c> is a
/// leg-branch intermediate and stays unmapped.</item>
/// <item>The neck chain is <c>CC_Base_NeckTwist01 → CC_Base_NeckTwist02 → CC_Base_Head</c>;
/// despite the name, <c>NeckTwist01</c> IS the neck bone (there is no plain
/// <c>CC_Base_Neck</c>), so it is the <see cref="BoneRole.Neck"/> alias. NeckTwist02 is
/// left unmapped. All other Twist/ShareBone helpers are excluded (no aliases).</item>
/// <item><c>CC_Base_L_ToeBase</c> is the toe role; the co-located
/// <c>CC_Base_L_ToeBaseShareBone</c> is a helper and must never be mapped.</item>
/// </list>
/// </summary>
public static Profile ActorCoreCc { get; } = BuildActorCoreCc();
/// <summary>Unreal Engine mannequin (UE4/UE5): <c>pelvis</c>, <c>spine_01..05</c>,
/// <c>clavicle_l</c>, <c>thumb_01_l</c>, UE5 <c>*_metacarpal_*</c>; <c>*_twist_*</c>
/// bones have no aliases and are never mapped.</summary>
public static Profile UeMannequin { get; } = BuildUeMannequin();
/// <summary>Rokoko / Xsens style BVH rigs: plain <c>Hips</c>/<c>Spine..Spine4</c>/<c>
/// LeftArm|LeftUpperArm</c> name variants, usually no fingers.</summary>
public static Profile RokokoBvh { get; } = BuildRokokoBvh();
/// <summary>
/// SMPL body model family (AMASS exports, Meshcapade FBX rigs). Joint names per the
/// published model (vchoutas/smplx <c>joint_names.py</c>, Meshcapade wiki):
/// <c>pelvis</c>, sided <c>hip→knee→ankle→foot</c> legs (the "hip" joint IS the thigh;
/// "ankle" is the foot, "foot" is the toe region) and <c>collar→shoulder→elbow→wrist</c>
/// arms ("shoulder" is the upper arm, "wrist" is the hand; the <c>hand</c> joint is a
/// finger stub and stays unmapped). Both spellings occur in the wild: <c>left_hip</c>
/// (model joints) and <c>L_Hip</c> with gendered FBX prefixes <c>m_avg_</c>/<c>f_avg_</c>
/// (SMPL Unity/FBX rigs). No fingers — that is SMPL-X (<see cref="SmplX"/>), kept as a
/// separate preset so a finger-less SMPL rig still reaches full optional coverage.
/// </summary>
public static Profile Smpl { get; } = BuildSmpl(withFingers: false);
/// <summary>
/// SMPL-X: the SMPL body joints (<see cref="Smpl"/>) plus articulated hands —
/// <c>left_thumb1..3</c>/<c>left_index1..3</c>-style finger joints per
/// vchoutas/smplx <c>joint_names.py</c> (jaw/eye joints carry no humanoid role).
/// Evaluated before <see cref="Smpl"/> so it wins the tie on SMPL-X rigs (both score
/// the body fully; only this one maps the fingers).
/// </summary>
public static Profile SmplX { get; } = BuildSmpl(withFingers: true);
/// <summary>
/// NVIDIA SOMA uniform-proportion skeleton (SOMA/SEED BVH exports, e.g.
/// github.com/NVIDIA/soma-retargeter <c>assets/motions/bvh</c>). Mixamo-identical
/// upper-body and finger names, but: spine is <c>Spine1→Spine2→Chest</c> (no plain
/// "Spine"), neck is <c>Neck1→Neck2</c>, and the legs are <c>LeftLeg→LeftShin</c> —
/// SOMA's <c>LeftLeg</c> is the THIGH (mixamo's is the calf), which is exactly why the
/// mixamo preset must never claim these rigs.
/// </summary>
public static Profile SomaBvh { get; } = BuildSomaBvh();
/// <summary>
/// Classic BVH / Character-Studio-friendly naming (MotionBuilder "Export BVH to
/// Character Studio" convention, ACCAD-style mocap BVHs): <c>Hips</c>,
/// <c>Chest[2..4]</c> spine, arms <c>Collar→Shoulder→Elbow→Wrist</c> (the "Shoulder"
/// is the upper arm) and legs <c>Hip→Knee→Ankle→Toe</c> (the sided "Hip" is the
/// thigh). No fingers.
/// </summary>
public static Profile ClassicBvh { get; } = BuildClassicBvh();
/// <summary>
/// 3ds Max Character Studio Biped rigs: every bone is "<BipedName> <Part>"
/// where the biped name defaults to <c>Bip01</c> (3ds Max ≤2009) / <c>Bip001</c>
/// (2010+) per the Autodesk "Naming the Biped" documentation; some exporters mangle
/// the spaces to underscores (<c>Bip01_L_Thigh</c>), hence the <c>^Bip\d+[ _]</c>
/// namespace pattern (alias comparison is separator-insensitive, so "L UpperArm" and
/// "L_UpperArm" normalize identically). Sided bones use a bare mid-name <c>L/R</c>:
/// <c>L Clavicle→L UpperArm→L Forearm→L Hand</c> arms,
/// <c>L Thigh→L Calf→L Foot→L Toe0</c> legs. Fingers are numbered chains
/// <c>L Finger0..4</c> (0 = thumb) with phalanx segments <c>Finger01/Finger02</c>
/// etc. (MotionBuilder's "3ds Max Biped Template" characterization maps exactly these
/// names). The COM root <c>Bip01</c> itself, <c>Footsteps</c>, toe segments
/// <c>Toe01/Toe02</c> and <c>HorseLink</c> carry no aliases and are never mapped.
/// </summary>
public static Profile Biped { get; } = BuildBiped();
/// <summary>
/// DAZ/Poser classic naming (Poser 4 era figures, DAZ Generation-4 V4/M4, Genesis 1/2,
/// MakeHuman's "Poser/DAZ names" BVH export — verified against the local
/// <c>dev/corpus/unknown_rigs/makehuman_cmu_03_03_dazNames.bvh</c>): camel-case bones
/// with a lower-case <c>l</c>/<c>r</c> side prefix — <c>hip</c> (the translating
/// root), <c>abdomen[→abdomen2]→chest</c> spine, <c>neck</c>, <c>head</c>,
/// <c>lCollar→lShldr→lForeArm→lHand</c> arms, <c>lThigh→lShin→lFoot→lToe</c> legs and
/// <c>lThumb1..3/lIndex1..3/lMid1..3/lRing1..3/lPinky1..3</c> fingers. The
/// <c>l/rButtock</c> thigh helpers and eye bones carry no aliases and stay unmapped.
/// DAZ Genesis 3/8/9 renamed the skeleton (<c>abdomenLower</c>, <c>lShldrBend</c>, …)
/// and is NOT covered by this preset.
/// </summary>
public static Profile DazPoser { get; } = BuildDazPoser();
/// <summary>
/// Blender Rigify human rigs, per the metarig definition in the rigify add-on
/// (<c>rigify/metarigs/human.py</c>) and the Blender manual's basic.human reference:
/// the spine chain is <c>spine→spine.001..spine.006</c> where <c>spine</c> IS the
/// pelvis/hips bone (it sits at the pelvis and parents the thighs), spine.001–003 are
/// the torso, spine.004/005 the two neck bones (004 carries <see cref="BoneRole.Neck"/>,
/// 005 stays unmapped — same policy as ActorCore's NeckTwist02) and spine.006 is the
/// head. Limbs: <c>shoulder.L→upper_arm.L→forearm.L→hand.L</c>,
/// <c>thigh.L→shin.L→foot.L→toe.L</c>; fingers <c>thumb.01.L..03.L</c> and
/// <c>f_index/f_middle/f_ring/f_pinky.01.L..03.L</c>. The <c>^DEF-</c> namespace
/// pattern also matches rigify's generated deform skeleton (<c>DEF-spine.001</c>,
/// <c>DEF-upper_arm.L</c>, …); the segmented deform twins (<c>DEF-upper_arm.L.001</c>),
/// <c>palm.*</c>, <c>pelvis.L/R</c>, <c>heel.02.L</c>, face bones and the generated
/// ORG-/MCH-/control bones have no aliases and are never mapped.
/// </summary>
public static Profile Rigify { get; } = BuildRigify();
/// <summary>
/// VRoid Studio / VRM avatars (UniVRM exports): <c>J_Bip_<side>_<Part></c>
/// bones where side is <c>C</c> (center), <c>L</c> or <c>R</c> — the standard VRoid
/// skeleton behind the VRM humanoid spec (vrm-c/vrm-specification, humanoid bone map):
/// <c>J_Bip_C_Hips/Spine/Chest/UpperChest/Neck/Head</c>,
/// <c>J_Bip_L_Shoulder→UpperArm→LowerArm→Hand</c>,
/// <c>J_Bip_L_UpperLeg→LowerLeg→Foot→ToeBase</c>, fingers
/// <c>J_Bip_L_Thumb1..3/Index1..3/Middle1..3/Ring1..3/Little1..3</c> ("Little" is the
/// pinky, per the VRM littleProximal/Intermediate/Distal humanoid bones). Secondary
/// physics/adjust bones (<c>J_Sec_*</c>, <c>J_Adj_*</c>) and the <c>Root</c> bone have
/// no aliases and are never mapped.
/// </summary>
public static Profile Vrm { get; } = BuildVrm();
/// <summary>
/// Blender Auto-Rig Pro humanoid FBX exports — bone names verified empirically against
/// the local user repro <c>dev/corpus/todo/Defenses.fbx</c> (the PunchPerfect family):
/// <c>.x</c> suffix marks center bones, <c>.l/.r</c> the sides, and the exported limb
/// deform bones carry the <c>_stretch</c> twin name — <c>root.x</c> is the hips
/// (under a ground bone <c>root</c>), <c>spine_01.x→spine_02.x→spine_03.x</c>,
/// <c>neck.x</c>, <c>head.x</c>, arms <c>shoulder.l→arm_stretch.l→forearm_stretch.l→
/// hand.l</c> (plain "arm", NOT "upperarm"), legs <c>thigh_stretch.l→leg_stretch.l→
/// foot.l→toes_01.l</c> ("leg" is the calf). Fingers keep Auto-Rig Pro's <c>c_</c>
/// control prefix on the exported deform chain: <c>c_thumb1.l..3.l</c>,
/// <c>c_index/c_middle/c_ring/c_pinky1.l..3.l</c>. Leftover finger-tip markers
/// (<c>mixamorig:LeftHandIndex4</c> in the repro) and <c>root</c> have no aliases.
/// </summary>
public static Profile AutoRigPro { get; } = BuildAutoRigPro();
/// <summary>All built-in presets, in detection order (first wins score ties — see
/// <see cref="SmplX"/> vs <see cref="Smpl"/>).</summary>
public static IReadOnlyList<Profile> All { get; } =
new[]
{
Mixamo, ActorCoreCc, UeMannequin, RokokoBvh, SmplX, Smpl, SomaBvh, ClassicBvh,
Biped, DazPoser, Rigify, Vrm, AutoRigPro,
};
// ---------------------------------------------------------------- mixamo
private static Profile BuildMixamo()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Hips" },
[BoneRole.Spine0] = new[] { "Spine" },
[BoneRole.Spine1] = new[] { "Spine1" },
[BoneRole.Spine2] = new[] { "Spine2" },
[BoneRole.Neck] = new[] { "Neck" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var (roleSide, nameSide) in Sides())
{
aliases[Role("Clavicle", roleSide)] = new[] { $"{nameSide}Shoulder" };
aliases[Role("UpperArm", roleSide)] = new[] { $"{nameSide}Arm" };
aliases[Role("LowerArm", roleSide)] = new[] { $"{nameSide}ForeArm" };
aliases[Role("Hand", roleSide)] = new[] { $"{nameSide}Hand" };
aliases[Role("UpperLeg", roleSide)] = new[] { $"{nameSide}UpLeg" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"{nameSide}Leg" };
aliases[Role("Foot", roleSide)] = new[] { $"{nameSide}Foot" };
aliases[Role("Toe", roleSide)] = new[] { $"{nameSide}ToeBase" };
foreach (var finger in new[] { "Thumb", "Index", "Middle", "Ring", "Pinky" })
{
aliases[Role($"{finger}Prox", roleSide)] = new[] { $"{nameSide}Hand{finger}1" };
aliases[Role($"{finger}Mid", roleSide)] = new[] { $"{nameSide}Hand{finger}2" };
aliases[Role($"{finger}Dist", roleSide)] = new[] { $"{nameSide}Hand{finger}3" };
}
}
// Both ':' (FBX namespace) and '_' (namespace mangled by some exporters) forms occur
// in the wild; some Mixamo downloads ship with no namespace at all, which still
// matches because the aliases are the bare names.
return new Profile("mixamo", new[] { "^mixamorig[0-9]*:", "^mixamorig[0-9]*_" }, aliases);
}
// ---------------------------------------------------------------- actorcore / cc
private static Profile BuildActorCoreCc()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Hip" },
[BoneRole.Spine0] = new[] { "Waist" },
[BoneRole.Spine1] = new[] { "Spine01" },
[BoneRole.Spine2] = new[] { "Spine02" },
[BoneRole.Neck] = new[] { "NeckTwist01" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var roleSide in new[] { "L", "R" })
{
var nameSide = roleSide; // CC bones use the bare side letter: CC_Base_L_Thigh.
aliases[Role("Clavicle", roleSide)] = new[] { $"{nameSide}_Clavicle" };
aliases[Role("UpperArm", roleSide)] = new[] { $"{nameSide}_Upperarm" };
aliases[Role("LowerArm", roleSide)] = new[] { $"{nameSide}_Forearm" };
aliases[Role("Hand", roleSide)] = new[] { $"{nameSide}_Hand" };
aliases[Role("UpperLeg", roleSide)] = new[] { $"{nameSide}_Thigh" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"{nameSide}_Calf" };
aliases[Role("Foot", roleSide)] = new[] { $"{nameSide}_Foot" };
aliases[Role("Toe", roleSide)] = new[] { $"{nameSide}_ToeBase" };
foreach (var (role, cc) in new[]
{
("Thumb", "Thumb"), ("Index", "Index"), ("Middle", "Mid"), ("Ring", "Ring"), ("Pinky", "Pinky"),
})
{
aliases[Role($"{role}Prox", roleSide)] = new[] { $"{nameSide}_{cc}1" };
aliases[Role($"{role}Mid", roleSide)] = new[] { $"{nameSide}_{cc}2" };
aliases[Role($"{role}Dist", roleSide)] = new[] { $"{nameSide}_{cc}3" };
}
}
return new Profile("actorcore_cc", new[] { "^CC_Base_" }, aliases);
}
// ---------------------------------------------------------------- ue mannequin
private static Profile BuildUeMannequin()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "pelvis" },
[BoneRole.Spine0] = new[] { "spine_01" },
[BoneRole.Spine1] = new[] { "spine_02" },
[BoneRole.Spine2] = new[] { "spine_03" },
[BoneRole.Spine3] = new[] { "spine_04" },
[BoneRole.Spine4] = new[] { "spine_05" },
[BoneRole.Neck] = new[] { "neck_01" },
[BoneRole.Head] = new[] { "head" },
};
foreach (var (roleSide, s) in new[] { ("L", "l"), ("R", "r") })
{
aliases[Role("Clavicle", roleSide)] = new[] { $"clavicle_{s}" };
aliases[Role("UpperArm", roleSide)] = new[] { $"upperarm_{s}" };
aliases[Role("LowerArm", roleSide)] = new[] { $"lowerarm_{s}" };
aliases[Role("Hand", roleSide)] = new[] { $"hand_{s}" };
aliases[Role("UpperLeg", roleSide)] = new[] { $"thigh_{s}" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"calf_{s}" };
aliases[Role("Foot", roleSide)] = new[] { $"foot_{s}" };
aliases[Role("Toe", roleSide)] = new[] { $"ball_{s}" };
foreach (var (role, ue) in new[]
{
("Thumb", "thumb"), ("Index", "index"), ("Middle", "middle"), ("Ring", "ring"), ("Pinky", "pinky"),
})
{
// UE5 mannequin adds metacarpals for the four fingers (not the thumb).
if (role != "Thumb")
aliases[Role($"{role}Meta", roleSide)] = new[] { $"{ue}_metacarpal_{s}" };
aliases[Role($"{role}Prox", roleSide)] = new[] { $"{ue}_01_{s}" };
aliases[Role($"{role}Mid", roleSide)] = new[] { $"{ue}_02_{s}" };
aliases[Role($"{role}Dist", roleSide)] = new[] { $"{ue}_03_{s}" };
}
}
return new Profile("ue_mannequin", new string[0], aliases);
}
// ---------------------------------------------------------------- rokoko / xsens bvh
private static Profile BuildRokokoBvh()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Hips" },
// Spine naming varies (Spine, Spine1..Spine4); ordered alias preference plus the
// used-bone exclusion in the detector shifts the chain up when "Spine" is absent.
[BoneRole.Spine0] = new[] { "Spine", "Spine1" },
[BoneRole.Spine1] = new[] { "Spine1", "Spine2" },
[BoneRole.Spine2] = new[] { "Spine2", "Spine3" },
[BoneRole.Spine3] = new[] { "Spine3", "Spine4" },
[BoneRole.Spine4] = new[] { "Spine4" },
[BoneRole.Neck] = new[] { "Neck", "Neck1" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var (roleSide, nameSide) in Sides())
{
aliases[Role("Clavicle", roleSide)] = new[] { $"{nameSide}Shoulder", $"{nameSide}Collar" };
aliases[Role("UpperArm", roleSide)] = new[] { $"{nameSide}Arm", $"{nameSide}UpperArm" };
aliases[Role("LowerArm", roleSide)] = new[] { $"{nameSide}ForeArm", $"{nameSide}LowerArm" };
aliases[Role("Hand", roleSide)] = new[] { $"{nameSide}Hand" };
aliases[Role("UpperLeg", roleSide)] = new[] { $"{nameSide}UpLeg", $"{nameSide}Thigh", $"{nameSide}UpperLeg" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"{nameSide}Leg", $"{nameSide}Shin", $"{nameSide}LowerLeg" };
aliases[Role("Foot", roleSide)] = new[] { $"{nameSide}Foot" };
aliases[Role("Toe", roleSide)] = new[] { $"{nameSide}Toe", $"{nameSide}ToeBase" };
}
return new Profile("rokoko_bvh", new string[0], aliases);
}
// ---------------------------------------------------------------- smpl / smpl-x
private static Profile BuildSmpl(bool withFingers)
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Pelvis" },
[BoneRole.Spine0] = new[] { "Spine1" },
[BoneRole.Spine1] = new[] { "Spine2" },
[BoneRole.Spine2] = new[] { "Spine3" },
[BoneRole.Neck] = new[] { "Neck" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var (roleSide, abbr, word) in new[] { ("L", "L", "left"), ("R", "R", "right") })
{
// Both documented spellings per role: abbreviated FBX-rig names ("L_Hip") and
// spelled model joint names ("left_hip"). Comparison is separator-insensitive.
aliases[Role("Clavicle", roleSide)] = new[] { $"{abbr}_Collar", $"{word}_collar" };
aliases[Role("UpperArm", roleSide)] = new[] { $"{abbr}_Shoulder", $"{word}_shoulder" };
aliases[Role("LowerArm", roleSide)] = new[] { $"{abbr}_Elbow", $"{word}_elbow" };
aliases[Role("Hand", roleSide)] = new[] { $"{abbr}_Wrist", $"{word}_wrist" };
aliases[Role("UpperLeg", roleSide)] = new[] { $"{abbr}_Hip", $"{word}_hip" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"{abbr}_Knee", $"{word}_knee" };
aliases[Role("Foot", roleSide)] = new[] { $"{abbr}_Ankle", $"{word}_ankle" };
aliases[Role("Toe", roleSide)] = new[] { $"{abbr}_Foot", $"{word}_foot" };
if (!withFingers)
continue;
// SMPL-X finger joints (left_index1..3 etc., per vchoutas/smplx joint_names.py).
foreach (var finger in new[] { "thumb", "index", "middle", "ring", "pinky" })
{
var name = char.ToUpperInvariant(finger[0]) + finger[1..];
aliases[Role($"{name}Prox", roleSide)] = new[] { $"{word}_{finger}1" };
aliases[Role($"{name}Mid", roleSide)] = new[] { $"{word}_{finger}2" };
aliases[Role($"{name}Dist", roleSide)] = new[] { $"{word}_{finger}3" };
}
}
// Gendered SMPL FBX rigs prefix every bone (m_avg_L_Hip, f_avg_Pelvis).
return new Profile(withFingers ? "smpl_x" : "smpl", new[] { "^m_avg_", "^f_avg_" }, aliases);
}
// ---------------------------------------------------------------- nvidia soma bvh
private static Profile BuildSomaBvh()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Hips" },
[BoneRole.Spine0] = new[] { "Spine1" },
[BoneRole.Spine1] = new[] { "Spine2" },
[BoneRole.Spine2] = new[] { "Chest" },
[BoneRole.Neck] = new[] { "Neck1" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var (roleSide, nameSide) in Sides())
{
aliases[Role("Clavicle", roleSide)] = new[] { $"{nameSide}Shoulder" };
aliases[Role("UpperArm", roleSide)] = new[] { $"{nameSide}Arm" };
aliases[Role("LowerArm", roleSide)] = new[] { $"{nameSide}ForeArm" };
aliases[Role("Hand", roleSide)] = new[] { $"{nameSide}Hand" };
// SOMA's "Leg" is the thigh, "Shin" the calf — the decisive difference from
// mixamo, where "Leg" is the calf under "UpLeg".
aliases[Role("UpperLeg", roleSide)] = new[] { $"{nameSide}Leg" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"{nameSide}Shin" };
aliases[Role("Foot", roleSide)] = new[] { $"{nameSide}Foot" };
aliases[Role("Toe", roleSide)] = new[] { $"{nameSide}ToeBase" };
// Mixamo-style finger names; segment 4 ("LeftHandIndex4") and the *End markers
// carry no role.
foreach (var finger in new[] { "Thumb", "Index", "Middle", "Ring", "Pinky" })
{
aliases[Role($"{finger}Prox", roleSide)] = new[] { $"{nameSide}Hand{finger}1" };
aliases[Role($"{finger}Mid", roleSide)] = new[] { $"{nameSide}Hand{finger}2" };
aliases[Role($"{finger}Dist", roleSide)] = new[] { $"{nameSide}Hand{finger}3" };
}
}
return new Profile("soma_bvh", new string[0], aliases);
}
// ---------------------------------------------------------------- classic bvh
private static Profile BuildClassicBvh()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Hips" },
[BoneRole.Spine0] = new[] { "Chest" },
[BoneRole.Spine1] = new[] { "Chest2" },
[BoneRole.Spine2] = new[] { "Chest3" },
[BoneRole.Spine3] = new[] { "Chest4" },
[BoneRole.Neck] = new[] { "Neck" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var (roleSide, nameSide) in Sides())
{
aliases[Role("Clavicle", roleSide)] = new[] { $"{nameSide}Collar" };
aliases[Role("UpperArm", roleSide)] = new[] { $"{nameSide}Shoulder" };
aliases[Role("LowerArm", roleSide)] = new[] { $"{nameSide}Elbow" };
aliases[Role("Hand", roleSide)] = new[] { $"{nameSide}Wrist" };
aliases[Role("UpperLeg", roleSide)] = new[] { $"{nameSide}Hip" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"{nameSide}Knee" };
aliases[Role("Foot", roleSide)] = new[] { $"{nameSide}Ankle" };
aliases[Role("Toe", roleSide)] = new[] { $"{nameSide}Toe" };
}
return new Profile("classic_bvh", new string[0], aliases);
}
// ---------------------------------------------------------------- 3ds max biped
private static Profile BuildBiped()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Pelvis" },
[BoneRole.Spine0] = new[] { "Spine" },
[BoneRole.Spine1] = new[] { "Spine1" },
[BoneRole.Spine2] = new[] { "Spine2" },
[BoneRole.Spine3] = new[] { "Spine3" },
[BoneRole.Neck] = new[] { "Neck" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var s in new[] { "L", "R" })
{
aliases[Role("Clavicle", s)] = new[] { $"{s} Clavicle" };
aliases[Role("UpperArm", s)] = new[] { $"{s} UpperArm" };
aliases[Role("LowerArm", s)] = new[] { $"{s} Forearm" };
aliases[Role("Hand", s)] = new[] { $"{s} Hand" };
aliases[Role("UpperLeg", s)] = new[] { $"{s} Thigh" };
aliases[Role("LowerLeg", s)] = new[] { $"{s} Calf" };
aliases[Role("Foot", s)] = new[] { $"{s} Foot" };
aliases[Role("Toe", s)] = new[] { $"{s} Toe0" };
// Numbered finger chains: Finger0 is the thumb; segment names append the
// phalanx digit (Finger0 → Finger01 → Finger02, Finger1 → Finger11 → ...).
foreach (var (finger, n) in new[]
{
("Thumb", 0), ("Index", 1), ("Middle", 2), ("Ring", 3), ("Pinky", 4),
})
{
aliases[Role($"{finger}Prox", s)] = new[] { $"{s} Finger{n}" };
aliases[Role($"{finger}Mid", s)] = new[] { $"{s} Finger{n}1" };
aliases[Role($"{finger}Dist", s)] = new[] { $"{s} Finger{n}2" };
}
}
// "Bip01 "/"Bip001 " biped-name prefix; underscore form covers exporters that
// mangle the spaces ("Bip01_L_Thigh"). The bare COM root "Bip01" is untouched by
// the pattern (no trailing separator) and has no alias.
return new Profile("biped", new[] { @"^Bip\d+[ _]" }, aliases);
}
// ---------------------------------------------------------------- daz / poser
private static Profile BuildDazPoser()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "hip" },
[BoneRole.Spine0] = new[] { "abdomen" },
// Poser classic / DAZ Gen4 spine is abdomen→chest; DAZ Genesis 1/2 inserts
// abdomen2. Ordered preference + used-bone exclusion handles both: without
// abdomen2 the chest falls back to Spine1 and Spine2 stays unmapped.
[BoneRole.Spine1] = new[] { "abdomen2", "chest" },
[BoneRole.Spine2] = new[] { "chest" },
[BoneRole.Neck] = new[] { "neck" },
[BoneRole.Head] = new[] { "head" },
};
foreach (var s in new[] { "L", "R" })
{
var p = s == "L" ? "l" : "r"; // lower-case side prefix: lShldr, rThigh
aliases[Role("Clavicle", s)] = new[] { $"{p}Collar" };
aliases[Role("UpperArm", s)] = new[] { $"{p}Shldr" };
aliases[Role("LowerArm", s)] = new[] { $"{p}ForeArm" };
aliases[Role("Hand", s)] = new[] { $"{p}Hand" };
aliases[Role("UpperLeg", s)] = new[] { $"{p}Thigh" };
aliases[Role("LowerLeg", s)] = new[] { $"{p}Shin" };
aliases[Role("Foot", s)] = new[] { $"{p}Foot" };
aliases[Role("Toe", s)] = new[] { $"{p}Toe" };
foreach (var (role, daz) in new[]
{
("Thumb", "Thumb"), ("Index", "Index"), ("Middle", "Mid"), ("Ring", "Ring"), ("Pinky", "Pinky"),
})
{
aliases[Role($"{role}Prox", s)] = new[] { $"{p}{daz}1" };
aliases[Role($"{role}Mid", s)] = new[] { $"{p}{daz}2" };
aliases[Role($"{role}Dist", s)] = new[] { $"{p}{daz}3" };
}
}
return new Profile("daz_poser", new string[0], aliases);
}
// ---------------------------------------------------------------- blender rigify
private static Profile BuildRigify()
{
var aliases = new Dictionary<BoneRole, string[]>
{
// rigify's "spine" bone sits AT the pelvis and parents both thighs — it is
// the hips, not a spine link (rigify/metarigs/human.py).
[BoneRole.Hips] = new[] { "spine" },
[BoneRole.Spine0] = new[] { "spine.001" },
[BoneRole.Spine1] = new[] { "spine.002" },
[BoneRole.Spine2] = new[] { "spine.003" },
// spine.004 + spine.005 are the two neck bones, spine.006 the head;
// spine.005 stays unmapped (same policy as ActorCore's NeckTwist02).
[BoneRole.Neck] = new[] { "spine.004" },
[BoneRole.Head] = new[] { "spine.006" },
};
foreach (var s in new[] { "L", "R" })
{
aliases[Role("Clavicle", s)] = new[] { $"shoulder.{s}" };
aliases[Role("UpperArm", s)] = new[] { $"upper_arm.{s}" };
aliases[Role("LowerArm", s)] = new[] { $"forearm.{s}" };
aliases[Role("Hand", s)] = new[] { $"hand.{s}" };
aliases[Role("UpperLeg", s)] = new[] { $"thigh.{s}" };
aliases[Role("LowerLeg", s)] = new[] { $"shin.{s}" };
aliases[Role("Foot", s)] = new[] { $"foot.{s}" };
aliases[Role("Toe", s)] = new[] { $"toe.{s}" };
foreach (var (role, rigify) in new[]
{
("Thumb", "thumb"), ("Index", "f_index"), ("Middle", "f_middle"),
("Ring", "f_ring"), ("Pinky", "f_pinky"),
})
{
aliases[Role($"{role}Prox", s)] = new[] { $"{rigify}.01.{s}" };
aliases[Role($"{role}Mid", s)] = new[] { $"{rigify}.02.{s}" };
aliases[Role($"{role}Dist", s)] = new[] { $"{rigify}.03.{s}" };
}
}
// The generated deform skeleton prefixes every deform bone with "DEF-"; its
// segmented limb twins ("DEF-upper_arm.L.001") keep their numeric suffix after
// stripping and therefore never collide with the whole-bone aliases.
return new Profile("rigify", new[] { "^DEF-" }, aliases);
}
// ---------------------------------------------------------------- vroid / vrm
private static Profile BuildVrm()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "J_Bip_C_Hips" },
[BoneRole.Spine0] = new[] { "J_Bip_C_Spine" },
[BoneRole.Spine1] = new[] { "J_Bip_C_Chest" },
[BoneRole.Spine2] = new[] { "J_Bip_C_UpperChest" },
[BoneRole.Neck] = new[] { "J_Bip_C_Neck" },
[BoneRole.Head] = new[] { "J_Bip_C_Head" },
};
foreach (var s in new[] { "L", "R" })
{
aliases[Role("Clavicle", s)] = new[] { $"J_Bip_{s}_Shoulder" };
aliases[Role("UpperArm", s)] = new[] { $"J_Bip_{s}_UpperArm" };
aliases[Role("LowerArm", s)] = new[] { $"J_Bip_{s}_LowerArm" };
aliases[Role("Hand", s)] = new[] { $"J_Bip_{s}_Hand" };
aliases[Role("UpperLeg", s)] = new[] { $"J_Bip_{s}_UpperLeg" };
aliases[Role("LowerLeg", s)] = new[] { $"J_Bip_{s}_LowerLeg" };
aliases[Role("Foot", s)] = new[] { $"J_Bip_{s}_Foot" };
aliases[Role("Toe", s)] = new[] { $"J_Bip_{s}_ToeBase" };
foreach (var (role, vrm) in new[]
{
("Thumb", "Thumb"), ("Index", "Index"), ("Middle", "Middle"),
("Ring", "Ring"), ("Pinky", "Little"),
})
{
aliases[Role($"{role}Prox", s)] = new[] { $"J_Bip_{s}_{vrm}1" };
aliases[Role($"{role}Mid", s)] = new[] { $"J_Bip_{s}_{vrm}2" };
aliases[Role($"{role}Dist", s)] = new[] { $"J_Bip_{s}_{vrm}3" };
}
}
return new Profile("vrm", new string[0], aliases);
}
// ---------------------------------------------------------------- auto-rig pro
private static Profile BuildAutoRigPro()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "root.x" },
[BoneRole.Spine0] = new[] { "spine_01.x" },
[BoneRole.Spine1] = new[] { "spine_02.x" },
[BoneRole.Spine2] = new[] { "spine_03.x" },
[BoneRole.Neck] = new[] { "neck.x" },
[BoneRole.Head] = new[] { "head.x" },
};
foreach (var s in new[] { "L", "R" })
{
var p = s == "L" ? "l" : "r";
aliases[Role("Clavicle", s)] = new[] { $"shoulder.{p}" };
aliases[Role("UpperArm", s)] = new[] { $"arm_stretch.{p}" };
aliases[Role("LowerArm", s)] = new[] { $"forearm_stretch.{p}" };
aliases[Role("Hand", s)] = new[] { $"hand.{p}" };
aliases[Role("UpperLeg", s)] = new[] { $"thigh_stretch.{p}" };
aliases[Role("LowerLeg", s)] = new[] { $"leg_stretch.{p}" };
aliases[Role("Foot", s)] = new[] { $"foot.{p}" };
aliases[Role("Toe", s)] = new[] { $"toes_01.{p}" };
// Exported finger deform bones keep ARP's c_ control prefix (Defenses.fbx).
foreach (var finger in new[] { "thumb", "index", "middle", "ring", "pinky" })
{
var role = char.ToUpperInvariant(finger[0]) + finger[1..];
aliases[Role($"{role}Prox", s)] = new[] { $"c_{finger}1.{p}" };
aliases[Role($"{role}Mid", s)] = new[] { $"c_{finger}2.{p}" };
aliases[Role($"{role}Dist", s)] = new[] { $"c_{finger}3.{p}" };
}
}
return new Profile("auto_rig_pro", new string[0], aliases);
}
// ---------------------------------------------------------------- helpers
private static IEnumerable<(string RoleSide, string NameSide)> Sides()
{
yield return ("L", "Left");
yield return ("R", "Right");
}
private static BoneRole Role(string baseName, string side)
=> System.Enum.Parse<BoneRole>(baseName + side);
}
using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Maths;
using HumanoidRetargeter.Target;
namespace HumanoidRetargeter.Solve;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>
/// Mirrors a solved TARGET-space clip across the target character's sagittal plane,
/// producing the left/right-swapped twin of an animation (e.g. a right-foot-lead walk from a
/// left-foot-lead one).
/// </summary>
/// <remarks>
/// <para><b>Mirror plane.</b> The plane through the rig-space origin spanned by the target
/// character's up and forward directions; its normal is the character's LATERAL axis,
/// computed from the target rig's rest geometry via <see cref="CharacterFrame"/> (never
/// hardcoded — an arbitrary target may be authored in any axis convention). When the
/// computed lateral lies on a coordinate axis up to float dirt (< 1e-3 on the other two
/// components — true for every axis-aligned authored rig, including the s&box citizen
/// rigs), it is snapped to that exact axis, which makes every reflection below an EXACT
/// sign-flip in IEEE arithmetic and therefore the whole mirror a bit-exact involution
/// (mirror ∘ mirror == identity, verified by test).</para>
/// <para><b>Math.</b> Let M = I − 2n̂n̂ᵀ be the reflection across the plane with unit normal
/// n̂. A world transform W = (R, t) maps to its mirror image by conjugation:
/// W′ = M̂ ∘ W ∘ M̂ (M̂ is its own inverse), giving rotation R′ = M·R·M and translation
/// t′ = M·t. For a quaternion q = (v, w), M·R·M is the rotation by the SAME angle about the
/// REFLECTED axis with REVERSED sense (a reflection flips orientation), i.e.
/// q′ = (2(n̂·v)n̂ − v, w); with n̂ = +X that is exactly q′ = (x, −y, −z, w), and positions
/// reflect as p′ = p − 2(n̂·p)n̂ = (−pₓ, p_y, p_z).</para>
/// <para><b>Locals, not worlds.</b> Because conjugation is a homomorphism
/// (M̂(AB)M̂ = (M̂AM̂)(M̂BM̂)) and world transforms are products of locals down the
/// hierarchy, mirroring every LOCAL transform and permuting bones by their L↔R partner is
/// exactly equivalent to mirroring the FK worlds — provided the partner permutation is
/// hierarchy-consistent (the partner's parent is the parent's partner), which is validated
/// and holds on structurally symmetric humanoid rigs. This avoids FK→inverse-FK float drift
/// entirely, which is what makes the double-mirror identity bit-exact.</para>
/// <para><b>Pairing.</b> Left/right bones are paired by the rig's canonical role annotations
/// first (UpperArmL ↔ UpperArmR, …); role-less bones (twist helpers, IK bones) fall back to
/// <c>_L</c>/<c>_R</c> name-token pairing (<c>arm_upper_L_twist0</c> ↔
/// <c>arm_upper_R_twist0</c>, <c>foot_L_IK_target</c> ↔ <c>foot_R_IK_target</c>); anything
/// unpaired (center bones: pelvis, spine, neck, head) mirrors in place, which reflects its
/// rotation across the sagittal plane and negates its lateral translation. IK-baked bones
/// should be re-baked from the mirrored body afterwards (<see cref="IkBoneBaker"/>) — the
/// pipeline does exactly that.</para>
/// </remarks>
public static class ClipMirror
{
/// <summary>Maximum off-axis component magnitude below which the computed lateral axis is
/// snapped to the exact coordinate axis (authored rigs are axis-aligned; the tiny rest
/// asymmetries of a real mesh stay far below this).</summary>
private const float AxisSnapTolerance = 1e-3f;
/// <summary>
/// Returns the mirrored copy of <paramref name="frames"/> (one new list, inputs
/// untouched): per frame, bone i takes the conjugated local transform of its L↔R partner
/// σ(i). See the class remarks for the math and pairing rules.
/// </summary>
/// <param name="frames">Solved per-frame local transforms (target skeleton bone order).</param>
/// <param name="rig">The target rig (skeleton + roles) the frames belong to.</param>
/// <exception cref="ArgumentException">Thrown when the rig maps a sided role without its
/// counterpart, the pairing is not hierarchy-consistent, or the character frame is not
/// computable — mirroring would silently produce garbage in those cases.</exception>
public static List<XForm[]> Mirror(List<XForm[]> frames, TargetRig rig)
{
ArgumentNullException.ThrowIfNull(frames);
ArgumentNullException.ThrowIfNull(rig);
var skeleton = rig.Skeleton;
var lateral = LateralAxis(rig);
var pair = BuildPairing(rig);
var result = new List<XForm[]>(frames.Count);
foreach (var locals in frames)
{
if (locals.Length != skeleton.Count)
throw new ArgumentException(
$"Frame has {locals.Length} bones but the target skeleton has {skeleton.Count}.",
nameof(frames));
var mirrored = new XForm[locals.Length];
for (var i = 0; i < locals.Length; i++)
{
var source = locals[pair[i]];
mirrored[i] = new XForm(
ReflectPoint(source.Pos, lateral),
ReflectRotation(source.Rot, lateral));
}
result.Add(mirrored);
}
return result;
}
// ================================================================ mirror plane
/// <summary>The unit mirror normal: the target character's lateral axis from rest
/// geometry, snapped to an exact coordinate axis when within tolerance (bit-exact
/// reflections, see class remarks).</summary>
private static Vector3 LateralAxis(TargetRig rig)
{
Vector3 lateral;
try
{
lateral = CharacterFrame.Compute(
rig.Skeleton, rig.ToMappingResult(), rig.Skeleton.RestWorld).Lateral;
}
catch (ArgumentException e)
{
throw new ArgumentException(
$"Cannot mirror: target character frame not computable ({e.Message}).", e);
}
var a = Vector3.Abs(lateral);
if (a.Y <= AxisSnapTolerance && a.Z <= AxisSnapTolerance)
return Vector3.UnitX;
if (a.X <= AxisSnapTolerance && a.Z <= AxisSnapTolerance)
return Vector3.UnitY;
if (a.X <= AxisSnapTolerance && a.Y <= AxisSnapTolerance)
return Vector3.UnitZ;
return lateral; // general (non-axis-aligned) rig: exact involution is lost, math is not
}
/// <summary>p′ = p − 2(n̂·p)n̂. With a snapped axis this is an exact sign flip of one
/// component (IEEE subtraction of representable values is exact).</summary>
private static Vector3 ReflectPoint(Vector3 p, Vector3 n)
=> p - 2f * Vector3.Dot(p, n) * n;
/// <summary>q′ = (2(n̂·v)n̂ − v, w): the conjugated rotation M·R·M — same angle, axis
/// reflected, sense reversed. With n̂ = +X this is (x, −y, −z, w). Components are
/// preserved exactly (no renormalization), keeping the double mirror bit-exact.</summary>
private static Quaternion ReflectRotation(Quaternion q, Vector3 n)
{
var v = new Vector3(q.X, q.Y, q.Z);
var reflected = 2f * Vector3.Dot(v, n) * n - v;
return new Quaternion(reflected.X, reflected.Y, reflected.Z, q.W);
}
// ================================================================ L↔R pairing
/// <summary>
/// σ: bone → mirror partner (identity for center/unpaired bones). Roles pair first;
/// role-less bones pair by <c>_L</c>/<c>_R</c> name tokens. Validated to be an involution
/// consistent with the hierarchy (σ(parent(i)) == parent(σ(i))).
/// </summary>
private static int[] BuildPairing(TargetRig rig)
{
var skeleton = rig.Skeleton;
var pair = new int[skeleton.Count];
for (var i = 0; i < pair.Length; i++)
pair[i] = i;
for (var i = 0; i < skeleton.Count; i++)
{
if (rig.RoleOf(i) is { } role)
{
if (MirrorRole(role) is not { } mirroredRole)
continue; // center role: mirrors in place
pair[i] = rig.BoneForRole(mirroredRole)
?? throw new ArgumentException(
$"Cannot mirror: target rig maps role {role} ('{skeleton[i].Name}') "
+ $"but not its counterpart {mirroredRole}.");
}
else
{
var partnerName = SwapSideTokens(skeleton[i].Name);
if (partnerName is null)
continue; // no side token: center bone
var partner = skeleton.IndexOf(partnerName);
if (partner >= 0)
pair[i] = partner;
// No partner bone: leave in place (e.g. an asymmetric prop bone) — its
// rotation still mirrors across the sagittal plane.
}
}
for (var i = 0; i < pair.Length; i++)
{
if (pair[pair[i]] != i)
throw new ArgumentException(
$"Cannot mirror: bone pairing is not symmetric ('{skeleton[i].Name}' → "
+ $"'{skeleton[pair[i]].Name}' → '{skeleton[pair[pair[i]]].Name}').");
var parent = skeleton[i].ParentIndex;
var partnerParent = skeleton[pair[i]].ParentIndex;
var consistent = parent < 0
? partnerParent < 0
: partnerParent == pair[parent];
if (!consistent)
throw new ArgumentException(
$"Cannot mirror: left/right pairing is not hierarchy-consistent — "
+ $"'{skeleton[i].Name}' and partner '{skeleton[pair[i]].Name}' hang under "
+ "non-mirrored parents.");
}
return pair;
}
/// <summary>UpperArmL → UpperArmR (and back); null for center roles. Every sided
/// <see cref="BoneRole"/> ends in <c>L</c>/<c>R</c>; no center role does.</summary>
private static BoneRole? MirrorRole(BoneRole role)
{
var name = role.ToString();
var mirroredName = name[^1] switch
{
'L' => name[..^1] + "R",
'R' => name[..^1] + "L",
_ => null,
};
return mirroredName is not null && Enum.TryParse<BoneRole>(mirroredName, out var mirrored)
? mirrored
: null;
}
/// <summary>Swaps <c>L</c>/<c>R</c> underscore-delimited name tokens
/// (<c>foot_L_IK_target</c> → <c>foot_R_IK_target</c>); null when the name carries no
/// side token.</summary>
private static string? SwapSideTokens(string name)
{
var tokens = name.Split('_');
for (var i = 0; i < tokens.Length; i++)
{
tokens[i] = tokens[i] switch
{
"L" => "R",
"R" => "L",
"l" => "r",
"r" => "l",
_ => tokens[i],
};
}
var result = string.Join('_', tokens);
return string.Equals(result, name, StringComparison.Ordinal) ? null : result;
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Maths;
namespace HumanoidRetargeter.Solve;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>
/// Finger retargeting. Picks one of three strategies per finger chain:
/// <list type="number">
/// <item><b>1:1 absolute copy</b> (via the <c>transferOneToOne</c> callback into
/// <see cref="GeometricSolver"/>'s body path) when the source and target chains are
/// <i>geometrically identical</i> — same mapped role set, same canonical frames, same
/// normalized rest rotations. This is the same-rig round-trip case and is lossless (exact
/// identity, twist included).</item>
/// <item><b>Direction matching</b> when the phalanx counts match ordinally but the rigs
/// differ (the common cross-rig case, e.g. Mixamo Prox/Mid/Dist onto the s&box finger
/// with its extra metacarpal — which keeps its rest local; a source metacarpal's rotation is
/// implicit in the proximal's absolute direction). Each target phalanx is swung — shortest
/// arc, rotation axis ⊥ the finger axis, hence <b>zero twist by construction</b> — so that its
/// segment direction matches the source phalanx's direction in character-frame coordinates
/// exactly. Curl and splay are both captured by the direction; the source's axial twist is
/// dropped (hinge-joint noise; copying it absolutely would read as roll through the
/// inter-phalanx canonical mismatch between rigs, measured up to ~12° on thumbs).</item>
/// <item><b>Proportional redistribution</b> when phalanx counts differ (e.g. a two-phalanx
/// source finger): per-phalanx local curls — swing-twist about the canonical hinge Y of
/// <c>λ_b = C_b⁻¹·(ΔR_prev⁻¹·ΔR_b)·C_b</c> — are summed over the source chain (metacarpal
/// included) and redistributed over the target phalanges proportional to rest segment
/// lengths; splay (metacarpal + proximal, swing-twist about canonical Z) goes 100% to the
/// target proximal; the X-twist residual is dropped.</item>
/// </list>
/// In every mode target world deltas rebuild hierarchically from the solved target hand:
/// <c>ΔR_i = ΔR_{i-1} · (C_i · λ_i · C_i⁻¹)</c>, then <c>W_i = ΔR_i · R_tgtNormRest,i</c>.
/// Instances are per-solve and not thread-safe.
/// </summary>
internal sealed class FingerSolver
{
/// <summary>Two canonical frames / rest rotations within this angle count as identical
/// (same-rig detection for the lossless 1:1 path); cross-rig differences are degrees.</summary>
private const float SameRigToleranceRad = 1e-3f;
private enum ChainMode
{
DirectionMatch,
Proportional,
}
private readonly struct SourcePhalanx
{
public required int Slot { get; init; }
public required Quaternion C { get; init; }
public required Quaternion CInv { get; init; }
public required bool TakesSplay { get; init; }
}
private readonly struct Recipient
{
public required int TgtBone { get; init; }
public required Quaternion C { get; init; }
public required Quaternion CInv { get; init; }
public required Quaternion RestRot { get; init; }
public required float Weight { get; init; }
public required bool Splay { get; init; }
}
private sealed class Chain
{
public required ChainMode Mode { get; init; }
public required int SrcHandSlot { get; init; }
public required int TgtHandBone { get; init; }
public required Quaternion TgtHandNormRestRotInv { get; init; }
public required SourcePhalanx[] Sources { get; init; }
public required Recipient[] Recipients { get; init; }
}
private readonly List<Chain> _chains;
private readonly Quaternion _chrSrcInv;
private readonly Quaternion _chrTgt;
private FingerSolver(List<Chain> chains, Quaternion chrSrcInv, Quaternion chrTgt)
{
_chains = chains;
_chrSrcInv = chrSrcInv;
_chrTgt = chrTgt;
}
// ---------------------------------------------------------------- role tables
private static readonly BoneRole[][] ChainRoles = BuildChainRoles();
private static readonly HashSet<BoneRole> FingerRoleSet = ChainRoles.SelectMany(c => c.Skip(1)).ToHashSet();
private static BoneRole[][] BuildChainRoles()
{
var chains = new List<BoneRole[]>();
foreach (var side in new[] { "L", "R" })
{
foreach (var finger in new[] { "Thumb", "Index", "Middle", "Ring", "Pinky" })
{
// Element 0 is the hand the chain hangs off; 1.. are Meta/Prox/Mid/Dist.
chains.Add(new[]
{
Enum.Parse<BoneRole>("Hand" + side),
Enum.Parse<BoneRole>(finger + "Meta" + side),
Enum.Parse<BoneRole>(finger + "Prox" + side),
Enum.Parse<BoneRole>(finger + "Mid" + side),
Enum.Parse<BoneRole>(finger + "Dist" + side),
});
}
}
return chains.ToArray();
}
/// <summary>True for the 40 per-finger segment roles (Meta/Prox/Mid/Dist × finger × side).</summary>
public static bool IsFingerRole(BoneRole role) => FingerRoleSet.Contains(role);
// ---------------------------------------------------------------- build
/// <summary>
/// Builds the per-chain plans. Geometrically identical chains are reported through
/// <paramref name="transferOneToOne"/> instead of being planned here. Returns null when
/// every mapped chain took that path (or none is mapped).
/// </summary>
public static FingerSolver? Build(
MappingResult sourceMap,
CanonicalFrames srcCanon,
IReadOnlyList<XForm> srcNormRest,
Func<BoneRole, int?> tgtBoneForRole,
CanonicalFrames tgtCanon,
IReadOnlyList<XForm> tgtNormRest,
Quaternion chrSrcInv,
Quaternion chrTgt,
Func<int, int> registerSlot,
Action<BoneRole> transferOneToOne)
{
var chains = new List<Chain>();
foreach (var chainRoles in ChainRoles)
{
var handRole = chainRoles[0];
var metaRole = chainRoles[1];
var proxRole = chainRoles[2];
var segments = chainRoles.Skip(1).ToArray();
var srcRoles = segments
.Where(r => sourceMap.RoleToBone.ContainsKey(r) && srcCanon.Has(r))
.ToArray();
var tgtRoles = segments
.Where(r => tgtBoneForRole(r) is not null && tgtCanon.Has(r))
.ToArray();
if (srcRoles.Length == 0 || tgtRoles.Length == 0)
continue;
if (srcRoles.SequenceEqual(tgtRoles) && ChainsCoincide(
srcRoles, sourceMap, srcCanon, srcNormRest, tgtBoneForRole, tgtCanon, tgtNormRest))
{
foreach (var role in srcRoles)
transferOneToOne(role);
continue;
}
var srcPhalanges = srcRoles.Where(r => r != metaRole).ToArray();
var tgtPhalanges = tgtRoles.Where(r => r != metaRole).ToArray();
var recipientRoles = tgtPhalanges.Length > 0 ? tgtPhalanges : tgtRoles;
var mode = srcPhalanges.Length == recipientRoles.Length && srcPhalanges.Length > 0
? ChainMode.DirectionMatch
: ChainMode.Proportional;
// Direction matching consumes only the non-meta phalanges (the metacarpal's
// motion is implicit in the proximal's absolute direction); redistribution
// decomposes every mapped source segment including the metacarpal.
var sourceRolesUsed = mode == ChainMode.DirectionMatch ? srcPhalanges : srcRoles;
var sources = sourceRolesUsed.Select(r =>
{
var c = srcCanon.WorldFrameOf(r);
return new SourcePhalanx
{
Slot = registerSlot(sourceMap.RoleToBone[r]),
C = c,
CInv = Quaternion.Conjugate(c),
TakesSplay = r == metaRole || r == proxRole,
};
}).ToArray();
var weights = SegmentWeights(tgtRoles, recipientRoles, tgtBoneForRole, tgtNormRest);
var recipients = recipientRoles.Select((r, i) =>
{
var bone = tgtBoneForRole(r)!.Value;
var c = tgtCanon.WorldFrameOf(r);
return new Recipient
{
TgtBone = bone,
C = c,
CInv = Quaternion.Conjugate(c),
RestRot = tgtNormRest[bone].Rot,
Weight = weights[i],
Splay = i == 0,
};
}).ToArray();
var tgtHand = tgtBoneForRole(handRole);
chains.Add(new Chain
{
Mode = mode,
SrcHandSlot = sourceMap.RoleToBone.TryGetValue(handRole, out var srcHand)
? registerSlot(srcHand)
: -1,
TgtHandBone = tgtHand ?? -1,
TgtHandNormRestRotInv = tgtHand is int h
? Quaternion.Conjugate(tgtNormRest[h].Rot)
: Quaternion.Identity,
Sources = sources,
Recipients = recipients,
});
}
return chains.Count > 0 ? new FingerSolver(chains, chrSrcInv, chrTgt) : null;
}
/// <summary>Same-rig detection: every chain member's canonical frame and normalized rest
/// rotation agree between source and target (within float noise). Only then is the 1:1
/// absolute copy lossless.</summary>
private static bool ChainsCoincide(
BoneRole[] roles, MappingResult sourceMap, CanonicalFrames srcCanon,
IReadOnlyList<XForm> srcNormRest, Func<BoneRole, int?> tgtBoneForRole,
CanonicalFrames tgtCanon, IReadOnlyList<XForm> tgtNormRest)
{
foreach (var role in roles)
{
var srcBone = sourceMap.RoleToBone[role];
var tgtBone = tgtBoneForRole(role)!.Value;
if (MathQ.AngleBetween(srcCanon.WorldFrameOf(role), tgtCanon.WorldFrameOf(role)) > SameRigToleranceRad
|| MathQ.AngleBetween(srcNormRest[srcBone].Rot, tgtNormRest[tgtBone].Rot) > SameRigToleranceRad)
{
return false;
}
}
return true;
}
/// <summary>Normalized rest segment lengths of the recipient phalanges (the proportional
/// curl weights). The distal segment, having no chain child, is estimated as 0.8× its
/// preceding segment.</summary>
private static float[] SegmentWeights(
BoneRole[] tgtRoles, BoneRole[] recipientRoles,
Func<BoneRole, int?> tgtBoneForRole, IReadOnlyList<XForm> tgtNormRest)
{
var positions = tgtRoles.Select(r => tgtNormRest[tgtBoneForRole(r)!.Value].Pos).ToArray();
var weights = new float[recipientRoles.Length];
for (var i = 0; i < recipientRoles.Length; i++)
{
var j = Array.IndexOf(tgtRoles, recipientRoles[i]);
weights[i] = j + 1 < positions.Length
? (positions[j + 1] - positions[j]).Length()
: j > 0 ? 0.8f * (positions[j] - positions[j - 1]).Length() : 1f;
}
var sum = weights.Sum();
if (sum <= 1e-6f)
return Enumerable.Repeat(1f / weights.Length, weights.Length).ToArray();
for (var i = 0; i < weights.Length; i++)
weights[i] /= sum;
return weights;
}
// ---------------------------------------------------------------- per frame
/// <summary>
/// Solves the planned chains for one frame. <paramref name="srcDeltas"/> holds the
/// registered source world rotation deltas (from normalized rest); solved target world
/// rotations are written into <paramref name="rot"/>/<paramref name="solved"/>. The target
/// hands must already be solved (body pass runs first).
/// </summary>
public void Apply(Quaternion[] srcDeltas, bool[] solved, Quaternion[] rot)
{
foreach (var chain in _chains)
{
var acc = chain.TgtHandBone >= 0 && solved[chain.TgtHandBone]
? MathQ.Normalize(rot[chain.TgtHandBone] * chain.TgtHandNormRestRotInv)
: Quaternion.Identity;
if (chain.Mode == ChainMode.DirectionMatch)
ApplyDirectionMatch(chain, srcDeltas, acc, solved, rot);
else
ApplyProportional(chain, srcDeltas, acc, solved, rot);
}
}
private void ApplyDirectionMatch(
Chain chain, Quaternion[] srcDeltas, Quaternion acc, bool[] solved, Quaternion[] rot)
{
for (var i = 0; i < chain.Recipients.Length; i++)
{
var sp = chain.Sources[i];
var rc = chain.Recipients[i];
// Source phalanx direction in character coords; re-expressed in the target world,
// then relative to the already-reconstructed parent delta, then in the phalanx's
// canonical frame — where the rest direction is unit X.
var srcAbs = MathQ.Normalize(_chrSrcInv * srcDeltas[sp.Slot] * sp.C);
var dirChr = Vector3.Transform(Vector3.UnitX, srcAbs);
var dirTgtWorld = Vector3.Transform(dirChr, _chrTgt);
var dirLocal = Vector3.Transform(dirTgtWorld, Quaternion.Conjugate(acc));
var dirCanon = Vector3.Transform(dirLocal, rc.CInv);
// Shortest-arc swing X -> dir: rotation axis ⊥ X, so it carries zero finger-axis
// twist by construction.
var swing = MathQ.FromTo(Vector3.UnitX, dirCanon);
acc = MathQ.Normalize(acc * (rc.C * swing * rc.CInv));
rot[rc.TgtBone] = MathQ.Normalize(acc * rc.RestRot);
solved[rc.TgtBone] = true;
}
}
private static void ApplyProportional(
Chain chain, Quaternion[] srcDeltas, Quaternion acc, bool[] solved, Quaternion[] rot)
{
// Decompose: total local curl over the chain, splay from metacarpal + proximal.
var prev = chain.SrcHandSlot >= 0 ? srcDeltas[chain.SrcHandSlot] : Quaternion.Identity;
float totalCurl = 0f, splay = 0f;
foreach (var sp in chain.Sources)
{
var dr = srcDeltas[sp.Slot];
var local = MathQ.Normalize(Quaternion.Conjugate(prev) * dr);
var canon = MathQ.Normalize(sp.CInv * local * sp.C);
MathQ.SwingTwist(canon, Vector3.UnitY, out var swing, out var curlQ);
totalCurl += SignedAngle(curlQ, Vector3.UnitY);
if (sp.TakesSplay)
{
MathQ.SwingTwist(swing, Vector3.UnitZ, out _, out var splayQ);
splay += SignedAngle(splayQ, Vector3.UnitZ);
}
prev = dr;
}
foreach (var rc in chain.Recipients)
{
var mu = Quaternion.CreateFromAxisAngle(Vector3.UnitY, totalCurl * rc.Weight);
if (rc.Splay)
mu = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, splay) * mu;
acc = MathQ.Normalize(acc * (rc.C * mu * rc.CInv));
rot[rc.TgtBone] = MathQ.Normalize(acc * rc.RestRot);
solved[rc.TgtBone] = true;
}
}
/// <summary>Signed rotation angle of an axis-aligned twist quaternion about
/// <paramref name="axis"/>, wrapped to (−π, π].</summary>
private static float SignedAngle(Quaternion twist, Vector3 axis)
{
var s = twist.X * axis.X + twist.Y * axis.Y + twist.Z * axis.Z;
var angle = 2f * MathF.Atan2(s, twist.W);
if (angle > MathF.PI)
angle -= 2f * MathF.PI;
else if (angle < -MathF.PI)
angle += 2f * MathF.PI;
return angle;
}
}
using System.Collections.Generic;
using HumanoidRetargeter.Mapping;
namespace HumanoidRetargeter.Solve;
/// <summary>How a mapped role's rotation is transferred by the <see cref="GeometricSolver"/>.</summary>
public enum RoleTransferMode
{
/// <summary>
/// Absolute canonical-orientation matching: the target's animated chain direction is
/// driven to <b>equal</b> the source's (in character-frame coordinates). Right for limbs
/// and the spine — the pose IS the direction — but it also imposes the source rig's rest
/// proportions/posture on roles whose rest directions legitimately differ between rigs.
/// </summary>
AbsoluteDirection,
/// <summary>
/// Rest-relative delta: the source's canonical-space rotation <i>delta from its own
/// normalized rest</i> is replayed onto the <b>target's</b> normalized rest
/// (<c>W_t(f) = C_t·ΔC(f)·C_t⁻¹·R_tgtNormRest</c> with
/// <c>ΔC(f) = C_s⁻¹·ΔR(f)·C_s</c>). The target keeps its own rest carriage (shoulder
/// line height, neck-base angle) and moves with the source. Identical to
/// <see cref="AbsoluteDirection"/> when source and target rigs coincide.
/// </summary>
DeltaFromRest,
/// <summary>
/// Character-space delta: the source's world-rotation delta from its normalized rest is
/// re-expressed in character coordinates and applied to the <b>target's</b> normalized
/// rest (<c>W_t(f) = M·ΔR(f)·M⁻¹·R_tgtNormRest</c> with <c>M = Q_tgt·Q_src⁻¹</c>, the
/// same character basis change <see cref="AbsoluteDirection"/> premultiplies). Like
/// <see cref="DeltaFromRest"/> the target keeps its own rest carriage, but the delta
/// keeps its <i>world</i> rotation axes instead of being remapped through the per-role
/// canonical frames — the faithful replay when the rigs' rest chain directions diverge
/// so far that canonical-axis remapping would tilt every rotation axis by that
/// divergence (measured 23–44° on feet: CMU/ARP ankle anatomy vs the s&box rig's
/// steep ankle, where canonical remapping mis-pitched planted feet by up to 47°).
/// Identical to the other modes when source and target rigs coincide.
/// </summary>
CharacterDeltaFromRest,
}
/// <summary>Options controlling a single retarget solve (one clip → one output clip).</summary>
public sealed class SolveOptions
{
/// <summary>
/// Default per-role transfer modes: shoulder girdle and neck carriage are
/// <see cref="RoleTransferMode.DeltaFromRest"/> (each rig's clavicle line / neck-base
/// direction is rig anatomy, not pose — absolute matching was measured to drag the
/// s&box shoulders 6–28° toward the source's flatter/lower clavicle line and is the
/// "low shoulders, hunched neck" artifact), and feet are
/// <see cref="RoleTransferMode.CharacterDeltaFromRest"/> (a rest foot→toe direction is
/// ankle anatomy too — rigs diverge 11–44° from the s&box rig's steep ankle, so
/// absolute matching pitched planted feet up to 25° off flat, the "feet bent
/// upward/inward" artifact; the character-space delta keeps the rotation's world axes,
/// which canonical-frame remapping would tilt by that same divergence). The head is
/// <see cref="RoleTransferMode.CharacterDeltaFromRest"/> for the same reason: the rest
/// neck→head direction is head-joint-placement anatomy (measured 0–27° forward lean
/// across neutral-rest rigs vs the s&box rig's 25.5°), so the target keeps its own
/// neutral skull attitude and replays the source's attitude <i>changes</i> — for the
/// head this computes exactly what the previous virtual-frame absolute matching did.
/// Two solver fallbacks adjust these defaults per rig pair: on a toe-less source the
/// foot entries become <see cref="RoleTransferMode.DeltaFromRest"/> (virtual-foot
/// fallback), and a source whose normalized rest head attitude is implausible as a
/// neutral carriage (a posed bind — e.g. a chin-down/tilted fighting-stance rest,
/// measured 40.7° forward / 16.9° lateral on such a rig where the delta replay read
/// ~12° "looking up at an angle") switches the head to
/// <see cref="RoleTransferMode.AbsoluteDirection"/> so the gaze follows the source
/// absolutely instead of replaying deltas from a posed reference (see the
/// <see cref="GeometricSolver"/> remarks for both). Everything else (limbs, spine,
/// toes, fingers) stays absolute: there the worldspace direction IS the pose.
/// </summary>
public static IReadOnlyDictionary<BoneRole, RoleTransferMode> DefaultTransferModes { get; } =
new Dictionary<BoneRole, RoleTransferMode>
{
[BoneRole.ClavicleL] = RoleTransferMode.DeltaFromRest,
[BoneRole.ClavicleR] = RoleTransferMode.DeltaFromRest,
[BoneRole.Neck] = RoleTransferMode.DeltaFromRest,
[BoneRole.Head] = RoleTransferMode.CharacterDeltaFromRest,
[BoneRole.FootL] = RoleTransferMode.CharacterDeltaFromRest,
[BoneRole.FootR] = RoleTransferMode.CharacterDeltaFromRest,
};
/// <summary>
/// Per-role transfer modes. Null (default) = <see cref="DefaultTransferModes"/> plus the
/// solver's fallback heuristics (a toe-less source's virtual foot direction overrides
/// the foot default to <see cref="RoleTransferMode.DeltaFromRest"/>, and a posed-rest
/// source head overrides the head default to
/// <see cref="RoleTransferMode.AbsoluteDirection"/> — see the
/// <see cref="GeometricSolver"/> remarks). A non-null map REPLACES the defaults entirely
/// and disables every fallback heuristic: each role uses exactly the mode in the map, and
/// roles absent from it are <see cref="RoleTransferMode.AbsoluteDirection"/>. Pass an
/// empty dictionary for fully absolute (legacy) behavior — API callers supplying a map
/// opt out of all heuristics.
/// </summary>
public IReadOnlyDictionary<BoneRole, RoleTransferMode>? TransferModes { get; init; }
/// <summary>
/// Scale applied to the pelvis translation components perpendicular to the character up
/// direction. Null (default) = automatic: target hip height / source hip height, both
/// measured on the normalized rests.
/// </summary>
public float? HipScaleHorizontal { get; init; }
/// <summary>
/// Scale applied to the pelvis translation component along the character up direction.
/// Null (default) = the same automatic hip-height ratio as <see cref="HipScaleHorizontal"/>.
/// </summary>
public float? HipScaleVertical { get; init; }
/// <summary>Whether finger roles are transferred; when false, target finger bones keep
/// their rest locals.</summary>
public bool TransferFingers { get; init; } = true;
/// <summary>Output clip name; null = the source clip's name.</summary>
public string? ClipName { get; init; }
/// <summary>Index of the source clip to retarget (<c>SourceScene.Clips</c>).</summary>
public int ClipIndex { get; init; }
}
using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Maths;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;
namespace HumanoidRetargeter.Cleanup;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>Tunables for the grounded-foot stance recalibration pass.</summary>
public sealed class FootGroundAlignOptions
{
/// <summary>
/// Dead zone (degrees): measured stance offsets at or below this are genuine planted
/// articulation (heel-roll bias, natural lean — measured 2–4° on well-rested rigs and
/// on citizen clips) and are left untouched, keeping the transfer byte-faithful there.
/// Only offsets beyond it are clearly rest-pose artifacts (measured 12–25° on the
/// repro rig) and get recalibrated.
/// </summary>
public float MinCorrectionDeg { get; set; } = 8f;
/// <summary>
/// Maximum mean sole deviation (degrees) a plant may show and still count as a STANCE
/// for the offset measurement. Plants beyond this are not standing on the sole (crawls,
/// kneels, prone contact — measured 60–90° there) and are excluded; genuine rest-pose
/// stance artifacts measure well below it (largest seen: 27°).
/// </summary>
public float MaxStanceDeviationDeg { get; set; } = 35f;
}
/// <summary>Per-foot results of a <see cref="FootGroundAlign.Apply"/> run.</summary>
public sealed class FootGroundAlignFootReport
{
/// <summary>Plants that contributed to the stance measurement.</summary>
public int StancePlants { get; set; }
/// <summary>Plants excluded as non-stance (mean sole deviation beyond
/// <see cref="FootGroundAlignOptions.MaxStanceDeviationDeg"/>).</summary>
public int SkippedPlants { get; set; }
/// <summary>Measured planted sole offset from the ground plane, degrees (0 when no
/// stance plants exist).</summary>
public float MeasuredOffsetDeg { get; set; }
/// <summary>Foot correction applied to every frame, degrees (0 = inside the dead zone,
/// nothing changed).</summary>
public float AppliedFootDeg { get; set; }
/// <summary>Toe correction applied to every frame, degrees.</summary>
public float AppliedToeDeg { get; set; }
}
/// <summary>Results of a <see cref="FootGroundAlign.Apply"/> run.</summary>
public sealed class FootGroundAlignReport
{
/// <summary>Left-foot results.</summary>
public required FootGroundAlignFootReport Left { get; init; }
/// <summary>Right-foot results.</summary>
public required FootGroundAlignFootReport Right { get; init; }
}
/// <summary>
/// Grounded-foot stance recalibration: measures how far the foot's SOLE sits from the ground
/// plane while planted, and — when that offset is clearly a rest-pose artifact — rotates it
/// out with one constant per foot, applied to every frame of the clip.
/// </summary>
/// <remarks>
/// <para><b>Why a cleanup pass.</b> The solver transfers feet as rest-relative deltas
/// (<see cref="Solve.RoleTransferMode.CharacterDeltaFromRest"/>), so the target keeps its own
/// ankle anatomy — correct whenever the source's rest pose is a flat-footed stance (the delta
/// is then "deviation from standing"). Some rigs ship a NON-stance rest (measured: an
/// Auto-Rig-Pro export whose rest foot sits 12–25° from its planted stance), and that constant
/// offset rides into every frame of the replay — planted feet hover toe-down/heel-up. What a
/// stance actually looks like is animation evidence (planted phases), which a per-frame
/// solver cannot see, so the recalibration lives here.</para>
/// <para><b>Measurement.</b> Per foot: over every planted frame, the sole normal = rest up
/// carried by the foot's world delta from the target bind rest (whose feet stand on the
/// ground by construction); plants whose own mean normal sits beyond
/// <see cref="FootGroundAlignOptions.MaxStanceDeviationDeg"/> are excluded (crawl/kneel/prone
/// contact is not a stance). The pooled mean normal's deviation from up is the stance
/// offset.</para>
/// <para><b>Correction.</b> Offsets inside <see cref="FootGroundAlignOptions.MinCorrectionDeg"/>
/// are genuine articulation — nothing is changed (well-rested rigs and same-rig round trips
/// stay byte-identical through this pass). Beyond it, the shortest-arc rotation taking the
/// pooled normal back to up (pitch+roll only — yaw/toe-out is pose and follows the source)
/// premultiplies the foot's world rotation on EVERY frame: a rest artifact is constant, so
/// the fix is too — within-plant heel-roll, swing styling and frame-to-frame continuity are
/// preserved exactly, and no blending is needed. The toe then receives its own residual
/// constant measured on top of the corrected foot (it neither double-rotates with the foot
/// fix nor inherits the source toe's own rest artifact). Corrections rotate bones about
/// their own joints: ankle positions are untouched, so the pass composes freely with the
/// <see cref="FootPlant"/> position pinning (which preserves foot world rotations).</para>
/// <para><b>Plant intervals come from the caller</b> (the pipeline detects them on the
/// SOURCE clip via <see cref="FootPlant.DetectPlantIntervals"/> — ground truth, immune to
/// the hip-height rescaling that can push target-side trajectories outside the cm-tuned
/// Kovar thresholds). So does the decision to run at all: the pipeline invokes this pass
/// only when the source's normalized rest is implausible as a flat stance (toe at/above
/// ankle level or asymmetric feet — see <c>Retargeter.GroundAlignFeet</c>); on plausible
/// stance rests the solver's rest-relative transfer is already faithful and planted-sole
/// deviations are genuine articulation (boxing stances, heel rolls) that must not be
/// flattened.</para>
/// </remarks>
public static class FootGroundAlign
{
/// <summary>Measures planted stance offsets and recalibrates feet whose offset is a
/// rest-pose artifact; returns what was measured and done.</summary>
/// <param name="frames">Per-frame local transforms (skeleton bone order); modified in place.</param>
/// <param name="skeleton">Bone hierarchy the frames are expressed against; its bind rest
/// is the flat-stance reference.</param>
/// <param name="left">Left leg chain bone indices.</param>
/// <param name="right">Right leg chain bone indices.</param>
/// <param name="up">World up direction of the clip's space.</param>
/// <param name="leftPlants">Left-foot plant intervals (frame indices into
/// <paramref name="frames"/>; out-of-range parts are clamped/ignored).</param>
/// <param name="rightPlants">Right-foot plant intervals.</param>
/// <param name="options">Tunables; defaults used when null.</param>
public static FootGroundAlignReport Apply(
List<XForm[]> frames,
SkeletonModel skeleton,
FootChain left,
FootChain right,
Vector3 up,
IReadOnlyList<FrameRange> leftPlants,
IReadOnlyList<FrameRange> rightPlants,
FootGroundAlignOptions? options = null)
{
ArgumentNullException.ThrowIfNull(frames);
ArgumentNullException.ThrowIfNull(skeleton);
ArgumentNullException.ThrowIfNull(left);
ArgumentNullException.ThrowIfNull(right);
ArgumentNullException.ThrowIfNull(leftPlants);
ArgumentNullException.ThrowIfNull(rightPlants);
options ??= new FootGroundAlignOptions();
var report = new FootGroundAlignReport
{
Left = new FootGroundAlignFootReport(),
Right = new FootGroundAlignFootReport(),
};
if (frames.Count == 0 || up.LengthSquared() < 1e-12f)
return report;
up = Vector3.Normalize(up);
RecalibrateFoot(frames, skeleton, left, up, leftPlants, options, report.Left);
RecalibrateFoot(frames, skeleton, right, up, rightPlants, options, report.Right);
return report;
}
private static void RecalibrateFoot(
List<XForm[]> frames, SkeletonModel skeleton, FootChain chain, Vector3 up,
IReadOnlyList<FrameRange> plants, FootGroundAlignOptions options,
FootGroundAlignFootReport report)
{
int n = frames.Count;
var foot = chain.Ankle;
var restFootRotInv = Quaternion.Conjugate(skeleton.RestWorld[foot].Rot);
var maxStanceCos = MathF.Cos(options.MaxStanceDeviationDeg * MathF.PI / 180f);
// ---- measurement: pooled planted sole normal over the stance plants ----
var pooled = Vector3.Zero;
foreach (var plant in plants)
{
int start = Math.Max(plant.Start, 0);
int end = Math.Min(plant.End, n - 1);
if (start > end)
continue;
var plantSum = Vector3.Zero;
for (int f = start; f <= end; f++)
{
var footRot = FkUtil.BoneWorld(frames[f], skeleton, foot).Rot;
plantSum += Vector3.Transform(up, MathQ.Normalize(footRot * restFootRotInv));
}
if (plantSum.LengthSquared() < 1e-8f
|| Vector3.Dot(Vector3.Normalize(plantSum), up) < maxStanceCos)
{
report.SkippedPlants++; // not standing on the sole — crawl/kneel/toe contact
continue;
}
report.StancePlants++;
pooled += plantSum; // frame-count-weighted: longer stances dominate
}
if (pooled.LengthSquared() < 1e-8f)
return;
pooled = Vector3.Normalize(pooled);
var offsetDeg = MathQ.AngleBetween(pooled, up) * (180f / MathF.PI);
report.MeasuredOffsetDeg = offsetDeg;
if (offsetDeg <= options.MinCorrectionDeg)
return; // genuine planted articulation — leave the transfer byte-faithful
// ---- correction: one constant per foot, every frame ----
var footFix = MathQ.FromTo(pooled, up);
report.AppliedFootDeg = offsetDeg;
// Toe residual measured on top of the corrected foot, same dead zone.
var toeFix = Quaternion.Identity;
if (chain.Toe is { } toe && skeleton[toe].ParentIndex == foot)
{
var restToeRotInv = Quaternion.Conjugate(skeleton.RestWorld[toe].Rot);
var toePooled = Vector3.Zero;
foreach (var plant in plants)
{
int start = Math.Max(plant.Start, 0);
int end = Math.Min(plant.End, n - 1);
for (int f = start; f <= end && f >= 0; f++)
{
var toeRot = FkUtil.BoneWorld(frames[f], skeleton, toe).Rot;
toePooled += Vector3.Transform(
up, MathQ.Normalize(footFix * toeRot * restToeRotInv));
}
}
if (toePooled.LengthSquared() > 1e-8f)
{
toePooled = Vector3.Normalize(toePooled);
var toeDeg = MathQ.AngleBetween(toePooled, up) * (180f / MathF.PI);
if (toeDeg > options.MinCorrectionDeg && Vector3.Dot(toePooled, up) >= maxStanceCos)
{
toeFix = MathQ.FromTo(toePooled, up);
report.AppliedToeDeg = toeDeg;
}
}
}
for (int f = 0; f < n; f++)
CorrectFrame(frames[f], skeleton, chain, footFix, toeFix);
}
/// <summary>Premultiplies the foot's world rotation by the constant fix (the joint
/// position is untouched — the rotation pivots the foot about its own head), then gives
/// the toe its own residual on top of the corrected foot.</summary>
private static void CorrectFrame(
XForm[] locals, SkeletonModel skeleton, FootChain chain,
Quaternion footFix, Quaternion toeFix)
{
var foot = chain.Ankle;
var parent = skeleton[foot].ParentIndex;
var parentRot = parent < 0
? Quaternion.Identity
: FkUtil.BoneWorld(locals, skeleton, parent).Rot;
var footWorld = MathQ.Normalize(parentRot * locals[foot].Rot);
var newFootWorld = MathQ.Normalize(footFix * footWorld);
locals[foot] = new XForm(
locals[foot].Pos, MathQ.Normalize(Quaternion.Conjugate(parentRot) * newFootWorld));
if (chain.Toe is { } toe && skeleton[toe].ParentIndex == foot)
{
// Desired toe world = toeFix ∘ footFix ∘ original world; re-derive its local
// against the corrected foot so it does not double-rotate with the foot fix.
var toeWorldOld = MathQ.Normalize(footWorld * locals[toe].Rot);
var desired = MathQ.Normalize(toeFix * footFix * toeWorldOld);
locals[toe] = new XForm(
locals[toe].Pos, MathQ.Normalize(Quaternion.Conjugate(newFootWorld) * desired));
}
}
}
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
using System.Text;
using HumanoidRetargeter.Maths;
using HumanoidRetargeter.Skeleton;
namespace HumanoidRetargeter.Formats.Bvh;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>Options for <see cref="BvhImporter.Import"/>.</summary>
public sealed class BvhImportOptions
{
/// <summary>Fixed resampling rate for the motion data, frames per second.</summary>
public float SampleFps { get; init; } = 30f;
}
/// <summary>
/// BVH (Biovision Hierarchy) → <see cref="SourceScene"/> importer.
/// </summary>
/// <remarks>
/// <para><b>Format conventions implemented</b> (verified against Blender's
/// <c>io_anim_bvh</c> importer, which is the project's ground-truth extractor):</para>
/// <list type="bullet">
/// <item><b>Rest pose:</b> each joint's rest local translation is its <c>OFFSET</c>; rest
/// rotation is identity (BVH stores no rest orientation).</item>
/// <item><b>Rotation channels:</b> the channel list order IS the rotation order. The listed
/// rotations apply left-to-right as intrinsic rotations, which in this library's
/// column-vector convention (<c>a * b</c> applies <c>b</c> first) is the product
/// <c>R = R_chan1 * R_chan2 * R_chan3</c> — e.g. <c>Zrotation Yrotation Xrotation</c> gives
/// <c>R = Rz * Ry * Rx</c>. This matches Blender, which builds
/// <c>Euler((x,y,z), reversed(channelOrder))</c> for the same matrix. Angles are degrees.</item>
/// <item><b>Position channels:</b> when a joint has any position channel, the channel values
/// REPLACE the joint's local translation (missing components are 0) — they are not added to
/// the <c>OFFSET</c>. This is Blender's behavior; in practice roots have OFFSET 0 so the two
/// readings only diverge on non-root position channels (e.g. Bandai-Namco exports).</item>
/// <item><b>End Sites:</b> synthesized as a channel-less leaf bone named
/// <c>"<parent>_end"</c> so chain tips keep their direction information (Blender instead
/// folds them into the parent bone's tail).</item>
/// </list>
/// <para><b>Units</b>: BVH files carry no unit declaration. Heuristic: compute the rest
/// skeleton height (max−min world Y over all joints); if it is < 10 the file is assumed
/// to be in meters and all translations (offsets AND position channels, root included) are
/// scaled ×100 to centimeters, otherwise it is assumed to already be centimeters (×1).
/// Millimeter-scale files (height > 400) are not special-cased — they are rare and
/// ambiguous against cm mocap of long ranges; <see cref="SourceScene.UnitScaleCm"/> records
/// whichever factor was applied for diagnostics.</para>
/// <para><b>Resampling</b>: motion frames are resampled from the file's <c>Frame Time</c>
/// grid onto <see cref="BvhImportOptions.SampleFps"/>. Each native frame's euler channels are
/// converted to a quaternion FIRST and bracketing frames are then slerped (positions lerped).
/// Interpolating raw euler angles across frames would mostly work at mocap densities
/// (30–120 fps, small per-frame deltas) but breaks down when an angle wraps ±180° between
/// frames; per-frame quaternion + slerp has no such failure mode, so that is what we do.</para>
/// <para><b>Axes</b>: BVH is conventionally Y-up / Z-forward / X-right. Native axes are
/// preserved (no conversion), matching the FBX importer's policy; the conventional axes are
/// recorded on the <see cref="SourceScene"/> (up = Y, front = Z, coord = X).</para>
/// </remarks>
public static class BvhImporter
{
private const float MeterHeightThreshold = 10f;
/// <summary>Parses BVH bytes and builds the source scene.</summary>
/// <exception cref="FormatException">Malformed or truncated BVH.</exception>
public static SourceScene Import(byte[] data, BvhImportOptions? options = null)
{
ArgumentNullException.ThrowIfNull(data);
options ??= new BvhImportOptions();
if (!(options.SampleFps > 0f) || !float.IsFinite(options.SampleFps))
throw new ArgumentOutOfRangeException(nameof(options), "SampleFps must be positive.");
var cursor = new TokenCursor(Encoding.UTF8.GetString(data));
// ---- HIERARCHY -----------------------------------------------------------------
cursor.ExpectKeyword("HIERARCHY");
var joints = new List<Joint>();
int channelCount = 0;
if (!cursor.PeekIs("ROOT"))
throw new FormatException("BVH: expected ROOT after HIERARCHY.");
while (cursor.PeekIs("ROOT")) // multiple roots are out of spec but harmless to accept
{
cursor.Next();
ParseJoint(cursor, joints, parent: -1, ref channelCount);
}
// ---- MOTION ---------------------------------------------------------------------
cursor.ExpectKeyword("MOTION");
cursor.ExpectKeyword("FRAMES:");
int frameCount = cursor.NextInt();
if (frameCount < 0)
throw new FormatException($"BVH: negative frame count {frameCount}.");
cursor.ExpectKeyword("FRAME");
cursor.ExpectKeyword("TIME:");
float frameTime = cursor.NextFloat();
if (!(frameTime > 0f) || !float.IsFinite(frameTime))
throw new FormatException($"BVH: invalid Frame Time {frameTime}.");
var motion = new float[frameCount][];
for (int f = 0; f < frameCount; f++)
{
var row = new float[channelCount];
for (int c = 0; c < channelCount; c++)
row[c] = cursor.NextFloat();
motion[f] = row;
}
// ---- units heuristic --------------------------------------------------------------
float unitScale = HeuristicUnitScale(joints);
// ---- skeleton ----------------------------------------------------------------------
var defs = new List<BoneDefinition>(joints.Count);
foreach (var j in joints)
{
defs.Add(new BoneDefinition(
j.Name,
j.Parent < 0 ? null : joints[j.Parent].Name,
new XForm(j.Offset * unitScale, Quaternion.Identity)));
}
var skeleton = Skeleton.Skeleton.Create(defs);
// ---- clip ----------------------------------------------------------------------------
var clips = new List<Clip>();
if (frameCount > 0)
clips.Add(ResampleClip(joints, skeleton, motion, frameTime, unitScale, options.SampleFps));
// BVH conventional axes: Y-up (1), Z-front (2), X-coord (0) — recorded, not converted.
return new SourceScene(
skeleton, clips, unitScale,
upAxis: 1, upAxisSign: 1,
frontAxis: 2, frontAxisSign: 1,
coordAxis: 0, coordAxisSign: 1,
originalUpAxis: -1);
}
// =====================================================================================
// hierarchy parsing
// =====================================================================================
private sealed class Joint
{
public required string Name;
public required int Parent; // index into the joint list, -1 for roots
public Vector3 Offset; // raw file units
public int PosX = -1, PosY = -1, PosZ = -1; // motion column per position axis
public List<(int Axis, int Column)> Rot = new(); // rotation channels in file order
public bool HasPos => PosX >= 0 || PosY >= 0 || PosZ >= 0;
}
private static void ParseJoint(TokenCursor cursor, List<Joint> joints, int parent, ref int channelCount)
{
// Joint name: tokens up to '{', joined with '_' (mirrors Blender's handling of
// names containing spaces).
var nameParts = new List<string>();
while (!cursor.PeekIs("{"))
{
if (cursor.AtEnd)
throw new FormatException("BVH: unexpected end of file in joint name.");
nameParts.Add(cursor.Next());
}
if (nameParts.Count == 0)
throw new FormatException("BVH: joint with no name.");
string name = UniqueName(string.Join('_', nameParts), joints);
cursor.ExpectKeyword("{");
cursor.ExpectKeyword("OFFSET");
var joint = new Joint { Name = name, Parent = parent };
joint.Offset = new Vector3(cursor.NextFloat(), cursor.NextFloat(), cursor.NextFloat());
int index = joints.Count;
joints.Add(joint);
if (cursor.PeekIs("CHANNELS"))
{
cursor.Next();
int n = cursor.NextInt();
if (n < 0 || n > 6)
throw new FormatException($"BVH: joint '{name}' has invalid channel count {n}.");
for (int i = 0; i < n; i++)
{
string channel = cursor.Next();
int column = channelCount++;
switch (channel.ToUpperInvariant())
{
case "XPOSITION": joint.PosX = column; break;
case "YPOSITION": joint.PosY = column; break;
case "ZPOSITION": joint.PosZ = column; break;
case "XROTATION": joint.Rot.Add((0, column)); break;
case "YROTATION": joint.Rot.Add((1, column)); break;
case "ZROTATION": joint.Rot.Add((2, column)); break;
default:
throw new FormatException($"BVH: unknown channel '{channel}' on joint '{name}'.");
}
}
}
while (!cursor.PeekIs("}"))
{
if (cursor.AtEnd)
throw new FormatException($"BVH: unexpected end of file inside joint '{name}'.");
if (cursor.PeekIs("JOINT"))
{
cursor.Next();
ParseJoint(cursor, joints, index, ref channelCount);
}
else if (cursor.PeekIs("END"))
{
cursor.Next();
cursor.ExpectKeyword("SITE");
while (!cursor.PeekIs("{")) // a name after "End Site" is out of spec; skip it
{
if (cursor.AtEnd)
throw new FormatException("BVH: unexpected end of file in End Site.");
cursor.Next();
}
cursor.ExpectKeyword("{");
cursor.ExpectKeyword("OFFSET");
var endOffset = new Vector3(cursor.NextFloat(), cursor.NextFloat(), cursor.NextFloat());
cursor.ExpectKeyword("}");
// Synthesize a channel-less leaf so the chain tip's direction is kept.
joints.Add(new Joint
{
Name = UniqueName(name + "_end", joints),
Parent = index,
Offset = endOffset,
});
}
else
{
throw new FormatException(
$"BVH: unexpected token '{cursor.Next()}' inside joint '{name}'.");
}
}
cursor.ExpectKeyword("}");
}
private static string UniqueName(string name, List<Joint> joints)
{
bool Taken(string candidate)
{
foreach (var j in joints)
if (string.Equals(j.Name, candidate, StringComparison.Ordinal))
return true;
return false;
}
if (!Taken(name))
return name;
for (int i = 1; ; i++)
{
string candidate = $"{name}#{i}";
if (!Taken(candidate))
return candidate;
}
}
// =====================================================================================
// units
// =====================================================================================
/// <summary>
/// Meters-vs-centimeters heuristic: rest skeleton height (max−min world Y over all
/// joints, end sites included) < 10 → meters → ×100; otherwise centimeters → ×1.
/// </summary>
private static float HeuristicUnitScale(List<Joint> joints)
{
Span<float> worldY = joints.Count <= 256 ? stackalloc float[joints.Count] : new float[joints.Count];
float min = float.MaxValue, max = float.MinValue;
for (int i = 0; i < joints.Count; i++)
{
worldY[i] = (joints[i].Parent < 0 ? 0f : worldY[joints[i].Parent]) + joints[i].Offset.Y;
min = MathF.Min(min, worldY[i]);
max = MathF.Max(max, worldY[i]);
}
float height = max - min;
return height > 0f && height < MeterHeightThreshold ? 100f : 1f;
}
// =====================================================================================
// motion sampling
// =====================================================================================
/// <summary>
/// Decodes every native frame to per-joint local transforms (quaternions built per frame
/// from the joint's channel order), then resamples onto the <paramref name="fps"/> grid —
/// positions lerped, rotations slerped between the bracketing native frames.
/// </summary>
private static Clip ResampleClip(
List<Joint> joints, Skeleton.Skeleton skeleton, float[][] motion,
float frameTime, float unitScale, float fps)
{
int jointCount = joints.Count;
int nativeCount = motion.Length;
// Joint order may differ from skeleton bone order (topological sort) — map.
var toSkeleton = new int[jointCount];
for (int i = 0; i < jointCount; i++)
toSkeleton[i] = skeleton.IndexOf(joints[i].Name);
// Native-frame locals.
var native = new XForm[nativeCount][];
for (int f = 0; f < nativeCount; f++)
{
var row = motion[f];
var locals = new XForm[jointCount];
for (int i = 0; i < jointCount; i++)
locals[i] = EvaluateLocal(joints[i], row, unitScale);
native[f] = locals;
}
double duration = (nativeCount - 1) * (double)frameTime;
int outCount = Math.Max(1, (int)Math.Round(duration * fps) + 1);
var frames = new List<XForm[]>(outCount);
for (int f = 0; f < outCount; f++)
{
double s = f / (double)fps / frameTime; // position on the native frame grid
int i0 = Math.Clamp((int)Math.Floor(s), 0, nativeCount - 1);
int i1 = Math.Min(i0 + 1, nativeCount - 1);
float u = Math.Clamp((float)(s - i0), 0f, 1f);
var frame = new XForm[skeleton.Count];
var a = native[i0];
var b = native[i1];
for (int i = 0; i < jointCount; i++)
{
frame[toSkeleton[i]] = new XForm(
Vector3.Lerp(a[i].Pos, b[i].Pos, u),
MathQ.Normalize(Quaternion.Slerp(a[i].Rot, b[i].Rot, u)));
}
frames.Add(frame);
}
// NativeFps records the file's authored frame rate (1 / FrameTime): external frame
// ranges (Unity .meta clipAnimations) are expressed in it.
float nativeFps = frameTime > 0f ? (float)(1.0 / frameTime) : fps;
return new Clip("motion", fps, looping: false, frames, nativeFps);
}
/// <summary>One joint's local transform from one motion row (see class remarks).</summary>
private static XForm EvaluateLocal(Joint joint, float[] row, float unitScale)
{
// Position channels replace the OFFSET; absent channels (or no position channels at
// all) fall back per Blender's semantics described in the class remarks.
Vector3 pos = joint.HasPos
? new Vector3(
joint.PosX >= 0 ? row[joint.PosX] : 0f,
joint.PosY >= 0 ? row[joint.PosY] : 0f,
joint.PosZ >= 0 ? row[joint.PosZ] : 0f)
: joint.Offset;
// R = R_chan1 * R_chan2 * R_chan3 (column-vector convention; degrees in the file).
var rot = Quaternion.Identity;
foreach (var (axis, column) in joint.Rot)
{
float radians = row[column] * (MathF.PI / 180f);
var axisVector = axis switch
{
0 => Vector3.UnitX,
1 => Vector3.UnitY,
_ => Vector3.UnitZ,
};
rot *= Quaternion.CreateFromAxisAngle(axisVector, radians);
}
return new XForm(pos * unitScale, MathQ.Normalize(rot));
}
// =====================================================================================
// tokenizer
// =====================================================================================
/// <summary>Whitespace token stream over the BVH text (BVH is line-format agnostic).</summary>
private sealed class TokenCursor
{
private readonly string[] _tokens;
private int _pos;
public TokenCursor(string text)
=> _tokens = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
public bool AtEnd => _pos >= _tokens.Length;
public bool PeekIs(string keywordUpper)
=> _pos < _tokens.Length &&
string.Equals(_tokens[_pos], keywordUpper, StringComparison.OrdinalIgnoreCase);
public string Next()
{
if (AtEnd)
throw new FormatException("BVH: unexpected end of file.");
return _tokens[_pos++];
}
public void ExpectKeyword(string keywordUpper)
{
string token = Next();
if (!string.Equals(token, keywordUpper, StringComparison.OrdinalIgnoreCase))
throw new FormatException($"BVH: expected '{keywordUpper}', found '{token}'.");
}
public int NextInt()
{
string token = Next();
if (!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value))
throw new FormatException($"BVH: expected an integer, found '{token}'.");
return value;
}
public float NextFloat()
{
string token = Next();
if (!float.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out float value) ||
!float.IsFinite(value))
throw new FormatException($"BVH: expected a number, found '{token}'.");
return value;
}
}
}
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using HumanoidRetargeter.Skeleton;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;
namespace HumanoidRetargeter.Formats.Dmx;
/// <summary>Options for <see cref="DmxWriter.Write"/>.</summary>
public sealed class DmxWriteOptions
{
/// <summary>Model/clip name written into the DmeModel element (e.g. the sequence name).</summary>
public string Name { get; set; } = "";
/// <summary>Free-form provenance note written as the DmeDCCMakefile source name
/// (fbx2dmx writes the source .fbx path here).</summary>
public string SourceNote { get; set; } = "";
/// <summary>When true (default, matching fbx2dmx output) the file declares a Y-up axis
/// system; when false it declares Z-up. Data is written as-is either way.</summary>
public bool UpAxisY { get; set; } = true;
/// <summary>
/// Skeleton bone indices that get NO DmeChannel pair: the bones keep their DmeJoint and
/// bind (rest) transform, but no animation channels are written for them — the engine then
/// drives them itself (e.g. ConstraintDriven twist/helper bones, design §3). Null (default)
/// writes channels for every bone.
/// </summary>
public IReadOnlySet<int>? ChannelExcludedBones { get; set; }
}
/// <summary>
/// Writes an animation DMX in <c>keyvalues2_noids</c> text encoding, replicating the exact
/// element/attribute shape of fbx2dmx output (authoritative reference:
/// <c>dev/m0/ref_idlepose.dmx</c>): a root DmElement holding an inline DmeModel (joint GUID
/// refs + bind base state), a top-level DmeAnimationList with one DmeChannelsClip carrying a
/// position and an orientation channel per bone, and top-level DmeTransform/DmeJoint elements
/// the channels and joint lists reference by GUID. Output is fully deterministic: GUIDs are
/// MD5-derived from the options name and an element path, and export tags use fixed
/// placeholder strings.
/// </summary>
public static class DmxWriter
{
private const string Header = "<!-- dmx encoding keyvalues2_noids 4 format model 22 -->";
/// <summary>
/// Serializes <paramref name="clip"/> on <paramref name="skeleton"/> to DMX text.
/// Frames must contain one local transform per bone in skeleton order.
/// </summary>
/// <exception cref="ArgumentException">Thrown when the clip is empty or a frame's bone
/// count does not match the skeleton.</exception>
public static string Write(SkeletonModel skeleton, Clip clip, DmxWriteOptions options)
{
ArgumentNullException.ThrowIfNull(skeleton);
ArgumentNullException.ThrowIfNull(clip);
ArgumentNullException.ThrowIfNull(options);
if (clip.FrameCount == 0)
throw new ArgumentException("Clip has no frames.", nameof(clip));
for (var f = 0; f < clip.FrameCount; f++)
{
if (clip.Frames[f].Length != skeleton.Count)
throw new ArgumentException(
$"Frame {f} has {clip.Frames[f].Length} bone transforms, skeleton has {skeleton.Count}.",
nameof(clip));
}
var w = new Emitter();
var animListGuid = GuidString(options.Name, "animationList");
var jointGuids = new string[skeleton.Count];
var transformGuids = new string[skeleton.Count];
for (var i = 0; i < skeleton.Count; i++)
{
jointGuids[i] = GuidString(options.Name, "joint:" + skeleton[i].Name);
transformGuids[i] = GuidString(options.Name, "transform:" + skeleton[i].Name);
}
w.Raw(Header);
// ---- root DmElement -------------------------------------------------
w.BeginTopLevel("DmElement");
w.Attr("name", "string", "root");
w.BeginInlineAttr("skeleton", "DmeModel");
w.Attr("name", "string", options.Name);
w.BeginInlineAttr("transform", "DmeTransform");
w.Attr("position", "vector3", "0 0 0");
w.Attr("orientation", "quaternion", "0 0 0 1");
w.EndInlineAttr();
w.Attr("shape", "element", "");
w.Attr("visible", "bool", "1");
w.BeginArray("children");
var roots = new List<int>();
for (var i = 0; i < skeleton.Count; i++)
{
if (skeleton[i].ParentIndex < 0)
roots.Add(i);
}
for (var r = 0; r < roots.Count; r++)
w.ElementRef(jointGuids[roots[r]], last: r == roots.Count - 1);
w.EndArray();
w.BeginArray("jointList");
for (var i = 0; i < skeleton.Count; i++)
w.ElementRef(jointGuids[i], last: i == skeleton.Count - 1);
w.EndArray();
w.BeginArray("baseStates");
w.BeginArrayElement("DmeTransformList");
w.Attr("name", "string", "bind");
w.BeginArray("transforms");
for (var i = 0; i < skeleton.Count; i++)
{
w.BeginArrayElement("DmeTransform");
w.Attr("name", "string", skeleton[i].Name);
w.Attr("position", "vector3", Vec(skeleton[i].RestLocal));
w.Attr("orientation", "quaternion", Quat(skeleton[i].RestLocal));
w.EndArrayElement(last: i == skeleton.Count - 1);
}
w.EndArray();
w.EndArrayElement(last: true);
w.EndArray();
w.Attr("upAxis", "string", options.UpAxisY ? "Y" : "Z");
w.BeginInlineAttr("axisSystem", "DmeAxisSystem");
w.Attr("upAxis", "int", options.UpAxisY ? "2" : "3");
w.Attr("forwardParity", "int", "2");
w.Attr("coordSys", "int", "0");
w.EndInlineAttr();
w.Attr("animationList", "element", animListGuid);
w.EndInlineAttr(); // skeleton DmeModel
w.BeginInlineAttr("makefile", "DmeDCCMakefile");
w.Attr("name", "string", "makefile");
w.BeginArray("sources");
w.BeginArrayElement("DmeSource");
w.Attr("name", "string", options.SourceNote);
w.EndArrayElement(last: true);
w.EndArray();
w.EndInlineAttr();
// Deterministic placeholders — never wall-clock/user data, so output is reproducible.
w.BeginInlineAttr("exportTags", "DmeExportTags");
w.Attr("name", "string", "exportTags");
w.Attr("date", "string", "2026/01/01");
w.Attr("time", "string", "12:00:00 am");
w.Attr("user", "string", "retargeter");
w.Attr("machine", "string", "retargeter");
w.Attr("app", "string", "humanoid-retargeter");
w.Attr("appVersion", "string", "1.0");
w.Attr("cmdLine", "string", "humanoid-retargeter");
w.Attr("pwd", "string", "");
w.EndInlineAttr();
w.Attr("animationList", "element", animListGuid);
w.EndTopLevel();
// ---- DmeAnimationList ----------------------------------------------
w.BeginTopLevel("DmeAnimationList");
w.Attr("id", "elementid", animListGuid);
w.Attr("name", "string", "anim");
w.BeginArray("animations");
w.BeginArrayElement("DmeChannelsClip");
w.Attr("name", "string", "anim");
w.BeginInlineAttr("timeFrame", "DmeTimeFrame");
w.Attr("start", "time", Time(0.0));
w.Attr("duration", "time", Time((clip.FrameCount - 1) / (double)clip.Fps));
w.Attr("offset", "time", Time(0.0));
w.Attr("scale", "float", "1");
w.EndInlineAttr();
w.Attr("color", "color", "0 0 0 0");
w.Attr("text", "string", "");
w.Attr("mute", "bool", "0");
w.BeginArray("trackGroups");
w.EndArray();
w.Attr("displayScale", "float", "1");
var channelBones = new List<int>(skeleton.Count);
for (var i = 0; i < skeleton.Count; i++)
{
if (options.ChannelExcludedBones is null || !options.ChannelExcludedBones.Contains(i))
channelBones.Add(i);
}
w.BeginArray("channels");
for (var n = 0; n < channelBones.Count; n++)
{
var i = channelBones[n];
WriteChannel(w, skeleton, clip, i, transformGuids[i], position: true, last: false);
WriteChannel(w, skeleton, clip, i, transformGuids[i], position: false,
last: n == channelBones.Count - 1);
}
w.EndArray();
w.Attr("frameRate", "int",
((int)MathF.Round(clip.Fps)).ToString(CultureInfo.InvariantCulture));
w.EndArrayElement(last: true);
w.EndArray();
w.EndTopLevel();
// ---- top-level channel-target DmeTransforms (rest values) -----------
for (var i = 0; i < skeleton.Count; i++)
{
w.BeginTopLevel("DmeTransform");
w.Attr("id", "elementid", transformGuids[i]);
w.Attr("name", "string", skeleton[i].Name);
w.Attr("position", "vector3", Vec(skeleton[i].RestLocal));
w.Attr("orientation", "quaternion", Quat(skeleton[i].RestLocal));
w.EndTopLevel();
}
// ---- top-level DmeJoints --------------------------------------------
for (var i = 0; i < skeleton.Count; i++)
{
w.BeginTopLevel("DmeJoint");
w.Attr("id", "elementid", jointGuids[i]);
w.Attr("name", "string", skeleton[i].Name);
w.Attr("transform", "element", transformGuids[i]);
w.Attr("shape", "element", "");
w.Attr("visible", "bool", "1");
w.BeginArray("children");
var children = new List<int>();
for (var c = 0; c < skeleton.Count; c++)
{
if (skeleton[c].ParentIndex == i)
children.Add(c);
}
for (var c = 0; c < children.Count; c++)
w.ElementRef(jointGuids[children[c]], last: c == children.Count - 1);
w.EndArray();
w.EndTopLevel();
}
return w.ToString();
}
/// <summary>
/// Deterministic element GUID: MD5 over <c>"<name>\n<path>"</c> (UTF-8)
/// interpreted as <see cref="Guid"/> bytes. Exposed so tests can verify the scheme.
/// </summary>
public static Guid ElementGuid(string name, string path)
=> new(MD5.HashData(Encoding.UTF8.GetBytes(name + "\n" + path)));
private static string GuidString(string name, string path)
=> ElementGuid(name, path).ToString("D", CultureInfo.InvariantCulture);
// ---------------------------------------------------------------- channels
private static void WriteChannel(Emitter w, SkeletonModel skeleton, Clip clip, int bone,
string transformGuid, bool position, bool last)
{
var logClass = position ? "DmeVector3Log" : "DmeQuaternionLog";
var layerClass = position ? "DmeVector3LogLayer" : "DmeQuaternionLogLayer";
var logName = position ? "vector3 log" : "quaternion log";
w.BeginArrayElement("DmeChannel");
w.Attr("name", "string", skeleton[bone].Name + (position ? "_p" : "_o"));
w.Attr("fromElement", "element", "");
w.Attr("fromAttribute", "string", "");
w.Attr("fromIndex", "int", "0");
w.Attr("toElement", "element", transformGuid);
w.Attr("toAttribute", "string", position ? "position" : "orientation");
w.Attr("toIndex", "int", "0");
w.Attr("mode", "int", "3");
w.BeginInlineAttr("log", logClass);
w.Attr("name", "string", logName);
w.BeginArray("layers");
w.BeginArrayElement(layerClass);
w.Attr("name", "string", logName);
w.BeginArray("times", "time_array");
for (var f = 0; f < clip.FrameCount; f++)
w.ArrayValue(Time(f / (double)clip.Fps), last: f == clip.FrameCount - 1);
w.EndArray();
w.BeginArray("curvetypes", "int_array");
w.EndArray();
w.BeginArray("values", position ? "vector3_array" : "quaternion_array");
// Orientation values are hemisphere-aligned on the fly (q and -q are the same
// rotation, but the engine interpolates between DMX samples numerically — see
// QuaternionContinuity). The clip itself is never mutated.
var prev = System.Numerics.Quaternion.Identity;
for (var f = 0; f < clip.FrameCount; f++)
{
var x = clip.Frames[f][bone];
string value;
if (position)
{
value = Vec(x);
}
else
{
var q = x.Rot;
if (f > 0 && System.Numerics.Quaternion.Dot(prev, q) < 0f)
q = System.Numerics.Quaternion.Negate(q);
prev = q;
value = Quat(q);
}
w.ArrayValue(value, last: f == clip.FrameCount - 1);
}
w.EndArray();
w.EmptyBinaryAttr("compressed");
w.EndArrayElement(last: true);
w.EndArray(); // layers
w.Attr("curveinfo", "element", "");
w.Attr("usedefaultvalue", "bool", "0");
w.Attr("defaultvalue", position ? "vector3" : "quaternion", position ? "0 0 0" : "0 0 0 1");
w.BeginArray("bookmarksX", "time_array");
w.EndArray();
w.BeginArray("bookmarksY", "time_array");
w.EndArray();
w.BeginArray("bookmarksZ", "time_array");
w.EndArray();
w.EndInlineAttr(); // log
w.EndArrayElement(last);
}
// ---------------------------------------------------------------- formatting
/// <summary>fbx2dmx float style: up to 10 decimal places, trailing zeros stripped,
/// invariant culture, negative zero normalized.</summary>
private static string F(float value)
{
if (value == 0f)
return "0";
return ((double)value).ToString("0.##########", CultureInfo.InvariantCulture);
}
private static string Time(double seconds)
=> seconds.ToString("0.0000", CultureInfo.InvariantCulture);
private static string Vec(in Maths.XForm x)
=> $"{F(x.Pos.X)} {F(x.Pos.Y)} {F(x.Pos.Z)}";
private static string Quat(in Maths.XForm x) => Quat(x.Rot);
private static string Quat(in System.Numerics.Quaternion q)
=> $"{F(q.X)} {F(q.Y)} {F(q.Z)} {F(q.W)}";
// ---------------------------------------------------------------- emitter
/// <summary>
/// Low-level keyvalues2 text emitter reproducing fbx2dmx layout quirks: CRLF endings,
/// tab indentation, a trailing space after array-typed attribute names, and an
/// indentation-only line after every inline element attribute closes.
/// </summary>
private sealed class Emitter
{
private readonly StringBuilder _sb = new();
private int _indent;
public void Raw(string text)
{
_sb.Append(text).Append("\r\n");
}
private void Line(string text)
{
_sb.Append('\t', _indent).Append(text).Append("\r\n");
}
public void Attr(string name, string type, string value)
=> Line($"\"{name}\" \"{type}\" \"{value}\"");
public void BeginTopLevel(string className)
{
Line($"\"{className}\"");
Line("{");
_indent++;
}
public void EndTopLevel()
{
_indent--;
Line("}");
_sb.Append("\r\n"); // blank separator after every top-level element (incl. the last)
}
public void BeginInlineAttr(string name, string className)
{
Line($"\"{name}\" \"{className}\"");
Line("{");
_indent++;
}
public void EndInlineAttr()
{
_indent--;
Line("}");
Line(""); // indentation-only line, as fbx2dmx emits
}
public void BeginArrayElement(string className)
{
Line($"\"{className}\"");
Line("{");
_indent++;
}
public void EndArrayElement(bool last)
{
_indent--;
Line(last ? "}" : "},");
}
public void BeginArray(string name, string type = "element_array")
{
Line($"\"{name}\" \"{type}\" ");
Line("[");
_indent++;
}
public void EndArray()
{
_indent--;
Line("]");
}
public void ElementRef(string guid, bool last)
=> Line($"\"element\" \"{guid}\"" + (last ? "" : ","));
public void ArrayValue(string value, bool last)
=> Line($"\"{value}\"" + (last ? "" : ","));
public void EmptyBinaryAttr(string name)
{
Line($"\"{name}\" \"binary\" ");
Line("\"");
Line("\"");
}
public override string ToString() => _sb.ToString();
}
}
using System;
using System.Collections.Generic;
using HumanoidRetargeter.Cleanup;
using HumanoidRetargeter.Formats;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Solve;
using HumanoidRetargeter.Target;
namespace HumanoidRetargeter;
/// <summary>Which solver retargets a request's clips (design §10).</summary>
public enum SolverKind
{
/// <summary>The deterministic <see cref="Solve.GeometricSolver"/> (default; better
/// wherever a role mapping exists).</summary>
Geometric,
/// <summary>The experimental skeleton-agnostic deep-learning solver
/// (<see cref="Dl.DlSolver"/>, SAME pretrained checkpoint) — the no-profile fallback.
/// Requires <see cref="RetargetTargetSpec.DlWeights"/>; ignores per-role mapping
/// (only hips/alignment heuristics consult it) and leaves fingers at rest.</summary>
DeepLearning,
}
/// <summary>
/// One source animation file to retarget (engine-agnostic: bytes in, no file IO). Every
/// request runs its OWN profile detection, so a single batch may mix Mixamo + ActorCore +
/// BVH sources — unless <see cref="MappingOverride"/> supplies a mapping explicitly.
/// </summary>
public sealed class RetargetRequest
{
/// <summary>Solver choice for this request's clips. <see cref="SolverKind.DeepLearning"/>
/// requires the batch's <see cref="RetargetTargetSpec.DlWeights"/> to be set; the
/// conversion fails per-clip with a clear error otherwise.</summary>
public SolverKind Solver { get; init; } = SolverKind.Geometric;
/// <summary>Raw bytes of the source file (.fbx, .bvh, .glb, .gltf or .vrm).</summary>
public required byte[] SourceData { get; init; }
/// <summary>
/// Source file name (used for the report and DMX provenance). The extension drives the
/// format choice (<c>.fbx</c> / <c>.bvh</c> / <c>.glb</c> / <c>.gltf</c> / <c>.vrm</c> —
/// a VRM is a glTF container whose authored humanoid bone map becomes the mapping);
/// when the extension is unknown the content is sniffed (FBX binary magic /
/// "FBXHeaderExtension" / BVH "HIERARCHY" / GLB 'glTF' magic / glTF JSON).
/// </summary>
public required string SourceFileName { get; init; }
/// <summary>
/// Caller-supplied identity of this request, echoed verbatim on every produced
/// <see cref="ClipResult.SourceId"/> so callers can join results back to their own
/// entries unambiguously (e.g. the editor window passes the FULL file path here, since
/// two files in different folders may share the same <see cref="SourceFileName"/>).
/// Null = <see cref="SourceFileName"/>.
/// </summary>
public string? SourceId { get; init; }
/// <summary>
/// Import sample rate the source clips are resampled to (BVH native frames / FBX curves
/// are evaluated on this grid). Null = the importer default (30 fps).
/// </summary>
public float? SampleFps { get; init; }
/// <summary>
/// Restricts the conversion to ONE take of the source file (0-based index into the
/// imported scene's clips). Null = convert all takes. Out of range fails the request's
/// clip result with a clear error (the batch continues). UI listings that expand a
/// multi-take file into one entry per take submit one request per selected take.
/// When <see cref="ClipDefinitions"/> is set this index addresses the DEFINITIONS
/// instead (each definition is what a UI row represents then).
/// </summary>
public int? TakeIndex { get; init; }
/// <summary>
/// Optional external clip definitions, parsed from a Unity <c><file>.fbx.meta</c>
/// sidecar (<see cref="UnityMeta.ParseClipAnimations"/>): Unity animation packs ship FBX
/// files whose clips are sub-ranges of ONE source timeline. When set (non-empty), the
/// conversion produces one output clip per definition instead of one per take: the
/// definition's take (matched by <see cref="ExternalClipDef.TakeName"/>, falling back to
/// the file's first take) is sliced to the definition's native-frame range
/// (<see cref="UnityMeta.Slice"/>), named <see cref="ExternalClipDef.Name"/> (sanitized
/// like take names, collision-suffixed across the batch) and looped per
/// <see cref="ExternalClipDef.Loop"/> unless <see cref="LoopingOverride"/> is set.
/// <see cref="TakeIndex"/> then indexes INTO this list. Null = no definitions.
/// </summary>
public IReadOnlyList<ExternalClipDef>? ClipDefinitions { get; init; }
/// <summary>
/// UI-supplied mapping (manual mapping table or a user preset loaded Editor-side).
/// Null = auto-detect per request: preset profiles via <see cref="ProfileDetector"/>,
/// then the <see cref="AutoMapper"/> as best-effort fallback.
/// </summary>
public MappingResult? MappingOverride { get; init; }
/// <summary>Solver tunables (hip scales, finger transfer). ClipIndex/ClipName are managed
/// by the pipeline per take and ignored here. Null = defaults.</summary>
public SolveOptions? Solve { get; init; }
/// <summary>
/// Root-motion handling. <see cref="RootMotionMode.Extract"/> on a target without a
/// dedicated animated root bone (the s&box rig: pelvis is parentless, root_IK is
/// IkBaked) leaves the frames untouched and instead sets the ExtractMotion flag on the
/// clip's vmdl AnimFile entry — Source 2's compile-time extraction replaces the missing
/// bone-level extraction. <see cref="RootMotionMode.InPlace"/> always operates on the
/// hips directly.
/// </summary>
public RootMotionMode RootMotion { get; init; } = RootMotionMode.Off;
/// <summary>Run the Kovar foot-plant cleanup pass on the solved frames (default on).</summary>
public bool FootPlantCleanup { get; init; } = true;
/// <summary>
/// Optional arm end-effector IK pass pulling the wrists onto limb-length-normalized
/// source hand positions. Default OFF: the geometric solver already matches anatomical
/// directions, so arm IK only helps reach-critical work (props, contact poses) and can
/// otherwise disturb elbow styling.
/// </summary>
public bool ArmEffectorIk { get; init; }
/// <summary>
/// Generate <c>AE_FOOTSTEP</c> AnimEvent nodes on each produced clip's vmdl AnimFile
/// entry (default OFF). After solving and cleanup, foot-plant intervals are detected on
/// the SOLVED target clip (<see cref="Cleanup.FootPlant.DetectPlantIntervals"/>); each
/// plant's start frame is a touchdown and becomes one footstep event, in the exact node
/// shape the shipped citizen data uses (see <see cref="Target.FootstepEvents"/>).
/// Skipped (with a report note) when the target rig lacks complete leg chains.
/// </summary>
public bool GenerateFootstepEvents { get; init; }
/// <summary>
/// Additionally produce a mirrored twin of every converted clip (default OFF), named
/// <c><clip>_M</c> (collision-suffixed across the batch as usual). Mirroring runs
/// in TARGET space on the solved clip (<see cref="Solve.ClipMirror"/>): left/right role
/// bone channels swap and everything is reflected across the target character's sagittal
/// plane; IK-baked helper bones are re-baked from the mirrored body afterwards.
/// </summary>
public bool CreateMirroredVariant { get; init; }
/// <summary>
/// Additionally register an additive (delta) twin of every converted clip in the
/// generated/augmented vmdl (default OFF), named <c><clip>_delta</c> (the shipped
/// citizen naming; collision-suffixed across the batch as usual). The twin is a second
/// AnimFile entry REUSING the clip's DMX with an <c>AnimSubtract</c> child
/// (<c>anim_name</c> = the base sequence, <c>frame</c> = 0) — exactly the shipped
/// <c>IdleLayer_01</c>/<c>IdleLayer_01_delta</c> pattern, where resourcecompiler
/// subtracts the reference frame at compile time (no frame math happens here). The
/// resulting <c>_delta</c> sequence is what s&box layered animation additively
/// blends on top of a base pose.
/// </summary>
public bool CreateAdditiveVariant { get; init; }
/// <summary>Output clip name override; with multiple takes an index suffix is appended.
/// Null = the source take name.</summary>
public string? ClipNameOverride { get; init; }
/// <summary>Force the looping flag on the output sequence(s); null = the source clip's flag.</summary>
public bool? LoopingOverride { get; init; }
}
/// <summary>
/// Axis/unit convention of a <see cref="RetargetTargetSpec"/>'s rig data — drives the DMX
/// axis-system declaration, foot-plant threshold units, and the editor preview's
/// rig-space → engine-space conversion.
/// </summary>
public enum TargetUpAxis
{
/// <summary>
/// The s&box source convention: rig authored in centimeters, Y-up (the shipped
/// citizen rig, FBX targets). The vmdl's ScaleAndMirror 0.3937 + resourcecompiler's
/// Y-up→Z-up conversion take it to engine space at compile time. Default.
/// </summary>
YUpCm,
/// <summary>
/// Engine space already: rig read from a compiled model's <c>Model.Bones</c>
/// (inches, Z-up). The DMX declares a Z-up axis system so the compiler performs no
/// further axis conversion.
/// </summary>
ZUpEngine,
}
/// <summary>
/// The conversion target shared by all requests of one <see cref="Retargeter.Convert"/> /
/// <see cref="Retargeter.ConvertBatch"/> call: the rig plus the vmdl generation parameters.
/// </summary>
public sealed class RetargetTargetSpec
{
/// <summary>The s&box-source → engine-units vmdl scale (cm rigs like the citizen).</summary>
public const float SboxSourceScale = 0.3937f;
/// <summary>The committed asset path of the s&box human male model.</summary>
public const string SboxHumanMalePath = "models/citizen_human/citizen_human_male.vmdl";
/// <summary>The committed asset path of the classic (4-finger) s&box citizen model.</summary>
public const string SboxCitizenPath = "models/citizen/citizen.vmdl";
/// <summary>Target rig (skeleton + bone classes + roles).</summary>
public required TargetRig Rig { get; init; }
/// <summary>ModelModifier_ScaleAndMirror scale written into standalone vmdls:
/// <c>0.3937</c> for cm-authored s&box-source rigs, <c>1.0</c> for engine-unit rigs
/// (the modifier node is omitted at 1.0).</summary>
public required float VmdlScale { get; init; }
/// <summary>base_model_name of generated standalone vmdls (the model that owns the mesh).</summary>
public string BaseModelPath { get; init; } = "";
/// <summary>default_root_bone_name of the generated AnimationList (also the bone vmdl
/// ExtractMotion nodes operate on).</summary>
public string DefaultRootBone { get; init; } = "pelvis";
/// <summary>
/// Axis/unit convention of <see cref="Rig"/>. <see cref="TargetUpAxis.YUpCm"/> (default)
/// for cm Y-up source-space rigs (DMX declares Y-up, compiler converts);
/// <see cref="TargetUpAxis.ZUpEngine"/> for rigs read from compiled engine models
/// (DMX declares Z-up so no double conversion happens at compile, and cm-tuned cleanup
/// thresholds are rescaled to inches).
/// </summary>
public TargetUpAxis UpAxis { get; init; } = TargetUpAxis.YUpCm;
/// <summary>
/// Raw bytes of the committed SAME weight blob
/// (<c>Assets/humanoid_retargeter/dl/same_v1.weights</c>; callers do the file IO).
/// Required only when a request selects <see cref="SolverKind.DeepLearning"/>; the
/// solver instance is built once per batch from these bytes.
/// </summary>
public byte[]? DlWeights { get; init; }
/// <summary>
/// The shipped s&box default target: rig parsed from the committed
/// <c>Assets/humanoid_retargeter/target_rig_sbox.json</c> text (callers do the file IO),
/// 0.3937 vmdl scale, citizen human male base model, pelvis root. Pass the committed
/// SAME weight bytes as <paramref name="dlWeights"/> to enable the deep-learning solver.
/// </summary>
public static RetargetTargetSpec SboxDefault(string targetRigJson, byte[]? dlWeights = null) => new()
{
Rig = TargetRig.SboxDefault(targetRigJson),
VmdlScale = SboxSourceScale,
BaseModelPath = SboxHumanMalePath,
DefaultRootBone = "pelvis",
DlWeights = dlWeights,
};
/// <summary>
/// The classic (4-finger) s&box citizen target: rig parsed from the committed
/// <c>Assets/humanoid_retargeter/target_rig_sbox_citizen.json</c> text (callers do the
/// file IO), 0.3937 vmdl scale, citizen base model, pelvis root, Y-up cm. The rig has no
/// pinky bones, so pinky roles stay unassigned — the engine's own constraints handle the
/// pinky at runtime for models that have one. Pass the committed SAME weight bytes as
/// <paramref name="dlWeights"/> to enable the deep-learning solver.
/// </summary>
public static RetargetTargetSpec SboxCitizen(string targetRigJson, byte[]? dlWeights = null) => new()
{
Rig = TargetRig.Load(targetRigJson),
VmdlScale = SboxSourceScale,
BaseModelPath = SboxCitizenPath,
DefaultRootBone = "pelvis",
UpAxis = TargetUpAxis.YUpCm,
DlWeights = dlWeights,
};
}
/// <summary>Options for <see cref="Retargeter.ConvertBatch"/> output assembly.</summary>
public sealed class BatchOptions
{
/// <summary>
/// When set, the batch additionally augments this existing vmdl text (all successful
/// clips spliced into its AnimationList via <see cref="VmdlAugmenter"/>) and returns the
/// result in <see cref="RetargetBatchResult.AugmentedVmdl"/>.
/// </summary>
public string? AugmentVmdlText { get; init; }
/// <summary>Assets-relative folder the DMX files will be written to by the caller; used
/// to build each AnimFile's <c>source_filename</c>.</summary>
public string DmxFolderRelative { get; init; } = "animations/retargeted";
/// <summary>Auto-suffix colliding clip names (<c>_2</c>, <c>_3</c>, …) across the whole
/// batch (default on). When off, duplicate names are kept as-is.</summary>
public bool AutoSuffixCollisions { get; init; } = true;
/// <summary>
/// After conversion, scan the batch's successful clip names for directional locomotion
/// families (default OFF): <c>_N</c>/<c>_NE</c>/…/<c>_NW</c> compass suffixes and
/// <c>_Forward</c>/<c>_Backward</c>(/<c>_Back</c>)/<c>_Left</c>/<c>_Right</c> word forms
/// sharing a stem. Each complete family (all four cardinals) is grouped under a Folder
/// node with a <c>2DBlend</c> wired to the citizen <c>move_x</c>/<c>move_y</c> pose
/// parameters, replicating the shipped citizen locomotion layout (see
/// <see cref="Target.LocomotionSetDetector"/>); detection results land on
/// <see cref="RetargetBatchResult.LocomotionSets"/>. Custom (non-citizen) base models
/// must declare <c>move_x</c>/<c>move_y</c> pose parameters themselves for the blends to
/// be drivable.
/// </summary>
public bool DetectLocomotionSets { get; init; }
}
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Maths;
namespace HumanoidRetargeter.Solve;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>
/// Hand rest-geometry helpers shared by <see cref="CanonicalFrames"/> (finger secondary axes)
/// and <see cref="RestNormalizer"/> (palm-down roll correction). Everything derives from joint
/// positions only — bone local axes carry no anatomical meaning on the s&box rig.
/// </summary>
internal static class HandGeometry
{
private static readonly BoneRole[] LeftProximals =
{
BoneRole.ThumbProxL, BoneRole.IndexProxL, BoneRole.MiddleProxL, BoneRole.RingProxL, BoneRole.PinkyProxL,
};
private static readonly BoneRole[] RightProximals =
{
BoneRole.ThumbProxR, BoneRole.IndexProxR, BoneRole.MiddleProxR, BoneRole.RingProxR, BoneRole.PinkyProxR,
};
// Index → pinky order; the knuckle line is taken from the first and last mapped of these.
private static readonly BoneRole[] LeftNonThumbProximals =
{
BoneRole.IndexProxL, BoneRole.MiddleProxL, BoneRole.RingProxL, BoneRole.PinkyProxL,
};
private static readonly BoneRole[] RightNonThumbProximals =
{
BoneRole.IndexProxR, BoneRole.MiddleProxR, BoneRole.RingProxR, BoneRole.PinkyProxR,
};
/// <summary>
/// Midpoint of all mapped finger proximal heads of one hand (the hand's anatomical
/// "chain child" point), or null when no finger proximal is mapped.
/// </summary>
public static Vector3? FingerProximalMidpoint(MappingResult map, IReadOnlyList<XForm> worldRest, bool left)
{
var sum = Vector3.Zero;
var count = 0;
foreach (var role in left ? LeftProximals : RightProximals)
{
if (map.RoleToBone.TryGetValue(role, out var index))
{
sum += worldRest[index].Pos;
count++;
}
}
return count > 0 ? sum / count : null;
}
/// <summary>
/// Dorsal palm normal of one hand: the unit vector pointing out of the <b>back</b> of the
/// hand (away from the palm), or null when the hand/finger geometry is unmapped or
/// degenerate.
/// </summary>
/// <remarks>
/// Formula (mirror-consistent by construction, verified on the ActorCore fixture by the
/// finger-curl test): <c>dorsal = sideSign · cross(knuckle, fingerDir)</c> with
/// <c>sideSign = +1</c> left / <c>−1</c> right, <c>knuckle = IndexProx.head −
/// PinkyProx.head</c> (first/last mapped non-thumb proximal), and <c>fingerDir =
/// FingerProximalMidpoint − Hand.head</c>. On every fixture rig the thumb proximal lies on
/// the −dorsal (palmar) side of the hand plane, grounding the sign anatomically. A positive
/// rotation about a finger frame's hinge axis (frame Y = cross(dorsal, fingerChainDir))
/// curls the fingertip toward the palm on <b>both</b> hands.
/// </remarks>
public static Vector3? Dorsal(MappingResult map, IReadOnlyList<XForm> worldRest, bool left)
{
if (!map.RoleToBone.TryGetValue(left ? BoneRole.HandL : BoneRole.HandR, out var handIndex))
return null;
var hand = worldRest[handIndex].Pos;
var nonThumb = left ? LeftNonThumbProximals : RightNonThumbProximals;
Vector3? first = null, last = null;
foreach (var role in nonThumb)
{
if (!map.RoleToBone.TryGetValue(role, out var index))
continue;
first ??= worldRest[index].Pos;
last = worldRest[index].Pos;
}
if (first is null || last is null || (first.Value - last.Value).LengthSquared() < 1e-8f)
return null;
var midpoint = FingerProximalMidpoint(map, worldRest, left);
if (midpoint is null)
return null;
var knuckle = first.Value - last.Value;
var fingerDir = midpoint.Value - hand;
var raw = Vector3.Cross(knuckle, fingerDir) * (left ? 1f : -1f);
return raw.LengthSquared() < 1e-8f ? null : Vector3.Normalize(raw);
}
}
using Sandbox.UI;
namespace Sandbox;
public interface ICleanupEvents
{
public void OnCleanup( int removedObjects, int restoredObjects );
}
/// <summary>
/// A system that tracks the baseline scene state and allows resetting the map to its original state.
/// Removes all spawned props and restores destroyed map objects while leaving players untouched.
/// </summary>
internal sealed class CleanupSystem : GameObjectSystem<CleanupSystem>, ISceneLoadingEvents
{
/// <summary>
/// Set of GameObjects that existed in the original scene baseline.
/// </summary>
private readonly HashSet<Guid> _baselineObjectIds = new();
/// <summary>
/// Serialized data of baseline objects so we can restore them if destroyed.
/// </summary>
private readonly Dictionary<Guid, string> _baselineObjectData = new();
private static bool _restorePersistedBaseline;
private static HashSet<Guid> _persistedBaselineIds;
private static Dictionary<Guid, string> _persistedBaselineData;
/// <summary>
/// Whether a baseline has been captured.
/// </summary>
public bool HasBaseline => _baselineObjectIds.Count > 0;
public CleanupSystem( Scene scene ) : base( scene )
{
}
/// <summary>
/// Call from SaveSystem before Game.ChangeScene() to snapshot the current baseline
/// </summary>
public static void PreserveBaselineForSaveLoad()
{
if ( Current is null || !Current.HasBaseline ) return;
_restorePersistedBaseline = true;
_persistedBaselineIds = new HashSet<Guid>( Current._baselineObjectIds );
_persistedBaselineData = new Dictionary<Guid, string>( Current._baselineObjectData );
}
void ISceneLoadingEvents.BeforeLoad( Scene scene, SceneLoadOptions options )
{
// Clear any existing baseline when a new scene is loading
_baselineObjectIds.Clear();
_baselineObjectData.Clear();
}
async Task ISceneLoadingEvents.OnLoad( Scene scene, SceneLoadOptions options, LoadingContext context )
{
// We don't care if the game is not playing
if ( !Game.IsPlaying ) return;
// Wait for next frame to ensure all objects are spawned
await Task.Yield();
// Could be null if the scene was unloaded before this runs
if ( !Scene.IsValid() ) return;
// When loading a save, restore the baseline captured before the scene was destroyed
if ( _restorePersistedBaseline && _persistedBaselineIds is not null )
{
_baselineObjectIds.UnionWith( _persistedBaselineIds );
foreach ( var kvp in _persistedBaselineData )
_baselineObjectData.TryAdd( kvp.Key, kvp.Value );
_restorePersistedBaseline = false;
Log.Info( $"CleanupSystem: Restored persisted baseline with {_baselineObjectIds.Count} objects." );
}
else
{
CaptureBaseline();
}
}
/// <summary>
/// Captures the current scene state as the baseline.
/// All objects that exist at this point are considered part of the original map.
/// </summary>
public void CaptureBaseline()
{
_baselineObjectIds.Clear();
_baselineObjectData.Clear();
foreach ( var go in Scene.Children?.ToArray() ?? [] )
{
CaptureObjectRecursive( go );
}
Log.Info( $"CleanupSystem: Captured baseline with {_baselineObjectIds.Count} objects." );
}
private void CaptureObjectRecursive( GameObject go )
{
if ( !go.IsValid() )
return;
// Skip player objects
if ( IsPlayerObject( go ) )
return;
if ( go.Flags.Contains( GameObjectFlags.DontDestroyOnLoad ) )
return;
_baselineObjectIds.Add( go.Id );
var serialized = go.Serialize();
if ( serialized is not null )
{
_baselineObjectData[go.Id] = serialized.ToJsonString();
}
foreach ( var child in go.Children?.ToArray() ?? [] )
{
CaptureObjectRecursive( child );
}
}
/// <summary>
/// Determines if a GameObject is a player or belongs to a player.
/// </summary>
private static bool IsPlayerObject( GameObject go )
{
if ( !go.IsValid() )
return false;
if ( go.Components.Get<Player>( true ) is not null )
return true;
if ( go.Components.Get<PlayerData>( true ) is not null )
return true;
var parent = go.Parent;
while ( parent is not null && parent != go.Scene )
{
if ( parent.Components.Get<Player>( true ) is not null )
return true;
if ( parent.Components.Get<PlayerData>( true ) is not null )
return true;
parent = parent.Parent;
}
return false;
}
/// <summary>
/// Cleans up the scene by removing all spawned objects and restoring destroyed baseline objects.
/// Players and their belongings are preserved.
/// </summary>
public void Cleanup()
{
if ( !HasBaseline )
{
Log.Warning( "CleanupSystem: No baseline captured. Cannot cleanup." );
return;
}
if ( !Networking.IsHost )
{
Log.Warning( "CleanupSystem: Only the host can perform cleanup." );
return;
}
var removedCount = 0;
var restoredCount = 0;
var objectsToRemove = new List<GameObject>();
var existingBaselineIds = new HashSet<Guid>();
foreach ( var go in Scene.GetAllObjects( true ) )
{
if ( !go.IsValid() )
continue;
// Never remove player objects
if ( IsPlayerObject( go ) )
continue;
if ( go.Flags.Contains( GameObjectFlags.DontDestroyOnLoad ) )
continue;
if ( _baselineObjectIds.Contains( go.Id ) )
{
existingBaselineIds.Add( go.Id );
}
else
{
if ( go.Parent == Scene )
{
objectsToRemove.Add( go );
}
}
}
// Remove spawned objects
foreach ( var go in objectsToRemove )
{
if ( go.IsValid() )
{
go.Destroy();
removedCount++;
}
}
// Restore destroyed baseline objects
foreach ( var kvp in _baselineObjectData )
{
var id = kvp.Key;
// Skip if the object still exists
if ( existingBaselineIds.Contains( id ) )
continue;
// Skip if we already processed the parent object
var go = Scene.Directory.FindByGuid( id );
if ( go.IsValid() )
continue;
try
{
var json = System.Text.Json.Nodes.JsonNode.Parse( kvp.Value );
if ( json is System.Text.Json.Nodes.JsonObject jso )
{
var restored = new GameObject();
restored.Deserialize( jso );
restoredCount++;
}
}
catch ( System.Exception ex )
{
Log.Warning( $"CleanupSystem: Failed to restore object {id}: {ex.Message}" );
}
}
BroadcastCleanup( removedCount, restoredCount );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
private static void BroadcastCleanup( int removedObjects, int restoredObjects )
{
Game.ActiveScene?.RunEvent<ICleanupEvents>( x => x.OnCleanup( removedObjects, restoredObjects ) );
Log.Info( $"Cleanup complete. Removed {removedObjects} spawned objects, restored {restoredObjects} destroyed objects." );
}
/// <summary>
/// Console command to cleanup the map.
/// </summary>
[ConCmd( "cleanup" )]
public static void CleanupCommand( string targetName = null )
{
if ( !Networking.IsHost ) return;
//
// Targeted cleanup, doesn't use the same cleanup shit
//
if ( !string.IsNullOrEmpty( targetName ) )
{
var target = GameManager.FindPlayerWithName( targetName );
if ( target is not null )
{
CleanupPlayer( target );
}
else
{
Notices.AddNotice( "cleaning_services", Color.Red, $"Can't find {targetName} to clean up" );
}
return;
}
if ( Current is null )
{
Log.Warning( "CleanupSystem: No active cleanup system." );
return;
}
Current.Cleanup();
}
[Rpc.Host]
public static void RpcCleanUpMine()
{
CleanupPlayer( Rpc.Caller );
}
[Rpc.Host]
public static void RpcCleanUpAll()
{
if ( !Rpc.Caller.HasPermission( "admin" ) ) return;
Current?.Cleanup();
}
[Rpc.Host]
public static void RpcCleanUpTarget( Connection target )
{
if ( !Rpc.Caller.HasPermission( "admin" ) ) return;
CleanupPlayer( target );
}
public static void CleanupPlayer( Connection caller )
{
Assert.True( Networking.IsHost, "Only the host may call this method!" );
var removable = Game.ActiveScene.GetAllComponents<Ownable>()
.Where( o => o.Owner == caller );
var count = 0;
foreach ( var ownable in removable.ToArray() )
{
ownable.GameObject.Destroy();
count++;
}
Notices.SendNotice( caller, "cleaning_services", Color.Green, $"Cleaned up {count} objects" );
}
}
[Alias( "dynamite" )]
public sealed class DynamiteEntity : Component, IPlayerControllable, Component.IDamageable
{
[Property, Range( 1, 500 ), Step( 1 ), ClientEditable]
public float Damage { get; set; } = 128;
[Property, Range( 16, 4096 ), Step( 16 ), ClientEditable]
public float Radius { get; set; } = 1024f;
[Property, Range( 1, 100 ), Step( 1 ), ClientEditable]
public float Force { get; set; } = 1;
[Property, Sync, ClientEditable]
public ClientInput Activate { get; set; }
bool _isDead = false;
[Rpc.Host]
public void Explode()
{
_isDead = true;
var explosionPrefab = ResourceLibrary.Get<PrefabFile>( "/prefabs/engine/explosion_med.prefab" );
if ( explosionPrefab == null )
{
Log.Warning( "Can't find /prefabs/engine/explosion_med.prefab" );
return;
}
var go = GameObject.Clone( explosionPrefab, new CloneConfig { Transform = WorldTransform.WithScale( 1 ), StartEnabled = false } );
if ( !go.IsValid() ) return;
go.RunEvent<RadiusDamage>( x =>
{
x.Radius = Radius;
x.PhysicsForceScale = Force;
x.DamageAmount = Damage;
x.Attacker = go;
}, FindMode.EverythingInSelfAndDescendants );
go.Enabled = true;
go.NetworkSpawn( true, null );
GameObject.Destroy();
}
void IDamageable.OnDamage( in DamageInfo damage )
{
if ( _isDead ) return;
if ( IsProxy ) return;
Explode();
}
void IPlayerControllable.OnControl()
{
if ( Activate.Pressed() )
{
Explode();
}
}
void IPlayerControllable.OnEndControl()
{
// nothing to do
}
void IPlayerControllable.OnStartControl()
{
// nothing to do
}
}
/// <summary>
/// Whether the emitter fires while the input is held, or toggles on/off with a press.
/// </summary>
public enum EmitMode
{
/// <summary>
/// Press once to turn on, press again to turn off.
/// </summary>
Toggle,
/// <summary>
/// Emits only while the input is held down.
/// </summary>
Hold,
}
/// <summary>
/// A world-placed SENT that spawns and controls a particle/VFX emitter.
/// The emitter prefab is defined by a <see cref="ScriptedEmitter"/> resource.
/// </summary>
[Alias( "emitter" )]
public sealed class EmitterEntity : Component, IPlayerControllable
{
/// <summary>
/// The emitter definition points to a prefab containing a particle system.
/// </summary>
[Property, ClientEditable]
public ScriptedEmitter Emitter { get; set; }
/// <summary>
/// Whether this emitter toggles on/off with a press, or emits only while held.
/// </summary>
[Property, ClientEditable]
public EmitMode Mode { get; set; } = EmitMode.Toggle;
/// <summary>
/// Used when <see cref="Mode"/> is <see cref="EmitMode.Toggle"/>.
/// </summary>
[Property, Sync, ClientEditable, Group( "Input" )]
public ClientInput ToggleInput { get; set; }
/// <summary>
/// Used when <see cref="Mode"/> is <see cref="EmitMode.Hold"/>.
/// </summary>
[Property, Sync, ClientEditable, Group( "Input" )]
public ClientInput HoldInput { get; set; }
/// <summary>
/// Whether the emitter is currently active. Synced to all clients.
/// </summary>
[Sync] public bool IsEmitting { get; private set; }
/// <summary>
/// When enabled, forces the emitter on regardless of input or mode.
/// Can be set from the editor or wired up externally.
/// </summary>
[Property, ClientEditable]
public bool ManualOn
{
get => _manualOn;
set { _manualOn = value; if ( !IsProxy ) UpdateEmitState(); }
}
private bool _manualOn;
private bool _inputEmitting;
private GameObject _particleInstance;
private ScriptedEmitter _lastEmitter;
protected override void OnStart() { }
protected override void OnUpdate()
{
// Emitter resource changed — destroy existing instance so it gets recreated
if ( _lastEmitter != Emitter && _particleInstance.IsValid() )
DestroyParticle();
_lastEmitter = Emitter;
if ( IsEmitting && !_particleInstance.IsValid() )
SpawnParticle();
else if ( !IsEmitting && _particleInstance.IsValid() )
DestroyParticle();
}
void IPlayerControllable.OnStartControl() { }
void IPlayerControllable.OnEndControl()
{
if ( Mode == EmitMode.Hold )
{
_inputEmitting = false;
UpdateEmitState();
}
}
void IPlayerControllable.OnControl()
{
if ( Mode == EmitMode.Toggle )
{
if ( ToggleInput.Pressed() )
{
_inputEmitting = !_inputEmitting;
UpdateEmitState();
}
}
else
{
var held = HoldInput.Down();
if ( held != _inputEmitting )
{
_inputEmitting = held;
UpdateEmitState();
}
}
}
private void UpdateEmitState() => SetEmitting( _inputEmitting || _manualOn );
[Rpc.Broadcast]
private void SetEmitting( bool active )
{
IsEmitting = active;
}
private void SpawnParticle()
{
if ( !Emitter.IsValid() || Emitter.Prefab is null ) return;
_particleInstance = GameObject.Clone( Emitter.Prefab, new CloneConfig
{
Parent = GameObject,
Transform = new Transform( Vector3.Forward * 4f ),
StartEnabled = true,
} );
}
private void DestroyParticle()
{
_particleInstance.Destroy();
_particleInstance = null;
}
}
public sealed class SpotLightEntity : Component, IPlayerControllable
{
[Property, ClientEditable, Group( "Light" )]
public bool On { get; set { field = value; UpdateLight(); } } = true;
[Property, ClientEditable, Group( "Light" )]
public bool Shadows { get; set { field = value; UpdateLight(); } } = true;
[Property, Range( 0, 1 ), ClientEditable, Group( "Light" )]
public Color Color { get; set { field = value; UpdateLight(); } }
[Property, Range( 0, 50 ), ClientEditable, Group( "Light" )]
public float Brightness { get; set { field = value; UpdateLight(); } } = 2;
[Property, Range( 0, 1000 ), ClientEditable, Group( "Light" )]
public float Radius { get; set { field = value; UpdateLight(); } } = 500;
[Property, Range( 0, 90 ), ClientEditable, Group( "Light" )]
public float Angle { get; set { field = value; UpdateLight(); } } = 35;
[Property, Range( 0, 16 ), ClientEditable, Group( "Light" )]
public float Attenuation { get; set { field = value; UpdateLight(); } } = 2.4f;
[Property, Sync, ClientEditable, Group( "State" )]
public ClientInput TurnOn { get; set; }
[Property, Sync, ClientEditable, Group( "State" )]
public ClientInput TurnOff { get; set; }
[Property, Sync, ClientEditable, Group( "State" )]
public ClientInput Toggle { get; set; }
[Property]
public GameObject OnGameObject { get; set; }
[Property]
public GameObject OffGameObject { get; set; }
void IPlayerControllable.OnControl()
{
if ( Toggle.Pressed() )
{
On = !On;
}
if ( TurnOn.Pressed() )
{
On = true;
}
if ( TurnOff.Pressed() )
{
On = false;
}
}
void IPlayerControllable.OnEndControl()
{
}
void IPlayerControllable.OnStartControl()
{
}
void UpdateLight()
{
OnGameObject?.Enabled = On;
OffGameObject?.Enabled = !On;
if ( GetComponentInChildren<SpotLight>( true ) is not SpotLight light )
return;
light.Enabled = On;
var color = Color;
color.r *= Brightness;
color.g *= Brightness;
color.b *= Brightness;
light.Shadows = Shadows;
light.LightColor = color;
light.Radius = Radius;
light.Attenuation = Attenuation;
light.ConeOuter = Angle;
light.ConeInner = Angle * 0.5f;
Network.Refresh();
}
}
public partial class BaseBulletWeapon : BaseWeapon
{
[Property]
public SoundEvent ShootSound { get; set; }
[Property, Group( "Bullet" )]
public BulletConfiguration Bullet { get; set; } = new()
{
Damage = 12f,
BulletRadius = 1f,
Range = 4096f,
AimConeBase = new Vector2( 0.5f, 0.25f ),
AimConeSpread = new Vector2( 3f, 3f ),
AimConeRecovery = 0.2f,
RecoilPitch = new Vector2( -0.3f, -0.1f ),
RecoilYaw = new Vector2( -0.1f, 0.1f ),
CameraRecoilStrength = 1f,
CameraRecoilFrequency = 1f,
};
[Property, Group( "Bullet" ), ClientEditable, Range( 0f, 500000f ), Step( 10f )]
public float ShootForce { get; set; } = 100000f;
protected TimeSince TimeSinceShoot = 0;
/// <summary>
/// Returns 0 for no aim spread, 1 for full aim cone, based on time since last shot.
/// </summary>
protected float GetAimConeAmount( float recovery )
{
return TimeSinceShoot.Relative.Remap( 0, recovery, 1, 0 );
}
/// <summary>
/// Returns the aim cone amount using the configured recovery time
/// </summary>
protected float GetAimConeAmount()
{
return GetAimConeAmount( Bullet.AimConeRecovery );
}
/// <inheritdoc cref="ShootBullet(float, in BulletConfiguration)"/>
protected void ShootBullet( float fireRate )
{
ShootBullet( fireRate, Bullet );
}
/// <summary>
/// Shoot a bullet out of the front of the gun.
/// When held by a player, fires from the player's eye with aim cone and recoil.
/// When standalone (no owner), fires straight from the weapon's muzzle.
/// </summary>
protected void ShootBullet( float fireRate, in BulletConfiguration config )
{
if ( HasOwner && ( !HasAmmo() || IsReloading() ) )
{
TryAutoReload();
return;
}
if ( TimeUntilNextShotAllowed > 0 )
return;
// Only consume ammo when held by a player
if ( HasOwner && !TakeAmmo( 1 ) )
{
AddShootDelay( 0.2f );
return;
}
AddShootDelay( fireRate );
var aimConeAmount = GetAimConeAmount( config.AimConeRecovery );
var forward = AimRay.Forward
.WithAimCone(
config.AimConeBase.x + aimConeAmount * config.AimConeSpread.x,
config.AimConeBase.y + aimConeAmount * config.AimConeSpread.y
);
var traceRay = AimRay with { Forward = forward };
var tr = Scene.Trace.Ray( traceRay, config.Range )
.IgnoreGameObjectHierarchy( AimIgnoreRoot )
.WithCollisionRules( "bullet" )
.WithoutTags( "playercontroller" )
.Radius( config.BulletRadius )
.UseHitboxes()
.Run();
ShootEffects( tr.EndPosition, tr.Hit, tr.Normal, tr.GameObject, tr.Surface );
TraceAttack( TraceAttackInfo.From( tr, config.Damage ) );
TimeSinceShoot = 0;
// Recoil only applies when held by a player
if ( !HasOwner )
{
// Simulate physical recoil by pushing the weapon opposite to its fire direction
if ( ShootForce > 0f && GetComponent<Rigidbody>( true ) is var rb )
{
var muzzle = WeaponModel?.MuzzleTransform?.WorldTransform ?? WorldTransform;
rb.ApplyForce( muzzle.Rotation.Up * ShootForce );
}
return;
}
Owner.Controller.EyeAngles += new Angles(
Random.Shared.Float( config.RecoilPitch.x, config.RecoilPitch.y ),
Random.Shared.Float( config.RecoilYaw.x, config.RecoilYaw.y ),
0
);
if ( !Owner.Controller.ThirdPerson && Owner.IsLocalPlayer )
{
_ = new Sandbox.CameraNoise.Recoil( config.CameraRecoilStrength, config.CameraRecoilFrequency );
}
}
[Rpc.Broadcast]
public void ShootEffects( Vector3 hitpoint, bool hit, Vector3 normal, GameObject hitObject, Surface hitSurface, Vector3? origin = null, bool noEvents = false )
{
if ( Application.IsDedicatedServer ) return;
if ( !hitSurface.IsValid() ) return;
Owner?.Controller.Renderer.Set( "b_attack", true );
if ( !noEvents )
{
if ( WeaponModel.IsValid() )
{
WeaponModel.GameObject.RunEvent<WeaponModel>( x => x.OnAttack() );
WeaponModel.GameObject.RunEvent<WeaponModel>( x => x.CreateRangedEffects( this, hitpoint, origin ) );
}
if ( ShootSound.IsValid() )
{
var snd = GameObject.PlaySound( ShootSound );
// If we're shooting, the sound should not be spatialized
if ( HasOwner && Owner.IsLocalPlayer && snd.IsValid() )
{
snd.SpacialBlend = 0;
}
}
}
if ( !hit || !hitObject.IsValid() )
return;
var baseSurface = hitSurface.GetBaseSurface();
var bulletSound = hitSurface.SoundCollection.Bullet ?? baseSurface?.SoundCollection.Bullet;
if ( bulletSound.IsValid() )
{
Sound.Play( bulletSound, hitpoint );
}
var prefab = hitSurface.PrefabCollection.BulletImpact ?? baseSurface?.PrefabCollection.BulletImpact;
// Still null?
if ( prefab is null )
return;
var fwd = Rotation.LookAt( normal * -1.0f, Vector3.Random );
var impact = prefab.Clone();
impact.WorldPosition = hitpoint;
impact.WorldRotation = fwd;
impact.SetParent( hitObject, true );
if ( hitObject.GetComponentInChildren<SkinnedModelRenderer>() is not { CreateBoneObjects: true } skinned )
return;
// find closest bone
var bones = skinned.GetBoneTransforms( true );
var closestDist = float.MaxValue;
for ( var i = 0; i < bones.Length; i++ )
{
var bone = bones[i];
var dist = bone.Position.Distance( hitpoint );
if ( dist < closestDist )
{
closestDist = dist;
impact.SetParent( skinned.GetBoneObject( i ), true );
}
}
}
public record struct BulletConfiguration
{
public float Damage { get; set; }
public float BulletRadius { get; set; }
public Vector2 AimConeBase { get; set; }
public Vector2 AimConeSpread { get; set; }
public float AimConeRecovery { get; set; }
public Vector2 RecoilPitch { get; set; }
public Vector2 RecoilYaw { get; set; }
public float CameraRecoilStrength { get; set; }
public float CameraRecoilFrequency { get; set; }
public float Range { get; set; }
}
}
using System.Threading;
public partial class BaseWeapon
{
/// <summary>
/// Should we consume 1 bullet per reload instead of filling the clip?
/// </summary>
[Property, Feature( "Ammo" )]
public bool IncrementalReloading { get; set; } = false;
/// <summary>
/// Extra delay after the first shell reload before subsequent shells begin (e.g. longer carrier insertion animation).
/// Only used with incremental reloading. If zero, no extra delay is added.
/// </summary>
[Property, Feature( "Ammo" ), ShowIf( nameof( IncrementalReloading ), true )]
public float FirstShellReloadTime { get; set; } = 0f;
/// <summary>
/// Delay before the first shell is inserted during incremental reload.
/// If zero, uses <see cref="ReloadTime"/>.
/// </summary>
[Property, Feature( "Ammo" ), ShowIf( nameof( IncrementalReloading ), true )]
public float ReloadStartTime { get; set; } = 0f;
/// <summary>
/// Can we cancel reloads?
/// </summary>
[Property, Feature( "Ammo" )]
public bool CanCancelReload { get; set; } = true;
private CancellationTokenSource reloadToken;
private bool isReloading;
public bool CanReload()
{
if ( !UsesClips ) return false;
if ( ClipContents >= ClipMaxSize ) return false;
if ( isReloading ) return false;
if ( !WeaponConVars.InfiniteReserves && ReserveAmmo <= 0 ) return false;
return true;
}
public bool IsReloading() => isReloading;
public virtual void CancelReload()
{
if ( reloadToken?.IsCancellationRequested == false )
{
reloadToken?.Cancel();
isReloading = false;
ViewModel?.RunEvent<ViewModel>( x => x.OnReloadCancel() );
}
}
public virtual async void OnReloadStart()
{
if ( !CanReload() )
return;
CancelReload();
var cts = new CancellationTokenSource();
reloadToken = cts;
isReloading = true;
try
{
await ReloadAsync( cts.Token );
}
finally
{
// Only clean up our own reload
if ( reloadToken == cts )
{
isReloading = false;
reloadToken = null;
}
cts.Dispose();
}
}
[Rpc.Broadcast]
private void BroadcastReload()
{
if ( !HasOwner ) return;
Assert.True( Owner.Controller.IsValid(), "BaseWeapon::BroadcastReload - Player Controller is invalid!" );
Assert.True( Owner.Controller.Renderer.IsValid(), "BaseWeapon::BroadcastReload - Renderer is invalid!" );
Owner.Controller.Renderer.Set( "b_reload", true );
}
protected virtual async Task ReloadAsync( CancellationToken ct )
{
// Capture so we can tell if a newer reload has replaced us by the time finally runs.
var mySource = reloadToken;
var isFirstShell = ClipContents == 0;
try
{
ViewModel?.RunEvent<ViewModel>( x => x.OnReloadStart() );
BroadcastReload();
var firstIteration = true;
while ( ClipContents < ClipMaxSize && !ct.IsCancellationRequested )
{
var delay = (firstIteration && IncrementalReloading && ReloadStartTime > 0f) ? ReloadStartTime : ReloadTime;
firstIteration = false;
await Task.DelaySeconds( delay, ct );
var needed = IncrementalReloading ? 1 : (ClipMaxSize - ClipContents);
if ( WeaponConVars.InfiniteReserves )
{
ViewModel?.RunEvent<ViewModel>( x => x.OnIncrementalReload( isFirstShell ) );
ClipContents += needed;
}
else
{
var available = Math.Min( needed, ReserveAmmo );
if ( available <= 0 )
break;
ViewModel?.RunEvent<ViewModel>( x => x.OnIncrementalReload( isFirstShell ) );
ReserveAmmo -= available;
ClipContents += available;
}
// After the first shell, wait longer before the next one starts
if ( isFirstShell && FirstShellReloadTime > 0f )
{
await Task.DelaySeconds( FirstShellReloadTime, ct );
}
isFirstShell = false;
}
}
finally
{
if ( reloadToken == mySource )
{
ViewModel?.RunEvent<ViewModel>( x => x.OnReloadFinish() );
}
}
}
}
/// <summary>
/// The local user's preferences in Deathmatch
/// </summary>
internal static class GamePreferences
{
/// <summary>
/// Enables automatic switching to better weapons on item pickup
/// </summary>
[ConVar( "sb.autoswitch", ConVarFlags.UserInfo | ConVarFlags.Saved )]
public static bool AutoSwitch { get; set; } = true;
/// <summary>
/// Enables fast switching between inventory weapons
/// </summary>
[ConVar( "sb.fastswitch", ConVarFlags.Saved )]
public static bool FastSwitch { get; set; } = false;
/// <summary>
/// Intensity of your camera's screenshake
/// </summary>
[ConVar( "sb.viewbob", ConVarFlags.Saved )]
[Group( "Camera" )]
public static bool ViewBobbing { get; set; } = true;
/// <summary>
/// Intensity of your camera's screenshake
/// </summary>
[ConVar( "sb.screenshake", ConVarFlags.Saved )]
[Range( 0.1f, 2f ), Step( 0.1f ), Group( "Camera" )]
public static float Screenshake { get; set; } = 0.3f;
}
namespace Sandbox.Npcs;
/// <summary>
/// Console variables that control NPC AI behaviour globally.
/// </summary>
public static class NpcConVars
{
/// <summary>
/// When disabled, all NPC AI thinking is paused — they just stand idle.
/// </summary>
[ConVar( "sb.ai.enabled", ConVarFlags.Replicated | ConVarFlags.Saved, Help = "Enable or disable NPC AI thinking." )]
public static bool Enabled { get; set; } = true;
/// <summary>
/// When enabled, NPCs cannot target players.
/// </summary>
[ConVar( "sb.ai.notarget", ConVarFlags.Replicated | ConVarFlags.Saved, Help = "When enabled, NPCs cannot target players." )]
public static bool NoTarget { get; set; } = false;
}
using Sandbox.Npcs.Layers;
using Sandbox.Npcs.Tasks;
namespace Sandbox.Npcs.Schedules;
/// <summary>
/// Panic flee — scream while sprinting away from the source.
/// </summary>
public sealed class ScientistFleeSchedule : ScheduleBase
{
private static readonly string[] PanicLines =
[
"AHHH!",
"Don't hurt me!",
"Help! HELP!",
"Stay away from me!",
"I'm just a scientist!",
"Please, no!",
"Somebody help!",
"Oh god oh god oh god!",
"What did I do?!",
"Leave me alone!",
];
public GameObject Source { get; set; }
/// <summary>
/// 0–1 panic intensity. Higher values mean faster speed and longer flee distance.
/// </summary>
public float PanicLevel { get; set; } = 0.5f;
protected override void OnStart()
{
if ( !Source.IsValid() ) return;
// Sprint speed scales with panic (200–350)
Npc.Navigation.WishSpeed = 200f + 150f * PanicLevel;
// Don't stare at the player — look where we're running
Npc.Animation.ClearLookTarget();
// Scream immediately — but only if not already mid-speech
if ( Npc.Speech.CanSpeak )
{
var line = PanicLines[Game.Random.Int( 0, PanicLines.Length - 1 )];
Npc.Speech.Say( line, 2f );
}
// Flee direction — away from the attacker with some randomness
var awayDir = (GameObject.WorldPosition - Source.WorldPosition).WithZ( 0 ).Normal;
var randomAngle = Game.Random.Float( -40f, 40f );
awayDir = Rotation.FromAxis( Vector3.Up, randomAngle ) * awayDir;
// Distance scales with panic (200–500)
var fleeDist = 512f + 1024f * PanicLevel;
var fleeTarget = GameObject.WorldPosition + awayDir * fleeDist;
// Snap to navmesh
if ( Npc.Scene.NavMesh.GetClosestPoint( fleeTarget ) is { } navPoint )
{
AddTask( new MoveTo( navPoint, 15f ) );
}
else
{
AddTask( new MoveTo( fleeTarget, 15f ) );
}
}
protected override void OnEnd()
{
// Reset to normal walk speed
// TODO: this is shit, can we scope these somehow so the IDisposable handles all this ?
Npc.Navigation.WishSpeed = 100f;
}
protected override bool ShouldCancel()
{
return !Source.IsValid();
}
}
/// <summary>
/// Apply fall damage to the player
/// </summary>
public class PlayerFallDamage : Component, Local.IPlayerEvents
{
[RequireComponent] public Player Player { get; set; }
/// <summary>
/// Fatal fall speed, you will die if you fall at or above this speed
/// </summary>
[Property] public float FatalFallSpeed { get; set; } = 1536.0f;
/// <summary>
/// Maximum safe fall speed, you won't take damage at or below this speed
/// </summary>
[Property] public float MaxSafeFallSpeed { get; set; } = 512.0f;
/// <summary>
/// Multiply damage amount by this much
/// </summary>
[Property] public float DamageMultiplier { get; set; } = 1.0f;
/// <summary>
/// Fall damage sound
/// </summary>
[Property] public SoundEvent FallSound { get; set; }
[Rpc.Owner]
private void PlayFallSound()
{
GameObject.PlaySound( FallSound );
}
void Local.IPlayerEvents.OnLand( float distance, Vector3 velocity )
{
var fallSpeed = Math.Abs( velocity.z );
if ( fallSpeed <= MaxSafeFallSpeed )
return;
var damageAmount = MathX.Remap( fallSpeed, MaxSafeFallSpeed, FatalFallSpeed, 0f, 100f ) * DamageMultiplier;
if ( damageAmount < 1 ) return;
if ( Networking.IsHost && damageAmount >= Player.Health )
Player.PlayerData?.AddStat( "player.fall.death" );
TakeFallDamage( damageAmount );
}
[Rpc.Broadcast]
public void TakeFallDamage( float amount )
{
if ( !Networking.IsHost ) return;
if ( Player is IDamageable damage )
{
var dmg = new DamageInfo( amount.CeilToInt(), Player.GameObject, null );
dmg.Tags.Add( DamageTags.Fall );
damage.OnDamage( dmg );
PlayFallSound();
}
}
}
/// <summary>
/// Manages loadout persistence, presets, and restoration for a player.
/// Lives on the Player GameObject alongside PlayerInventory.
/// Listens to inventory events to auto-save, and handles all loadout RPCs directly.
/// </summary>
public sealed class PlayerLoadout : Component, Local.IPlayerEvents, Global.IPlayerEvents, Global.ISaveEvents
{
[RequireComponent] public Player Player { get; set; }
[RequireComponent] public PlayerInventory Inventory { get; set; }
private bool _isRestoringLoadout;
/// <summary>
/// One entry in a serialized loadout: the prefab resource path and the slot it occupies.
/// </summary>
public struct LoadoutEntry
{
public string PrefabPath { get; set; }
public int Slot { get; set; }
public string SpawnerDataPayload { get; set; }
}
public struct SavedPreset
{
public string Name { get; set; }
public string LoadoutJson { get; set; }
}
public static IReadOnlyList<SavedPreset> GetLoadoutPresets()
{
return LocalData.Get<List<SavedPreset>>( "presets", new() );
}
public static void SaveLoadoutPreset( string name, string loadoutJson )
{
var presets = LocalData.Get<List<SavedPreset>>( "presets", new() );
var idx = presets.FindIndex( p => p.Name == name );
var entry = new SavedPreset { Name = name, LoadoutJson = loadoutJson };
if ( idx >= 0 )
presets[idx] = entry;
else
presets.Add( entry );
LocalData.Set( "presets", presets );
}
public static void DeleteLoadoutPreset( string name )
{
var presets = LocalData.Get<List<SavedPreset>>( "presets", new() );
presets.RemoveAll( p => p.Name == name );
LocalData.Set( "presets", presets );
}
public string SerializeLoadout()
{
var entries = Inventory.Weapons
.Where( w => !string.IsNullOrEmpty( w.GameObject.PrefabInstanceSource ) )
.Select( w => new LoadoutEntry
{
PrefabPath = w.GameObject.PrefabInstanceSource,
Slot = w.InventorySlot,
SpawnerDataPayload = (w as SpawnerWeapon)?.SpawnerData
} )
.ToList();
return entries.Count > 0 ? Json.Serialize( entries ) : null;
}
public void SaveLoadout()
{
if ( _isRestoringLoadout ) return;
var json = SerializeLoadout();
if ( string.IsNullOrEmpty( json ) ) return;
if ( Player.IsLocalPlayer )
{
LocalData.Set( "hotbar", json );
}
else
{
PushLoadoutToClient( json );
}
}
public void GiveLoadoutWeapons( string json )
{
var entries = Json.Deserialize<List<LoadoutEntry>>( json );
if ( entries is null ) return;
_isRestoringLoadout = true;
try
{
foreach ( var entry in entries )
{
if ( !Inventory.Pickup( entry.PrefabPath, entry.Slot, false ) )
continue;
if ( !string.IsNullOrEmpty( entry.SpawnerDataPayload ) && Inventory.GetSlot( entry.Slot ) is SpawnerWeapon spawnerWeapon )
{
spawnerWeapon.RestoreSpawnerData( entry.SpawnerDataPayload );
}
}
}
finally
{
_isRestoringLoadout = false;
}
}
private static async Task EnsureMountedAsync( string json )
{
var entries = Json.Deserialize<List<LoadoutEntry>>( json );
if ( entries is null ) return;
var needsMounts = entries.Any( e => !string.IsNullOrEmpty( e.SpawnerDataPayload )
&& e.SpawnerDataPayload.EndsWith( ".vmdl", StringComparison.OrdinalIgnoreCase ) );
if ( !needsMounts ) return;
foreach ( var entry in Sandbox.Mounting.Directory.GetAll().Where( e => e.Available ) )
await Sandbox.Mounting.Directory.Mount( entry.Ident );
}
public void SwitchToPreset( string loadoutJson )
{
if ( !Networking.IsHost )
{
HostSwitchToPreset( loadoutJson );
return;
}
_ = SwitchToPresetAsync( loadoutJson );
}
public void ResetToDefault()
{
if ( !Networking.IsHost )
{
HostResetToDefault();
return;
}
_ = ResetToDefaultAsync();
}
[Rpc.Host]
private void HostSwitchToPreset( string loadoutJson )
{
_ = SwitchToPresetAsync( loadoutJson );
}
[Rpc.Host]
private void HostResetToDefault()
{
_ = ResetToDefaultAsync();
}
private async Task SwitchToPresetAsync( string loadoutJson )
{
var previousSlot = Inventory.ActiveWeapon?.InventorySlot ?? 0;
foreach ( var weapon in Inventory.Weapons.ToList() )
weapon.DestroyGameObject();
await Task.Yield();
await EnsureMountedAsync( loadoutJson );
GiveLoadoutWeapons( loadoutJson );
var toEquip = Inventory.GetSlot( previousSlot ) ?? Inventory.GetBestWeapon();
if ( toEquip.IsValid() )
Inventory.SwitchWeapon( toEquip );
SaveLoadout();
}
private async Task ResetToDefaultAsync()
{
foreach ( var weapon in Inventory.Weapons.ToList() )
weapon.DestroyGameObject();
await Task.Yield();
Inventory.GiveDefaultWeapons();
Inventory.SwitchWeapon( Inventory.GetBestWeapon() );
SaveLoadout();
}
[Rpc.Owner]
private void PushLoadoutToClient( string loadoutJson )
{
LocalData.Set( "hotbar", loadoutJson );
}
[Rpc.Owner]
private void RequestClientLoadout()
{
var json = LocalData.Get<string>( "hotbar" );
if ( !string.IsNullOrEmpty( json ) )
HostRestoreLoadoutFromClient( json );
}
/// <summary>
/// Clears the current inventory, waits a frame, then gives the loadout from JSON and equips the best weapon.
/// </summary>
private async Task ReplaceLoadoutAsync( string json )
{
foreach ( var weapon in Inventory.Weapons.ToList() )
weapon.DestroyGameObject();
await Task.Yield();
await EnsureMountedAsync( json );
GiveLoadoutWeapons( json );
var best = Inventory.GetBestWeapon();
if ( best.IsValid() )
Inventory.SwitchWeapon( best );
}
[Rpc.Host]
private async void HostRestoreLoadoutFromClient( string loadoutJson )
{
await ReplaceLoadoutAsync( loadoutJson );
}
void Global.IPlayerEvents.OnPlayerSpawned( Player player )
{
if ( player != Player ) return;
if ( !Networking.IsHost ) return;
_ = RestoreOnSpawnAsync();
}
private async Task RestoreOnSpawnAsync()
{
if ( Player.IsLocalPlayer )
{
var json = LocalData.Get<string>( "hotbar" );
if ( !string.IsNullOrEmpty( json ) )
{
await ReplaceLoadoutAsync( json );
return;
}
}
else
{
RequestClientLoadout();
return;
}
Inventory.GiveDefaultWeapons();
var bestWeapon = Inventory.GetBestWeapon();
if ( bestWeapon.IsValid() )
Inventory.SwitchWeapon( bestWeapon );
}
void Local.IPlayerEvents.OnDied( PlayerDiedParams args )
{
if ( !Networking.IsHost ) return;
SaveLoadout();
}
void Local.IPlayerEvents.OnPickup( PlayerPickupEvent e )
{
if ( e.Cancelled ) return;
if ( !Networking.IsHost ) return;
SaveLoadout();
}
void Local.IPlayerEvents.OnDrop( PlayerDropEvent e )
{
if ( e.Cancelled ) return;
if ( !Networking.IsHost ) return;
_ = SaveLoadoutAfterYield();
}
void Local.IPlayerEvents.OnRemoveWeapon( PlayerRemoveWeaponEvent e )
{
if ( e.Cancelled ) return;
if ( !Networking.IsHost ) return;
_ = SaveLoadoutAfterYield();
}
void Local.IPlayerEvents.OnMoveSlot( PlayerMoveSlotEvent e )
{
if ( e.Cancelled ) return;
if ( !Networking.IsHost ) return;
SaveLoadout();
}
private async Task SaveLoadoutAfterYield()
{
await Task.Yield();
SaveLoadout();
}
void Global.ISaveEvents.BeforeSave( string filename )
{
if ( !Networking.IsHost ) return;
var steamId = (long)(Player.Network.Owner?.SteamId ?? 0);
if ( steamId == 0 ) return;
var json = SerializeLoadout();
if ( string.IsNullOrEmpty( json ) ) return;
SaveSystem.Current?.SetMetadata( $"Loadout_{steamId}", json );
}
void Global.ISaveEvents.AfterLoad( string filename )
{
if ( !Networking.IsHost ) return;
var steamId = (long)(Player.Network.Owner?.SteamId ?? 0);
if ( steamId == 0 ) return;
var json = SaveSystem.Current?.GetMetadata( $"Loadout_{steamId}" );
if ( string.IsNullOrEmpty( json ) ) return;
_ = RestoreLoadoutFromSaveAsync( json );
}
private async Task RestoreLoadoutFromSaveAsync( string json )
{
await ReplaceLoadoutAsync( json );
}
}
/// <summary>
/// Dead players become these. They try to observe their last corpse.
/// </summary>
internal sealed class PlayerObserver : Component
{
Angles EyeAngles;
TimeSince timeSinceStarted;
DeathCameraTarget _cachedCorpse;
float currentDistance;
protected override void OnEnabled()
{
base.OnEnabled();
EyeAngles = Scene.Camera.WorldRotation;
timeSinceStarted = 0;
currentDistance = 32;
_cachedCorpse = Scene.GetAllComponents<DeathCameraTarget>()
.Where( x => x.Connection == Network.Owner )
.OrderByDescending( x => x.Created )
.FirstOrDefault();
}
protected override void OnUpdate()
{
// Don't allow immediate respawn
if ( timeSinceStarted < 1 )
return;
// If pressed a button, or has been too long
if ( Input.Pressed( "attack1" ) || Input.Pressed( "jump" ) || timeSinceStarted > 4f )
{
GameManager.Current?.RequestRespawn();
GameObject.Destroy();
}
}
protected override void OnPreRender()
{
if ( IsProxy ) return;
if ( _cachedCorpse.IsValid() )
{
RotateAround( _cachedCorpse );
}
}
private void RotateAround( Component target )
{
// Find the corpse eyes
if ( target.Components.Get<SkinnedModelRenderer>().TryGetBoneTransform( "pelvis", out var tx ) )
{
tx.Position += Vector3.Up * 25;
}
var e = EyeAngles;
e += Input.AnalogLook;
e.pitch = e.pitch.Clamp( -90, 90 );
e.roll = 0.0f;
EyeAngles = e;
currentDistance = currentDistance.LerpTo( 150, Time.Delta * 5 );
var center = tx.Position;
var targetPos = center - EyeAngles.Forward * currentDistance;
var tr = Scene.Trace.FromTo( center, targetPos ).Radius( 1.0f ).WithoutTags( "ragdoll", "effect" ).Run();
Scene.Camera.WorldPosition = tr.EndPosition;
Scene.Camera.WorldRotation = EyeAngles;
}
}