Editor/HumanoidRetargeter/UiSmokeGate.cs

Editor-only UI smoke gate for the Humanoid Retargeter. When HR_UI_SMOKE is set it drives the same code paths the RetargetWindow uses: waits for assets, inspects and loads FBX fixtures, resolves targets, runs Retargeter.ConvertBatch, constructs RetargetWindow and PreviewWidget headlessly, round-trips user presets, runs a DL solver smoke, writes DMX/VMDL via EditorPipeline, verifies compiled model sequences and optionally runs an augment-mode path against an existing vmdl, then writes a JSON result and exits the editor.

File AccessExternal Download
// UI smoke gate: headless end-to-end through the SAME code paths the retarget window uses.
//
// Only runs when the HR_UI_SMOKE environment variable is set (done by
// dev/editor-rig/run_ui_smoke.ps1). Normal users of the library never trigger it.
//
// What it does (inside a real sbox-dev.exe editor session):
//   1. waits for the project + asset system to be ready
//   2. loads the source fixture (HR_UI_FIXTURE, an .fbx) via SourceFileEntry.Load -
//      the exact add-file path of the window (user preset lookup -> preset detection
//      -> auto map) - plus Retargeter.Inspect for the report; also loads the
//      footstep-bearing fixture (HR_UI_FIXTURE_STEPS, a forward-moving mocap walk)
//      converted alongside it in the same batch
//   3. resolves the s&box default target via TargetPickers.SboxDefault (window path)
//   4. Retargeter.ConvertBatch with the entry's mapping as override (window path), with
//      footstep events + additive variants ON and locomotion-set detection ON (single
//      clip -> no family; asserts the no-set path)
//   4.5 constructs the RetargetWindow itself (stacked options layout), asserts the
//      footstep-events / mirrored-variants / additive-variants / locomotion checkboxes
//      reach BuildRequest + BuildBatchOptions, and probes the locomotion toggle's
//      smart-disable (no directional family -> disabled + forced off; 4-way -> enabled)
//   5. constructs a PreviewWidget on the result, applies a solved frame headlessly and
//      draws the source-ghost overlay (the preview's "Show source" toggle)
//   6. round-trips a user preset (UserPresets.Save -> TryLoad) for the fixture rig
//   7. EditorPipeline.WriteAndCompileAsync: DMX + standalone vmdl into Assets,
//      RegisterFile + Compile, polls the .vmdl_c; the written vmdl must carry the
//      AE_FOOTSTEP AnimEvent nodes
//   8. Model.Load on the compiled vmdl, verifies the converted sequences are visible -
//      including the additive '<clip>_delta' twins (AnimSubtract compiles in-engine)
//   9. AUGMENT mode (HR_UI_SMOKE_AUGMENT = absolute path of a vmdl inside the scratch
//      Assets): drives the EXACT Convert-All window path (RetargetWindow.ConvertAndWriteAsync)
//      against that vmdl. The fixture vmdl references meshes a scratch project cannot
//      resolve, so a missing-mesh compile failure is acceptable - the assertions are:
//      augmented vmdl + .bak written, asset registered, compile poll COMPLETES, no
//      "quiet inputs ... abandoning recompile" for our vmdl, a PLANTED stale .vmdl_c is
//      never reported as a compile success (timestamp-verified compile), the install-path
//      guard rejects the shipped citizen vmdl, and the editor survives.
//  10. writes a JSON result to HR_UI_SMOKE and quits the editor
//
// Safety: refuses to do anything when the open project is not the hr-editor-rig scratch
// (a leaked HR_UI_SMOKE env var must never write into - or quit - a real session).

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 HumanoidRetargeter.Mapping;
using Sandbox;

namespace HumanoidRetargeter.Editor;

public static class UiSmokeGate
{
	static bool _started;
	static readonly SmokeResult Result = new();
	static string _resultPath;

	/// <summary>Assets-relative folder the smoke run writes its outputs to (cleaned by the
	/// driver script before each run).</summary>
	public const string OutputFolder = "humanoid_retargeter_smoke";

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

		_started = true;

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

		_ = RunAsync();
	}

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

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

		Result.completed = true;
		Result.passed = Result.dmxVmdlCompiled && Result.compiledFileFresh
			&& Result.sequenceVisible && Result.footstepEventsInVmdl
			&& Result.previewWidgetOk && Result.userPresetRoundTrip
			&& Result.windowConstructed && Result.optionsPlumbingOk
			&& Result.locomotionSmartDisableOk
			&& Result.dlSolverOk && Result.citizenTargetOk
			&& (!Result.augmentMode || Result.augmentOk);
		Flush();
		Note( $"UI smoke gate finished, passed={Result.passed}" );

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

		// Give the driver a moment to see the completed file, then exit cleanly.
		await Task.Delay( 1000 );
		try
		{
			EditorUtility.Quit( true );
		}
		catch ( Exception e )
		{
			Note( $"EditorUtility.Quit threw: {e.Message}" );
			Flush();
		}

		// Backstop if Quit() did not take the process down.
		await Task.Delay( 10_000 );
		Environment.Exit( Result.passed ? 0 : 1 );
	}

	static async Task RunGateAsync()
	{
		// ---- 1. wait for project + asset system --------------------------------
		Result.assetSystemReady = await WaitUntil(
			() => Project.Current is not null && AssetSystem.All.Any(),
			timeoutSeconds: 120 );
		Note( $"assetSystemReady={Result.assetSystemReady}" );
		Flush();

		if ( !Result.assetSystemReady )
			return;

		// Never touch a real session: only the hr-editor-rig scratch project is fair game.
		// (Observed failure mode: gate env vars leaking into a user-launched editor wrote
		// gate outputs into whatever project happened to be open.)
		var rootPath = Project.Current.GetRootPath() ?? "";
		if ( rootPath.IndexOf( "hr-editor-rig", StringComparison.OrdinalIgnoreCase ) < 0 )
		{
			Result.refusedWrongProject = true;
			Note( $"REFUSING to run: open project '{rootPath}' is not the hr-editor-rig scratch "
				+ "(leaked HR_UI_SMOKE env var?) - aborting without touching the project" );
			Flush();
			return;
		}

		var assetsPath = Project.Current.GetAssetsPath();
		Note( $"project={rootPath} assets={assetsPath} mainThread={ThreadSafe.IsMainThread}" );

		// ---- 2. fixture in via the window's add-file path ----------------------
		var fixture = Environment.GetEnvironmentVariable( "HR_UI_FIXTURE" );
		if ( string.IsNullOrWhiteSpace( fixture ) || !File.Exists( fixture ) )
		{
			Note( $"fixture not found (HR_UI_FIXTURE='{fixture}')" );
			Flush();
			return;
		}

		var inspect = Retargeter.Inspect( File.ReadAllBytes( fixture ), Path.GetFileName( fixture ) ).Mapping;
		Result.inspectProfile = inspect.ProfileName;
		Result.inspectConfidence = inspect.Confidence;
		Result.inspectNeedsUserDecision = inspect.NeedsUserDecision;
		Result.skeletonSignature = inspect.SkeletonSignature;
		Note( $"Inspect: profile={inspect.ProfileName} conf={inspect.Confidence:0.00} "
			+ $"needsUserDecision={inspect.NeedsUserDecision} sig={inspect.SkeletonSignature}" );

		var entry = SourceFileEntry.Load( fixture, assetsPath );
		Result.entryStatus = entry.Status.ToString();
		Result.entryChip = entry.ChipText;
		Note( $"SourceFileEntry: status={entry.Status} chip='{entry.ChipText}' clips={entry.ClipCount}" );
		Flush();

		if ( entry.Scene is null || entry.Mapping is null )
		{
			Note( $"fixture entry unreadable: {entry.StatusDetail}" );
			Flush();
			return;
		}

		// Footstep-bearing second fixture (HR_UI_FIXTURE_STEPS, a forward-moving mocap
		// walk the driver supplies): the main fixture is a crawl whose feet never plant,
		// so the AE_FOOTSTEP-through-compile assertion converts this one alongside it.
		// Missing/unreadable: the batch still runs, footstepEventsInVmdl then fails.
		var stepsFixture = Environment.GetEnvironmentVariable( "HR_UI_FIXTURE_STEPS" );
		SourceFileEntry stepsEntry = null;
		if ( !string.IsNullOrWhiteSpace( stepsFixture ) && File.Exists( stepsFixture ) )
		{
			var loaded = SourceFileEntry.Load( stepsFixture, assetsPath );
			if ( loaded.Scene is not null && loaded.Mapping is not null )
				stepsEntry = loaded;
			else
				Note( $"steps fixture unreadable: {loaded.StatusDetail}" );
		}
		Note( stepsEntry is null
			? $"steps fixture unavailable (HR_UI_FIXTURE_STEPS='{stepsFixture}') - the footstep assertion will fail"
			: $"steps fixture: {stepsEntry.FileName} clips={stepsEntry.ClipCount} profile={stepsEntry.Mapping.ProfileName}" );
		Flush();

		// ---- 3. target via the window's picker path ----------------------------
		TargetPickers.ResolvedTarget target;
		try
		{
			target = TargetPickers.SboxDefault();
		}
		catch ( Exception e )
		{
			Note( $"TargetPickers.SboxDefault failed: {e.Message}" );
			Flush();
			return;
		}
		Result.targetResolved = true;
		Note( $"target: {target.Description} previewModel={target.PreviewModelPath}" );
		Flush();

		// ---- 4. ConvertBatch exactly like RetargetWindow.BuildRequest ----------
		// Footstep events + additive variants ON: the written vmdl then carries AnimEvent
		// and AnimSubtract nodes through the REAL compile below (steps 7/8 assert the
		// '<clip>_delta' sequence is visible on the compiled model and the events are in the
		// vmdl text). DetectLocomotionSets ON too - neither batch clip is directional, so
		// no family forms, which pins the no-set path against crashes.
		var request = new RetargetRequest
		{
			SourceData = entry.Bytes,
			SourceFileName = entry.FileName,
			MappingOverride = entry.Mapping,
			RootMotion = Cleanup.RootMotionMode.Off,
			FootPlantCleanup = true,
			ArmEffectorIk = false,
			GenerateFootstepEvents = true,
			CreateAdditiveVariant = true,
			LoopingOverride = null,
		};
		var requests = new List<RetargetRequest> { request };
		if ( stepsEntry is not null )
		{
			requests.Add( new RetargetRequest
			{
				SourceData = stepsEntry.Bytes,
				SourceFileName = stepsEntry.FileName,
				MappingOverride = stepsEntry.Mapping,
				RootMotion = Cleanup.RootMotionMode.Off,
				FootPlantCleanup = true,
				ArmEffectorIk = false,
				GenerateFootstepEvents = true,
				CreateAdditiveVariant = true,
				LoopingOverride = null,
			} );
		}
		var batch = await Task.Run( () => Retargeter.ConvertBatch(
			requests, target.Spec,
			new BatchOptions { DmxFolderRelative = OutputFolder, DetectLocomotionSets = true } ) );

		// Root-cause evidence for the Convert-All crash: record where Task.Run
		// continuations actually resume in an editor session.
		Result.mainThreadAfterTaskRun = ThreadSafe.IsMainThread;
		Note( $"after Task.Run continuation: mainThread={Result.mainThreadAfterTaskRun}" );

		Result.clipCount = batch.Clips.Count;
		Result.solvedClipCount = batch.Clips.Count( c => c.Success && c.SolvedFrames is { Count: > 0 } );
		Result.clipNames = batch.Clips.Where( c => c.Success ).Select( c => c.ClipName ).ToArray();
		Result.batchErrors = batch.Errors.ToArray();
		Result.additiveClipNames = batch.Clips
			.Where( c => c.Success && c.AdditiveVariantName is not null )
			.Select( c => c.AdditiveVariantName ).ToArray();
		Result.footstepEventCount = batch.Clips
			.Where( c => c.Success )
			.Sum( c => c.FootstepEvents?.Count ?? 0 );
		Result.locomotionSetReports = batch.LocomotionSets.Count; // no directional clips: expected 0, but the path must not crash
		Note( $"ConvertBatch: clips={Result.clipCount} solved={Result.solvedClipCount} "
			+ $"names=[{string.Join( ", ", Result.clipNames )}] "
			+ $"deltas=[{string.Join( ", ", Result.additiveClipNames )}] "
			+ $"footstepEvents={Result.footstepEventCount} locomotionReports={Result.locomotionSetReports} "
			+ $"errors={batch.Errors.Count}" );
		Flush();

		if ( Result.solvedClipCount == 0 )
			return;

		// ---- 4.5 RetargetWindow construction + options plumbing ------------------
		// Constructs the dock window headlessly (the BuildUi stacked-columns layout must
		// never throw) and asserts the footstep-events / mirrored-variants checkboxes
		// reach the facade request via BuildRequest (internal gate hook).
		try
		{
			var window = new RetargetWindow( null );
			Result.windowConstructed = true;

			var take = entry.Takes[0];
			var (defaults, defaultOptions) = window.BuildRequestForGate( take,
				footstepEvents: false, mirroredVariants: false,
				additiveVariants: false, detectLocomotionSets: false );
			var (flipped, flippedOptions) = window.BuildRequestForGate( take,
				footstepEvents: true, mirroredVariants: true,
				additiveVariants: true, detectLocomotionSets: true );
			Result.optionsPlumbingOk =
				!defaults.GenerateFootstepEvents && !defaults.CreateMirroredVariant
				&& !defaults.CreateAdditiveVariant && !defaultOptions.DetectLocomotionSets
				&& flipped.GenerateFootstepEvents && flipped.CreateMirroredVariant
				&& flipped.CreateAdditiveVariant && flippedOptions.DetectLocomotionSets;
			Note( $"RetargetWindow: constructed, options plumbing ok={Result.optionsPlumbingOk} "
				+ $"(defaults footsteps={defaults.GenerateFootstepEvents} mirror={defaults.CreateMirroredVariant} "
				+ $"additive={defaults.CreateAdditiveVariant} locomotion={defaultOptions.DetectLocomotionSets}; "
				+ $"flipped footsteps={flipped.GenerateFootstepEvents} mirror={flipped.CreateMirroredVariant} "
				+ $"additive={flipped.CreateAdditiveVariant} locomotion={flippedOptions.DetectLocomotionSets})" );

			// Smart-disable of the locomotion toggle: no complete directional family among
			// the take rows → disabled AND forced off with the explainer tooltip; a complete
			// 4-way family → enabled with a "Detected: …" tooltip naming the stem.
			var none = window.ApplyLocomotionScan( new[] { entry.Takes[0].TakeName } );
			var fourWay = window.ApplyLocomotionScan(
				new[] { "Walk_N", "Walk_E", "Walk_S", "Walk_W" } );
			var emptied = window.ApplyLocomotionScan( Array.Empty<string>() );
			Result.locomotionSmartDisableOk =
				!none.Enabled && !none.Value && none.ToolTip.Contains( "No directional animation set" )
				&& fourWay.Enabled && fourWay.ToolTip.Contains( "Walk (4-way)" )
				&& !emptied.Enabled && !emptied.Value;
			Note( $"locomotion smart-disable: none=({none.Enabled},{none.Value},'{none.ToolTip}') "
				+ $"fourWay=({fourWay.Enabled},'{fourWay.ToolTip}') "
				+ $"emptied=({emptied.Enabled},{emptied.Value}) ok={Result.locomotionSmartDisableOk}" );

			window.Destroy();
		}
		catch ( Exception e )
		{
			Result.windowConstructed = false;
			Result.optionsPlumbingOk = false;
			Result.locomotionSmartDisableOk = false;
			Note( $"RetargetWindow construction/plumbing FAILED: {e}" );
		}
		Flush();

		// ---- 5. preview widget on the solved frames (headless) -----------------
		try
		{
			var clip = batch.Clips.First( c => c.Success && c.SolvedFrames is { Count: > 0 } );
			var preview = new PreviewWidget(
				null, target.Spec.Rig, target.PreviewModelPath, target.PreviewPositionScale,
				target.Spec.UpAxis );
			Result.previewModelLoaded = preview.HasModel;
			preview.SetClip( clip );
			preview.Scrub( Math.Min( 5, preview.FrameCount - 1 ) );
			preview.ApplyCurrentFrame();
			Result.previewWidgetOk = true;
			Note( $"PreviewWidget: hasModel={preview.HasModel} frames={preview.FrameCount} frame applied OK" );

			// Source ghost (the preview dialog's "Show source" toggle): install the source
			// clip, enable, re-apply the frame and require the overlay to have drawn lines.
			try
			{
				preview.SetSourceGhost( entry.Scene.Skeleton, entry.Scene.Clips[0], entry.Mapping );
				preview.ShowSourceGhost = true;
				preview.ApplyCurrentFrame();
				Result.previewGhostOk = preview.HasSourceGhost && preview.GhostLineCount > 0;
				Note( $"source ghost: hasGhost={preview.HasSourceGhost} lines={preview.GhostLineCount} "
					+ $"ok={Result.previewGhostOk}" );
			}
			catch ( Exception e )
			{
				Result.previewGhostOk = false;
				Note( $"source ghost FAILED: {e}" );
			}
			Result.previewWidgetOk &= Result.previewGhostOk;

			// Axis-conversion assertions (Y-up cm rig → Z-up inch engine model). Without the
			// conversion the preview lies on its back; these pin it upright.
			if ( preview.HasModel )
			{
				var rig = target.Spec.Rig;
				var skeleton = rig.Skeleton;
				var hipsIndex = rig.BoneForRole( HumanoidRetargeter.Mapping.BoneRole.Hips ) ?? 0;
				var pelvisName = skeleton[hipsIndex].Name;

				// (a) Clip frame: the SceneModel's pelvis must match the independently
				// FK'd + converted solved frame ((x,y,z) → (x,−z,y) × 0.3937). This fails
				// when the widget skips the conversion OR overrides never reach the model.
				var frameIndex = Math.Min( 5, clip.SolvedFrames.Count - 1 );
				var rigWorld = new HumanoidRetargeter.Skeleton.Pose( clip.SolvedFrames[frameIndex] )
					.ToWorld( skeleton )[hipsIndex].Pos;
				var expected = new Vector3( rigWorld.X, -rigWorld.Z, rigWorld.Y )
					* target.PreviewPositionScale;
				var actual = preview.GetModelBoneTransform( pelvisName )?.Position;
				var frameOk = actual is { } a && a.Distance( expected ) < 0.5f;
				Note( $"preview pelvis (clip frame {frameIndex}): actual={actual} expected={expected} ok={frameOk}" );

				// (b) Rest pose: the citizen pelvis rests at y≈93 cm (Y-up) → engine
				// (0, ~0, ~36.6 in) Z-up. The preview must stand upright, not lie down.
				var rest = new HumanoidRetargeter.Maths.XForm[skeleton.Count];
				for ( var i = 0; i < skeleton.Count; i++ )
					rest[i] = skeleton[i].RestLocal;
				preview.ApplyPose( rest );
				var restPelvis = preview.GetModelBoneTransform( pelvisName )?.Position;
				var restOk = restPelvis is { } r
					&& MathF.Abs( r.x ) < 2f && MathF.Abs( r.y ) < 2f && r.z is > 30f and < 40f;
				Result.previewPelvisRest = restPelvis?.ToString() ?? "(unavailable)";
				Note( $"preview pelvis (rest pose): {Result.previewPelvisRest} expected ~(0, 0, 36.6) ok={restOk}" );

				Result.previewPoseUpright = frameOk && restOk;
				Result.previewWidgetOk &= Result.previewPoseUpright;
			}

			preview.Destroy();
		}
		catch ( Exception e )
		{
			Result.previewWidgetOk = false;
			Note( $"PreviewWidget FAILED: {e}" );
		}
		Flush();

		// ---- 6. user preset round-trip (preview confirm "Save as profile" path) -
		try
		{
			UserPresets.Save( assetsPath, entry.Signature, entry.Scene.Skeleton, entry.Mapping );
			var loaded = UserPresets.TryLoad( assetsPath, entry.Signature, entry.Scene.Skeleton );
			Result.userPresetRoundTrip = loaded is not null
				&& loaded.Source == MappingSource.UserPreset
				&& loaded.RoleToBone.Count == entry.Mapping.RoleToBone.Count
				&& loaded.RoleToBone.All( kv =>
					entry.Mapping.RoleToBone.TryGetValue( kv.Key, out var b ) && b == kv.Value );
			Note( $"user preset round-trip: {Result.userPresetRoundTrip} "
				+ $"(roles={loaded?.RoleToBone.Count ?? 0}/{entry.Mapping.RoleToBone.Count})" );
		}
		catch ( Exception e )
		{
			Result.userPresetRoundTrip = false;
			Note( $"user preset round-trip FAILED: {e}" );
		}
		Flush();

		// ---- 6.5 DL solver smoke: shipped weights load + one forward pass --------
		// (Milestone 10: the dialog's "Deep learning (experimental)" path. Kept cheap -
		// a 10-frame slice of the fixture through the full encode/decode stack.)
		if ( DlAssets.Available )
		{
			try
			{
				var weights = DlAssets.TryLoadWeights();
				var dlSolver = new HumanoidRetargeter.Dl.DlSolver( weights );
				var scene = entry.Scene;
				var sliceFrames = scene.Clips[0].Frames.Take( 10 ).ToList();
				var slice = new HumanoidRetargeter.Skeleton.SourceScene(
					scene.Skeleton,
					new[] { new HumanoidRetargeter.Skeleton.Clip( "dl_smoke", scene.Clips[0].Fps, false, sliceFrames ) },
					scene.UnitScaleCm, scene.UpAxis, scene.UpAxisSign,
					scene.FrontAxis, scene.FrontAxisSign, scene.CoordAxis, scene.CoordAxisSign );
				var dlClip = dlSolver.Solve( slice, entry.Mapping, target.Spec.Rig,
					new HumanoidRetargeter.Solve.SolveOptions() );
				Result.dlSolverOk = dlClip.FrameCount == sliceFrames.Count;
				Note( $"DL solver smoke: weights={weights?.Length ?? 0} bytes, "
					+ $"{dlClip.FrameCount} frames decoded, ok={Result.dlSolverOk}" );
			}
			catch ( Exception e )
			{
				Result.dlSolverOk = false;
				Note( $"DL solver smoke FAILED: {e}" );
			}
			Flush();
		}
		else
		{
			Result.dlSolverOk = true; // asset not installed: nothing to assert
			Note( "DL solver smoke skipped: weight asset not found" );
		}

		// ---- 6.6 classic citizen target: picker path + in-memory conversion ------
		// (Built-in 4-finger target. Kept cheap and disk-free: resolve via the window's
		// picker path and convert once in memory - the compile cycle below stays on the
		// default target so this step cannot disturb it.)
		try
		{
			var citizen = TargetPickers.SboxCitizen();
			var citizenBatch = await Task.Run( () => Retargeter.ConvertBatch(
				new[] { request }, citizen.Spec,
				new BatchOptions { DmxFolderRelative = OutputFolder } ) );
			var citizenClip = citizenBatch.Clips.FirstOrDefault( c => c.Success );
			Result.citizenTargetOk = citizenClip is not null
				&& citizenClip.DmxContent.Contains( "spine_0_p" )      // citizen channel pair present
				&& !citizenClip.DmxContent.Contains( "finger_pinky" )  // no pinky bones on this rig
				// Eyes-out-of-sockets regression guard: face bones must carry rest-local
				// channel pairs (nothing re-drives channel-less face joints in a compiled
				// sequence - ModelDoc bakes them statically and the eyes detach from the
				// moving head), while twist/helper bones stay channel-less joints (the
				// model's AnimConstraintList drives those on every evaluated frame).
				&& citizenClip.DmxContent.Contains( "\"eye_L_p\"" )
				&& citizenClip.DmxContent.Contains( "\"eye_L_o\"" )
				&& citizenClip.DmxContent.Contains( "\"eye_R_p\"" )
				&& citizenClip.DmxContent.Contains( "\"face_lid_upper_L_o\"" )
				&& !citizenClip.DmxContent.Contains( "\"arm_upper_L_twist1_p\"" )
				&& !citizenClip.DmxContent.Contains( "\"neck_clothing_o\"" );
			Note( $"citizen target: '{citizen.Description}' clips={citizenBatch.Clips.Count} "
				+ $"errors={citizenBatch.Errors.Count} ok={Result.citizenTargetOk}" );
		}
		catch ( Exception e )
		{
			Result.citizenTargetOk = false;
			Note( $"citizen target FAILED: {e}" );
		}
		Flush();

		// ---- 7. write + register + compile (the window's convert path) ---------
		var compileStartUtc = DateTime.UtcNow;
		var write = await EditorPipeline.WriteAndCompileAsync(
			batch, OutputFolder, augmentVmdlPath: null, standaloneVmdlName: "ui_smoke_retargeted" );
		Result.dmxFilesWritten = write.DmxFilesWritten;
		Result.vmdlPath = write.VmdlPath;
		Result.assetRegistered = write.VmdlAsset is not null;
		Result.dmxVmdlCompiled = write.Compiled;
		Result.compiledFile = write.CompiledFile;
		Result.writeErrors = write.Errors.ToArray();

		// A reported compile success must be backed by a compiled file written by THIS run
		// (a stale .vmdl_c from an earlier run must never count - EditorPipeline verifies
		// by timestamp; this asserts that verification end to end).
		Result.compiledFileFresh = write.Compiled
			&& write.CompiledFile is not null && File.Exists( write.CompiledFile )
			&& File.GetLastWriteTimeUtc( write.CompiledFile )
				>= compileStartUtc - TimeSpan.FromSeconds( 10 );
		// The compile-verification caveat closer: the REAL compiled vmdl must carry the
		// footstep AnimEvent nodes (event_class AE_FOOTSTEP) the request asked for.
		try
		{
			var vmdlText = write.VmdlPath is not null ? File.ReadAllText( write.VmdlPath ) : "";
			Result.footstepEventsInVmdl = Result.footstepEventCount > 0
				&& vmdlText.Contains( "AnimEvent", StringComparison.Ordinal )
				&& vmdlText.Contains( Target.FootstepEvents.FootstepEventClass, StringComparison.Ordinal );
		}
		catch ( Exception e )
		{
			Result.footstepEventsInVmdl = false;
			Note( $"vmdl footstep-event check FAILED: {e.Message}" );
		}

		Note( $"WriteAndCompile: dmx={write.DmxFilesWritten} vmdl={write.VmdlPath} "
			+ $"compiled={write.Compiled} compiledFile={write.CompiledFile} "
			+ $"fresh={Result.compiledFileFresh} footstepEventsInVmdl={Result.footstepEventsInVmdl} "
			+ $"errors={write.Errors.Count}" );
		Flush();

		if ( !write.Compiled || write.VmdlAsset is null )
			return;

		// ---- 8. load the compiled model, verify the sequences -------------------
		// Both the base clips AND their additive '<clip>_delta' twins (AnimSubtract nodes)
		// must be visible on the COMPILED model - this is what proves the additive variant
		// actually compiles in-engine rather than merely serializing.
		var model = Model.Load( write.VmdlAsset.Path );
		Result.modelLoads = model is not null && !model.IsError;
		if ( model is not null )
		{
			Result.boneCount = model.BoneCount;
			Result.animationCount = model.AnimationCount;
			Result.animationNames = model.AnimationNames?.ToArray() ?? Array.Empty<string>();
			bool Visible( string clip )
				=> Result.animationNames.Any( n => string.Equals( n, clip, StringComparison.OrdinalIgnoreCase ) );
			Result.additiveSequenceVisible = Result.additiveClipNames.Length > 0
				&& Result.additiveClipNames.All( Visible );
			Result.sequenceVisible = Result.clipNames.Length > 0 && Result.clipNames.All( Visible )
				&& Result.additiveSequenceVisible;
		}

		Note( $"modelLoads={Result.modelLoads} bones={Result.boneCount} anims={Result.animationCount} "
			+ $"names=[{string.Join( ", ", Result.animationNames )}] sequenceVisible={Result.sequenceVisible} "
			+ $"additiveSequenceVisible={Result.additiveSequenceVisible}" );
		Flush();

		// ---- 9. augment mode: the EXACT Convert-All window path -----------------
		var augmentTarget = Environment.GetEnvironmentVariable( "HR_UI_SMOKE_AUGMENT" );
		if ( !string.IsNullOrWhiteSpace( augmentTarget ) )
			await RunAugmentAsync( entry, target, augmentTarget );
	}

	/// <summary>Assets-relative DMX folder for the augment run (separate from the
	/// standalone run's folder so its writes never re-trigger that vmdl's compile).</summary>
	const string AugmentDmxFolder = OutputFolder + "/augment";

	static async Task RunAugmentAsync(
		SourceFileEntry entry, TargetPickers.ResolvedTarget target, string augmentVmdlPath )
	{
		Result.augmentMode = true;
		Result.augmentVmdlPath = augmentVmdlPath;
		Note( $"augment mode: target vmdl={augmentVmdlPath}" );

		if ( !File.Exists( augmentVmdlPath ) )
		{
			Note( "augment target vmdl not found - augment FAILED" );
			Flush();
			return;
		}

		try
		{
			// Same request the window's BuildRequest produces for this entry.
			var requests = new[]
			{
				new RetargetRequest
				{
					SourceData = entry.Bytes,
					SourceFileName = entry.FileName,
					SourceId = entry.FilePath,
					MappingOverride = entry.Mapping,
					RootMotion = Cleanup.RootMotionMode.Off,
					FootPlantCleanup = true,
					ArmEffectorIk = false,
					LoopingOverride = null,
				},
			};
			var options = new BatchOptions
			{
				DmxFolderRelative = AugmentDmxFolder,
				AugmentVmdlText = File.ReadAllText( augmentVmdlPath ),
			};

			// Stale-compile probe (CompileAndWaitAsync timestamp verification): augment
			// targets always carry a pre-existing .vmdl_c in real use. Plant one, backdated,
			// BEFORE the run - its mere existence must never turn into a reported compile
			// success; if a success IS reported, the compiled file must be NEWER than this.
			var staleCompiledPath = augmentVmdlPath + "_c";
			DateTime? staleStampUtc = null;
			try
			{
				if ( !File.Exists( staleCompiledPath ) )
					File.WriteAllBytes( staleCompiledPath, new byte[] { 0 } );
				File.SetLastWriteTimeUtc( staleCompiledPath, DateTime.UtcNow.AddMinutes( -10 ) );
				staleStampUtc = File.GetLastWriteTimeUtc( staleCompiledPath );
				Note( $"planted stale compiled file {staleCompiledPath} @ {staleStampUtc:O}" );
			}
			catch ( Exception e )
			{
				Note( $"could not plant stale compiled file: {e.Message}" );
			}

			var logOffset = EditorPipeline.SboxLogLength();

			// THE window path: same method Convert All invokes (Task.Run batch ->
			// main-thread write/register/settle/compile).
			var (batch, write) = await RetargetWindow.ConvertAndWriteAsync(
				requests, target, options, augmentVmdlPath );

			Result.augmentOnMainThreadAfter = ThreadSafe.IsMainThread;
			Result.augmentedVmdlProduced = batch.AugmentedVmdl is not null;
			Result.augmentWriteErrors = write?.Errors.ToArray()
				?? new[] { "write skipped (no augmented vmdl produced)" };
			Result.augmentVmdlWritten = write?.VmdlPath is not null
				&& File.Exists( write.VmdlPath ) && File.Exists( write.VmdlPath + ".bak" );
			Result.augmentRegistered = write?.VmdlAsset is not null;
			Result.augmentCompiled = write?.Compiled ?? false; // informational: the fixture's
			// meshes cannot resolve from a scratch project, a compile failure is acceptable.
			Result.augmentCompilePollCompleted = write is not null;
			Result.augmentQuietInputsAbandon = EditorPipeline.LogSliceShowsAbandonedRecompile(
				logOffset, Path.GetFileName( augmentVmdlPath ) );

			// Stale-compile probe verdict: either the compile honestly failed/was not
			// reported (expected here - missing meshes), or it succeeded AND the compiled
			// file's timestamp advanced past the planted stale stamp. The pre-fix poll
			// returned success off the stale file's mere existence - this catches that.
			if ( write?.Compiled is not true )
			{
				Result.augmentCompileVerified = true;
			}
			else
			{
				var compiledPath = write.CompiledFile ?? staleCompiledPath;
				Result.augmentCompileVerified = File.Exists( compiledPath )
					&& (staleStampUtc is not { } stamp
						|| File.GetLastWriteTimeUtc( compiledPath ) > stamp);
			}
			Note( $"stale-compile probe: compiled={write?.Compiled} verified={Result.augmentCompileVerified}" );

			// The augmenter must really have added our clips to the vmdl on disk. (THIS
			// batch's clip names, not Result.clipNames - the standalone batch additionally
			// converts the steps fixture, which the augment run does not.)
			try
			{
				var text = File.ReadAllText( augmentVmdlPath );
				var augmentClipNames = batch.Clips.Where( c => c.Success ).Select( c => c.ClipName ).ToArray();
				Result.augmentVmdlContainsClips = augmentClipNames.Length > 0
					&& augmentClipNames.All( c => text.Contains( c, StringComparison.OrdinalIgnoreCase ) );
			}
			catch
			{
				Result.augmentVmdlContainsClips = false;
			}

			// Install-path guard: the pipeline must refuse to touch the SHIPPED citizen
			// vmdl (this is the exact path that crashed a user session) without writing
			// anything next to it.
			var engineRoot = EditorPipeline.EngineRootPath;
			if ( engineRoot is not null )
			{
				var shipped = Path.Combine( engineRoot, "addons", "citizen", "Assets",
					"models", "citizen_human", "citizen_human_male.vmdl" );
				var guard = await EditorPipeline.WriteAndCompileAsync( batch, AugmentDmxFolder, shipped );
				Result.installGuardRejected = guard.Errors.Count > 0
					&& guard.Errors[0].Contains( "installation", StringComparison.OrdinalIgnoreCase )
					&& !File.Exists( shipped + ".bak" );
				Note( $"install guard probe: rejected={Result.installGuardRejected} "
					+ $"error='{guard.Errors.FirstOrDefault()}'" );
			}
			else
			{
				Note( "engine root unavailable - skipping install guard probe" );
				Result.installGuardRejected = true;
			}

			Result.augmentOk = Result.augmentedVmdlProduced && Result.augmentVmdlWritten
				&& Result.augmentRegistered && Result.augmentCompilePollCompleted
				&& !Result.augmentQuietInputsAbandon && Result.augmentVmdlContainsClips
				&& Result.augmentCompileVerified
				&& Result.installGuardRejected;

			Note( $"augment: produced={Result.augmentedVmdlProduced} written={Result.augmentVmdlWritten} "
				+ $"registered={Result.augmentRegistered} pollCompleted={Result.augmentCompilePollCompleted} "
				+ $"compiled={Result.augmentCompiled} quietInputsAbandon={Result.augmentQuietInputsAbandon} "
				+ $"containsClips={Result.augmentVmdlContainsClips} compileVerified={Result.augmentCompileVerified} "
				+ $"guardRejected={Result.installGuardRejected} "
				+ $"mainThreadAfter={Result.augmentOnMainThreadAfter} => augmentOk={Result.augmentOk}" );
		}
		catch ( Exception e )
		{
			Result.augmentOk = false;
			Note( $"augment FAILED with exception: {e}" );
		}
		Flush();
	}

	// ---- 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( $"[hr-ui-smoke] {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 SmokeResult
	{
		public bool engineBooted { get; set; }
		public bool assetSystemReady { get; set; }
		public string inspectProfile { get; set; }
		public float inspectConfidence { get; set; }
		public bool inspectNeedsUserDecision { get; set; }
		public string skeletonSignature { get; set; }
		public string entryStatus { get; set; }
		public string entryChip { get; set; }
		public bool targetResolved { get; set; }
		public int clipCount { get; set; }
		public int solvedClipCount { get; set; }
		public string[] clipNames { get; set; } = Array.Empty<string>();
		public string[] additiveClipNames { get; set; } = Array.Empty<string>();
		public int footstepEventCount { get; set; }
		public int locomotionSetReports { get; set; }
		public string[] batchErrors { get; set; } = Array.Empty<string>();
		public bool previewModelLoaded { get; set; }
		public bool previewWidgetOk { get; set; }
		public bool previewPoseUpright { get; set; }
		public bool previewGhostOk { get; set; }
		public string previewPelvisRest { get; set; }
		public bool windowConstructed { get; set; }
		public bool optionsPlumbingOk { get; set; }
		public bool locomotionSmartDisableOk { get; set; }
		public bool userPresetRoundTrip { get; set; }
		public bool dlSolverOk { get; set; }
		public bool citizenTargetOk { get; set; }
		public int dmxFilesWritten { get; set; }
		public string vmdlPath { get; set; }
		public bool assetRegistered { get; set; }
		public bool dmxVmdlCompiled { get; set; }
		public bool compiledFileFresh { get; set; }
		public bool footstepEventsInVmdl { get; set; }
		public string compiledFile { get; set; }
		public string[] writeErrors { get; set; } = Array.Empty<string>();
		public bool modelLoads { get; set; }
		public int boneCount { get; set; }
		public int animationCount { get; set; }
		public string[] animationNames { get; set; } = Array.Empty<string>();
		public bool sequenceVisible { get; set; }
		public bool additiveSequenceVisible { get; set; }

		// threading evidence (Convert-All crash root cause)
		public bool mainThreadAfterTaskRun { get; set; }

		// augment mode (HR_UI_SMOKE_AUGMENT)
		public bool augmentMode { get; set; }
		public string augmentVmdlPath { get; set; }
		public bool augmentedVmdlProduced { get; set; }
		public bool augmentVmdlWritten { get; set; }
		public bool augmentRegistered { get; set; }
		public bool augmentCompilePollCompleted { get; set; }
		public bool augmentCompiled { get; set; }
		public bool augmentQuietInputsAbandon { get; set; }
		public bool augmentCompileVerified { get; set; }
		public bool augmentVmdlContainsClips { get; set; }
		public bool installGuardRejected { get; set; }
		public bool augmentOnMainThreadAfter { get; set; }
		public string[] augmentWriteErrors { get; set; } = Array.Empty<string>();
		public bool augmentOk { get; set; }

		public bool refusedWrongProject { get; set; }
		public bool completed { get; set; }
		public bool passed { get; set; }
		public System.Collections.Generic.List<string> log { get; set; } = new();
	}
}