Editor/BetterIk/BikGate.cs

Editor gate helper for BetterIk, used only in editor-process regression runs. When armed via BIK_GATE_RESULT and a one-shot ".arm" file, it spawns test rigs (citizen models + TwoBoneIK), enters play mode, samples IK state for a fixed number of frames, evaluates three scenarios, writes a JSON result file and exits the editor.

File Access
#nullable enable annotations

// better-ik editor-process regression gate.
//
// This only runs when the BIK_GATE_RESULT environment variable is set AND a matching
// one-shot ".arm" marker file exists (done by dev/editor-rig/run_editor_gate.ps1). Normal
// users of the library never trigger it. Mirrors humanoid-retargeter/Editor/HumanoidRetargeter/M0Gate.cs
// verbatim for the safety mechanisms (scratch-project refusal, arm marker, quit backstop).
//
// What it does (inside a real sbox-dev.exe editor session):
//   1. waits for the project + asset system to be ready
//   2. refuses to run unless the open project is the bik-editor-rig scratch project
//   3. spawns one Citizen GameObject per scenario (disjoint objects, never conflicting bones)
//   4. enters play mode via SceneEditorSession.SetPlaying (no scene clone - component
//      references taken before Play remain valid, so scenario state is read in-process)
//   5. samples each scenario's TwoBoneIK + bone state every frame for a fixed run length
//   6. writes a JSON result file to BIK_GATE_RESULT and quits the editor

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using BetterIk;

namespace BetterIk.Gate;

public static class BikGate
{
	static bool _started;
	static readonly BikResult Result = new();
	static string _resultPath;

	const int FramesToRun = 40;

	[EditorEvent.Frame]
	public static void Tick()
	{
		if ( _started )
			return;

		_started = true;

		_resultPath = Environment.GetEnvironmentVariable( "BIK_GATE_RESULT" );
		if ( string.IsNullOrWhiteSpace( _resultPath ) )
			return; // not a gate run - do nothing, ever

		// One-shot arming marker, written by the driver script immediately before launch and
		// CONSUMED here. The env var alone must never arm the gate: a gate run that boots
		// Steam as its child leaks BIK_GATE_RESULT into Steam's environment, and every editor
		// the user launches through that Steam afterwards would inherit it.
		var marker = _resultPath + ".arm";
		try
		{
			if ( !File.Exists( marker ) )
			{
				Log.Info( "[bik-gate] BIK_GATE_RESULT is set but there is no arming marker - "
					+ "leaked env var (stale Steam environment?), ignoring; not a gate run" );
				return;
			}
			File.Delete( marker );
		}
		catch
		{
			return; // cannot verify/consume the marker - err on never running
		}

		_ = RunAsync();
	}

	static async Task RunAsync()
	{
		Note( "bik gate starting" );
		Result.engineBooted = true;
		Flush();

		try
		{
			await RunGateAsync();
		}
		catch ( Exception e )
		{
			Note( $"EXCEPTION: {e}" );
		}

		Result.completed = true;
		Result.passed = Result.scenarios.Count > 0 && Result.scenarios.All( s => s.passed );
		Flush();
		Note( $"bik gate finished, passed={Result.passed}" );

		if ( Result.refusedWrongProject )
			return; // a leaked env var in a real session must never quit the user's editor

		await Task.Delay( 1000 );
		try
		{
			EditorUtility.Quit( true );
		}
		catch ( Exception e )
		{
			Note( $"EditorUtility.Quit threw: {e.Message}" );
			Flush();
		}

		await Task.Delay( 10_000 );
		Environment.Exit( Result.passed ? 0 : 1 );
	}

	static async Task RunGateAsync()
	{
		Result.assetSystemReady = await WaitUntil(
			() => Project.Current is not null && AssetSystem.All.Any(),
			timeoutSeconds: 120 );
		Note( $"assetSystemReady={Result.assetSystemReady}" );
		Flush();

		if ( !Result.assetSystemReady )
			return;

		var project = Project.Current;
		Result.projectPath = project.GetRootPath();

		// Never touch a real session: only the bik-editor-rig scratch project is fair game.
		if ( (Result.projectPath ?? "").IndexOf( "bik-editor-rig", StringComparison.OrdinalIgnoreCase ) < 0 )
		{
			Result.refusedWrongProject = true;
			Note( $"REFUSING to run: open project '{Result.projectPath}' is not the bik-editor-rig "
				+ "scratch (leaked BIK_GATE_RESULT env var?) - aborting without touching the project" );
			Flush();
			return;
		}

		var session = SceneEditorSession.CreateDefault();
		var scene = session.Scene;
		Note( $"scene created" );
		Flush();

		var citizenModel = Model.Load( "models/citizen/citizen.vmdl" );
		if ( citizenModel is null || citizenModel.IsError )
		{
			Note( "FAILED to load models/citizen/citizen.vmdl" );
			Flush();
			return;
		}

		// A camera was added here as a hypothesis fix for component update methods never firing
		// during this gate's play session - it did NOT resolve the issue (confirmed with
		// instrumented counters: OnPreRender/OnUpdate both stayed at 0 calls either way), so the
		// root cause is still open. Left in since a scene with an active camera is closer to a
		// normal game session regardless, and it is not the cause of harm.
		var cameraGo = scene.CreateObject( true );
		cameraGo.Name = "Gate Camera";
		cameraGo.WorldPosition = new Vector3( 0, -800, 200 );
		var camera = cameraGo.AddComponent<CameraComponent>( true );
		camera.WorldRotation = Rotation.LookAt( Vector3.Zero - cameraGo.WorldPosition );

		// One Citizen GameObject per scenario - disjoint objects, so no scenario's bone writes
		// can ever conflict with another's, matching how a real user would run separate limbs.
		var scenarios = new List<ScenarioRig>
		{
			BuildScenario( scene, citizenModel, "hand_R_farClamp", "hand_R",
				new Vector3( 300, 0, 0 ), targetPos: new Vector3( 300, -400, 550 ) ),
			BuildScenario( scene, citizenModel, "hand_L_reachable", "hand_L",
				new Vector3( 0, 0, 0 ), targetPos: new Vector3( 20, -20, 45 ) ),
			BuildScenario( scene, citizenModel, "hand_L_weightBlend", "hand_L",
				new Vector3( -300, 0, 0 ), targetPos: new Vector3( 12, -8, 33 ) ),
		};

		Note( $"spawned {scenarios.Count} scenario rigs" );
		Flush();

		session.SetPlaying( scene );
		Note( $"playing={session.IsPlaying}" );
		Flush();

		// [EditorEvent.Frame] does not appear to drive this session's own simulation tick on its
		// own (confirmed empirically: component update methods stayed at 0 calls without this),
		// so drive it explicitly every loop iteration. Prefer the game session's own Tick() when
		// playing is active; fall back to Scene.GameTick(dt) if that is ever unavailable.
		void TickScene( float dt )
		{
			var gameSession = session.GameSession;
			if ( gameSession is not null )
				gameSession.Tick();
			else
				scene.GameTick( dt );
		}

		// Wait for chain resolution to actually settle before asserting on it - the component
		// resolves its bone chain lazily on first Solve(), which needs at least one real frame
		// (and possibly a few, since SkinnedModelRenderer's own bone data is only guaranteed
		// once its OnStart has run in the now-playing scene) rather than a guessed frame count.
		bool allResolved = false;
		var chainWaitSw = Stopwatch.StartNew();
		while ( chainWaitSw.Elapsed.TotalSeconds < 15 )
		{
			TickScene( 1f / 30f );
			if ( scenarios.All( r => r.Ik is not null && r.Ik.HasValidChain ) )
			{
				allResolved = true;
				break;
			}
			await Task.Delay( 33 );
		}
		Note( $"allChainsResolved={allResolved}" );
		Flush();

		for ( int frame = 0; frame < FramesToRun; frame++ )
		{
			// weightBlend scenario sweeps Weight linearly across the run instead of holding it at 1.
			var weightBlendScenario = scenarios[2];
			weightBlendScenario.Ik.Weight = Math.Clamp( frame / (float)(FramesToRun - 1), 0f, 1f );

			TickScene( 1f / 30f );
			await Task.Delay( 33 ); // ~1 frame at 30fps; lets OnPreRender/Solve actually run

			foreach ( var rig in scenarios )
				SampleFrame( rig, frame );
		}

		session.StopPlaying();
		Note( "playing stopped" );
		Flush();

		foreach ( var rig in scenarios )
			EvaluateScenario( rig );
	}

	class ScenarioRig
	{
		public string Name;
		public GameObject GameObject;
		public TwoBoneIK Ik;
		public GameObject Target;
		public Vector3 RootPos, MidPos; // filled in on first sample
		public float L1, L2;
		public List<FrameSample> Samples = new();
	}

	static ScenarioRig BuildScenario( Scene scene, Model model, string name, string endBone, Vector3 origin, Vector3 targetPos )
	{
		var go = scene.CreateObject( true );
		go.Name = name;
		go.WorldPosition = origin;

		var renderer = go.AddComponent<SkinnedModelRenderer>( true );
		renderer.Model = model;

		var targetGo = scene.CreateObject( true );
		targetGo.Name = name + "_target";
		targetGo.WorldPosition = origin + targetPos;

		var ik = go.AddComponent<TwoBoneIK>( true );
		ik.Renderer = renderer;
		ik.EndBone = endBone;
		ik.Target = targetGo;

		return new ScenarioRig { Name = name, GameObject = go, Ik = ik, Target = targetGo };
	}

	static void SampleFrame( ScenarioRig rig, int frame )
	{
		if ( rig.Ik is null || rig.Ik.Renderer is null )
			return;

		var renderer = rig.Ik.Renderer;
		if ( !renderer.TryGetBoneTransform( rig.Ik.EndBone, out var endTx ) )
			return;

		if ( rig.L1 <= 0f )
		{
			// One-time bind reference, taken from the first available sample, used only for
			// the far-clamp Lmax check below.
			if ( renderer.TryGetBoneTransform( "arm_upper_R", out var rootTx )
				&& renderer.TryGetBoneTransform( "arm_lower_R", out var midTx ) )
			{
				rig.RootPos = rootTx.Position;
				rig.MidPos = midTx.Position;
				rig.L1 = (midTx.Position - rootTx.Position).Length;
				rig.L2 = (endTx.Position - midTx.Position).Length;
			}
		}

		rig.Samples.Add( new FrameSample
		{
			frame = frame,
			hasValidChain = rig.Ik.HasValidChain,
			lastSolveApplied = rig.Ik.LastSolveApplied,
			weight = rig.Ik.Weight,
			endPos = new[] { endTx.Position.x, endTx.Position.y, endTx.Position.z },
			endRot = new[] { endTx.Rotation.x, endTx.Rotation.y, endTx.Rotation.z, endTx.Rotation.w },
		} );
	}

	static void EvaluateScenario( ScenarioRig rig )
	{
		var result = new ScenarioResult { name = rig.Name, sampleCount = rig.Samples.Count };

		if ( rig.Samples.Count == 0 )
		{
			result.passed = false;
			result.failReason = "no samples captured";
			Result.scenarios.Add( result );
			return;
		}

		if ( rig.Samples.Any( s => !s.hasValidChain ) )
		{
			result.passed = false;
			result.failReason = "HasValidChain was false on at least one frame";
			Result.scenarios.Add( result );
			return;
		}

		float lmax = rig.L1 + rig.L2;
		float tolerance = MathF.Max( 0.03f * lmax, 1.0f );

		switch ( rig.Name )
		{
			case "hand_R_farClamp":
			{
				// Regression test for the exact stale-endTx.Position bug: the end effector
				// must sit at ~Lmax from root, every frame, not stuck near the animated pose.
				var lastPos = ToVec3( rig.Samples[^1].endPos );
				float distFromRoot = (lastPos - rig.RootPos).Length;
				bool ok = MathF.Abs( distFromRoot - lmax ) < tolerance;
				result.passed = ok;
				result.failReason = ok ? null : $"distFromRoot={distFromRoot} expected~{lmax} tol={tolerance}";
				result.measured = new Dictionary<string, float> { ["distFromRoot"] = distFromRoot, ["lmax"] = lmax };
				break;
			}
			case "hand_L_reachable":
			{
				var targetPos = rig.Target.WorldPosition;
				var lastPos = ToVec3( rig.Samples[^1].endPos );
				float err = (lastPos - targetPos).Length;
				bool ok = err < tolerance;
				result.passed = ok;
				result.failReason = ok ? null : $"endError={err} tol={tolerance}";
				result.measured = new Dictionary<string, float> { ["endError"] = err };
				break;
			}
			case "hand_L_weightBlend":
			{
				// Weight sweeps 0->1: first sampled frame should be near the animated pose,
				// last frame should be near the target. No hard per-frame position check here
				// (bone lengths differ from the assertion above), just first-vs-last movement.
				var first = ToVec3( rig.Samples[0].endPos );
				var last = ToVec3( rig.Samples[^1].endPos );
				float moved = (last - first).Length;
				bool ok = moved > tolerance;
				result.passed = ok;
				result.failReason = ok ? null : $"moved={moved} tol={tolerance} (weight blend produced no visible motion)";
				result.measured = new Dictionary<string, float> { ["moved"] = moved };
				break;
			}
			default:
				result.passed = false;
				result.failReason = "unknown scenario name";
				break;
		}

		Result.scenarios.Add( result );
		Note( $"scenario {rig.Name}: passed={result.passed} {result.failReason}" );
		Flush();
	}

	static Vector3 ToVec3( float[] a ) => new( a[0], a[1], a[2] );

	// ---- plumbing ---------------------------------------------------------------

	static async Task<bool> WaitUntil( Func<bool> condition, float timeoutSeconds )
	{
		var sw = Stopwatch.StartNew();
		while ( sw.Elapsed.TotalSeconds < timeoutSeconds )
		{
			bool ok = false;
			try { ok = condition(); }
			catch { /* not ready yet */ }

			if ( ok )
				return true;

			await Task.Delay( 250 );
		}

		return false;
	}

	static void Note( string message )
	{
		Result.log.Add( $"[{DateTime.UtcNow:HH:mm:ss.fff}] {message}" );
		Log.Info( $"[bik-gate] {message}" );
	}

	static void Flush()
	{
		try
		{
			File.WriteAllText( _resultPath, JsonSerializer.Serialize( Result,
				new JsonSerializerOptions { WriteIndented = true } ) );
		}
		catch
		{
			// never let result IO take the editor down
		}
	}

	class FrameSample
	{
		public int frame { get; set; }
		public bool hasValidChain { get; set; }
		public bool lastSolveApplied { get; set; }
		public float weight { get; set; }
		public float[] endPos { get; set; }
		public float[] endRot { get; set; }
	}

	class ScenarioResult
	{
		public string name { get; set; }
		public int sampleCount { get; set; }
		public bool passed { get; set; }
		public string failReason { get; set; }
		public Dictionary<string, float> measured { get; set; }
	}

	class BikResult
	{
		public bool engineBooted { get; set; }
		public bool assetSystemReady { get; set; }
		public string projectPath { get; set; }
		public bool refusedWrongProject { get; set; }
		public List<ScenarioResult> scenarios { get; set; } = new();
		public bool completed { get; set; }
		public bool passed { get; set; }
		public List<string> log { get; set; } = new();
	}
}