Editor utility that runs a headless "UI smoke" test inside the editor when HR_UI_SMOKE is armed. It drives the HumanoidRetargeter workflows end-to-end: waits for assets, loads fixtures, runs Retargeter.ConvertBatch, constructs RetargetWindow/PreviewWidget, dumps previews and rig solves, writes and compiles vmdl assets, probes compiled model playback, exercises custom/augment targets and records a JSON result.
#nullable enable annotations
// 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.Core;
using HumanoidRetargeter.Core.Mapping;
using Sandbox;
namespace HumanoidRetargeter.EditorTools;
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
// 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 HR_UI_SMOKE into Steam's environment, and every
// editor the user launches through that Steam afterwards inherits it - observed as
// the user's own scratch-project session quitting itself seconds after opening
// (same failure mode as M0Gate).
var marker = _resultPath + ".arm";
try
{
if ( !File.Exists( marker ) )
{
Log.Info( "[hr-ui-smoke] HR_UI_SMOKE is set but there is no arming marker - "
+ "leaked env var (stale Steam environment?), ignoring; not a smoke run" );
return;
}
File.Delete( marker );
}
catch
{
return; // cannot verify/consume the marker - err on never running
}
_ = 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.citizenAnimationSetupOk
&& (!Result.augmentMode || Result.augmentOk)
&& (!Result.customMode || Result.customOk);
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 = Core.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 = Core.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. Space-named
// takes ("Walk N" …) sanitize into a complete Walk_N family during conversion, so
// the scan must treat them the same and ENABLE the checkbox.
var none = window.ApplyLocomotionScan( new[] { entry.Takes[0].TakeName } );
var fourWay = window.ApplyLocomotionScan(
new[] { "Walk_N", "Walk_E", "Walk_S", "Walk_W" } );
var spaced = 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)" )
&& spaced.Enabled && spaced.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}') "
+ $"spaced=({spaced.Enabled},'{spaced.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
// AND to actually reach the screen (amber pixels - a missing line material
// rendered both overlays as a single purple blob; user report 2026-07-04).
try
{
preview.SetSourceGhost( entry.Scene.Skeleton, entry.Scene.Clips[0], entry.Mapping );
preview.ShowSourceGhost = true;
preview.ApplyCurrentFrame();
Result.previewGhostPixels = preview.CountRenderedPixels(
c => c.r > 0.45f && c.b < c.r * 0.6f );
Result.previewGhostOk = preview.HasSourceGhost && preview.GhostLineCount > 0
&& Result.previewGhostPixels > 20;
preview.ShowSourceGhost = false;
Note( $"source ghost: hasGhost={preview.HasSourceGhost} lines={preview.GhostLineCount} "
+ $"pixels={Result.previewGhostPixels} ok={Result.previewGhostOk}" );
}
catch ( Exception e )
{
Result.previewGhostOk = false;
Note( $"source ghost FAILED: {e}" );
}
Result.previewWidgetOk &= Result.previewGhostOk;
// Wireframe-skeleton view (the dialog's "Skeleton" toggle): switching over must
// draw the retargeted pose as solid bones - VISIBLY (light gray pixels) - and
// switching back must re-show the model.
try
{
preview.SkeletonOnly = true;
preview.ApplyCurrentFrame();
var lines = preview.SkeletonLineCount;
Result.previewSkeletonPixels = preview.CountRenderedPixels(
c => c.r > 0.35f && MathF.Abs( c.r - c.g ) < 0.08f && MathF.Abs( c.g - c.b ) < 0.08f ); // Blender-gray bones
preview.SkeletonOnly = false;
preview.ApplyCurrentFrame();
Result.previewSkeletonViewOk = lines > 0 && !preview.SkeletonOnly
&& Result.previewSkeletonPixels > 30;
Note( $"skeleton view: lines={lines} pixels={Result.previewSkeletonPixels} "
+ $"backToSkinned={!preview.SkeletonOnly} ok={Result.previewSkeletonViewOk}" );
}
catch ( Exception e )
{
Result.previewSkeletonViewOk = false;
Note( $"skeleton view FAILED: {e}" );
}
Result.previewWidgetOk &= Result.previewSkeletonViewOk;
// 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.Core.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.Core.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.Core.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.Core.Dl.DlSolver( weights );
var scene = entry.Scene;
var sliceFrames = scene.Clips[0].Frames.Take( 10 ).ToList();
var slice = new HumanoidRetargeter.Core.Skeleton.SourceScene(
scene.Skeleton,
new[] { new HumanoidRetargeter.Core.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.Core.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( Core.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();
await CitizenAnimationSetupGate.RunAsync();
Result.citizenAnimationSetupOk = true;
Flush();
// ---- 8.5 custom-target repro (HR_UI_SMOKE_CUSTOM) ------------------------
// User report (2026-07-04): custom vmdl / custom FBX targets show no model in the
// preview and their converted animations play NOTHING in ModelDoc (the shipped
// citizen/human presets work). Reproduces both picker flows headlessly.
if ( Environment.GetEnvironmentVariable( "HR_UI_SMOKE_CUSTOM" ) == "1" )
await RunCustomTargetAsync( stepsEntry ?? entry, fixture, write );
// ---- 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>DMX folder for the custom-target runs (separate so their writes never
/// re-trigger the standalone vmdl's compile).</summary>
const string CustomDmxFolder = OutputFolder + "/custom";
const string CustomFbxDmxFolder = OutputFolder + "/customfbx";
/// <summary>
/// Reproduces the custom-target user report end-to-end:
/// (a) BASELINE - drives the default-target vmdl compiled in step 7 through actual
/// sequence playback (SceneModel.CurrentSequence) and requires bones to move,
/// proving the probe methodology on the known-good path;
/// (b) custom MODEL target - the mounted citizen human male picked through
/// TargetPickers.FromModelAsset (the window's "custom model" flow, ZUpEngine):
/// preview widget must show the model and pose it, the converted clip must
/// compile and its playback must move bones AND stay upright (pelvis height
/// matching the solved frames - catches axis double-conversion);
/// (c) custom FBX target - the main fixture picked through FromFbxFile: converted
/// clip written + compiled, playback probed the same way.
/// </summary>
static async Task RunCustomTargetAsync(
SourceFileEntry motionEntry, string fixturePath, EditorPipeline.WriteResult standaloneWrite )
{
Result.customMode = true;
// HR_UI_FIXTURE_MOTION: animation converted onto the custom targets (user-repro
// runs point this at their own clip; default = the steps fixture).
var motionOverride = Environment.GetEnvironmentVariable( "HR_UI_FIXTURE_MOTION" );
if ( !string.IsNullOrWhiteSpace( motionOverride ) && File.Exists( motionOverride ) )
{
var loaded = SourceFileEntry.Load( motionOverride, Project.Current.GetAssetsPath() );
if ( loaded.Scene is not null && loaded.Mapping is not null )
motionEntry = loaded;
else
Note( $"custom motion fixture unreadable ({loaded.StatusDetail}) - using the default" );
}
Note( $"custom-target repro: motion fixture={motionEntry.FileName}" );
// ---- (a) baseline: the default-target vmdl must actually ANIMATE ------------
try
{
if ( standaloneWrite?.VmdlAsset is not null && Result.clipNames.Length > 0 )
{
var baseline = Model.Load( standaloneWrite.VmdlAsset.Path );
var probe = ProbeSequencePlayback( baseline, Result.clipNames[0], "pelvis" );
Result.baselinePlaybackMoves = probe.Moved;
Result.baselinePlaybackDetail = probe.Detail;
Note( $"baseline playback: {probe.Detail} moved={probe.Moved}" );
}
else
Note( "baseline playback skipped: no compiled standalone vmdl" );
}
catch ( Exception e )
{
Note( $"baseline playback FAILED: {e}" );
}
Flush();
// ---- (b) custom MODEL target (FromModelAsset on the mounted citizen male) ---
try
{
var asset = AssetSystem.FindByPath( RetargetTargetSpec.SboxHumanMalePath );
if ( asset is null )
{
Note( $"custom model target: asset not found at {RetargetTargetSpec.SboxHumanMalePath}" );
}
else
{
var custom = TargetPickers.FromModelAsset( asset, out var error );
Result.customModelTargetResolved = custom is not null;
Result.customModelTargetError = error;
Note( $"custom model target: resolved={custom is not null} error='{error}' "
+ $"desc='{custom?.Description}' previewModel='{custom?.PreviewModelPath}'" );
if ( custom is not null )
await ProbeCustomTargetAsync( motionEntry, custom, CustomDmxFolder,
"ui_smoke_custom", isModelTarget: true );
}
}
catch ( Exception e )
{
Note( $"custom model target FAILED: {e}" );
}
Flush();
// ---- (c) custom FBX target ---------------------------------------------------
// HR_UI_FIXTURE_TARGETFBX should be a SKINNED humanoid FBX: the embedded
// RenderMeshFile is where the compiled model's bones and skin come from, and a
// skeleton-only animation FBX (the main fixture) embeds to an empty model.
try
{
var targetFbxPath = Environment.GetEnvironmentVariable( "HR_UI_FIXTURE_TARGETFBX" );
if ( string.IsNullOrWhiteSpace( targetFbxPath ) || !File.Exists( targetFbxPath ) )
targetFbxPath = fixturePath;
Note( $"custom fbx target fixture: {targetFbxPath}" );
// A model that ships NO images renders legitimately monochrome - the
// chromatic-pixel floor below only applies when textures exist to show.
// Sidecars live next to the FBX or in a 'textures' folder BESIDE the
// source folder (the layouts the picker copies from).
var fixtureDir = Path.GetDirectoryName( targetFbxPath ) ?? ".";
var siblingTextures = Path.Combine( Path.GetDirectoryName( fixtureDir ) ?? ".", "textures" );
Result.customFbxHasTextures = new[] { fixtureDir, siblingTextures }
.Where( Directory.Exists )
.SelectMany( d => new[] { "*.png", "*.jpg", "*.jpeg", "*.tga" }
.SelectMany( p => Directory.GetFiles( d, p, SearchOption.AllDirectories ) ) )
.Any();
Note( $"custom fbx sidecar textures present: {Result.customFbxHasTextures}" );
var customFbx = TargetPickers.FromFbxFile( targetFbxPath, out var fbxError );
Result.customFbxTargetResolved = customFbx is not null;
Result.customFbxTargetError = fbxError;
Note( $"custom fbx target: resolved={customFbx is not null} error='{fbxError}' "
+ $"desc='{customFbx?.Description}' previewModel='{customFbx?.PreviewModelPath}'" );
if ( customFbx is not null )
{
var fbxLogOffset = EditorPipeline.SboxLogLength();
// The window compiles a mesh-only preview vmdl for skinned FBX targets so
// the preview shows the ACTUAL model - same path here; on success the
// preview probe below must find a model (customFbxPreviewHasModel).
Result.customFbxPreviewModelCompiled = await EditorPipeline
.CompileFbxTargetPreviewAsync( customFbx, CustomFbxDmxFolder );
Note( $"custom fbx preview model: compiled={Result.customFbxPreviewModelCompiled} "
+ $"path='{customFbx.PreviewModelPath}'" );
await ProbeCustomTargetAsync( motionEntry, customFbx, CustomFbxDmxFolder,
"ui_smoke_customfbx", isModelTarget: false );
// Auto-generated vmats: the mesh compiles of this leg must not report any
// 'Missing vmat' (the auto-vmat generation writes one per FBX material),
// and the renderer must not spam unresolved-vtex lookups (unregistered
// textures - the per-frame retry behind the user's 2 fps preview).
var fbxSlice = EditorPipeline.ReadLogSlice( fbxLogOffset ) ?? "";
Result.customFbxMissingVmats = fbxSlice.Split( '\n' )
.Count( l => l.Contains( "Missing vmat", StringComparison.OrdinalIgnoreCase ) );
Result.customFbxTextureSpamLines = fbxSlice.Split( '\n' )
.Count( l => l.Contains( "doesn't know about texture", StringComparison.OrdinalIgnoreCase ) );
Note( $"custom fbx missing-vmat lines: {Result.customFbxMissingVmats} "
+ $"unresolved-vtex lines: {Result.customFbxTextureSpamLines}" );
}
}
catch ( Exception e )
{
Note( $"custom fbx target FAILED: {e}" );
}
Flush();
Result.customOk =
Result.baselinePlaybackMoves
&& Result.customModelTargetResolved && Result.customFbxTargetResolved
&& Result.customModelPreviewHasModel && Result.customModelPreviewPoseOk
&& Result.customModelSkeletonLines > 0 && Result.customModelSkeletonPixels > 30
&& Result.customModelNoWildBones && Result.customFbxNoWildBones
&& Result.customModelCompiled && Result.customModelSequenceVisible
&& Result.customModelPlaybackMoves && Result.customModelPlaybackUpright
&& Result.customFbxSkeletonLines > 0 && Result.customFbxSkeletonPixels > 30
&& Result.customFbxPreviewModelCompiled && Result.customFbxPreviewHasModel
&& Result.customFbxPreviewPoseOk
&& Result.customFbxMissingVmats == 0
// Texture ground truth on the rendered pixels: no error material anywhere,
// real chromatic (textured) surface present (calibrated on a real character
// with a monochrome outfit: skin tones measure ~130 chromatic pixels; an
// all-placeholder or error-material render measures ~0), no unresolved-vtex
// render spam. Texture-less models render legitimately monochrome.
&& Result.customFbxErrorPixels == 0
&& (Result.customFbxColoredPixels > 10 || !Result.customFbxHasTextures)
&& Result.customFbxTextureSpamLines == 0
&& Result.customFbxCompiled && Result.customFbxSequenceVisible
&& Result.customFbxPlaybackMoves && Result.customFbxPlaybackUpright
&& Result.customFbxEmbeddedVisible && Result.customFbxEmbeddedMoves;
Note( $"custom-target repro => customOk={Result.customOk}" );
Flush();
}
/// <summary>Saves a preview render next to the result JSON (preview_dumps/) so the
/// harness can LOOK at what the user sees - texture resolution, overlay shapes, hand
/// posture are all visual complaints pixel counts cannot diagnose.</summary>
static void DumpPreviewRender( PreviewWidget preview, string name )
=> DumpPng( () => preview.RenderToPng(), name );
/// <summary>Writes the rig (names, roles, rest locals) + solved frames next to the
/// result JSON, render_skinned.py schema.</summary>
static void TryDumpRigSolve(
HumanoidRetargeter.Core.Target.TargetRig rig, HumanoidRetargeter.Core.ClipResult clip, string fileName )
{
try
{
var skeleton = rig.Skeleton;
var path = Path.Combine( Path.GetDirectoryName( _resultPath ), fileName );
using var stream = File.Create( path );
using var w = new System.Text.Json.Utf8JsonWriter( stream );
void Xf( HumanoidRetargeter.Core.Maths.XForm xf )
{
w.WriteStartArray( "p" );
w.WriteNumberValue( xf.Pos.X ); w.WriteNumberValue( xf.Pos.Y ); w.WriteNumberValue( xf.Pos.Z );
w.WriteEndArray();
w.WriteStartArray( "r" );
w.WriteNumberValue( xf.Rot.X ); w.WriteNumberValue( xf.Rot.Y );
w.WriteNumberValue( xf.Rot.Z ); w.WriteNumberValue( xf.Rot.W );
w.WriteEndArray();
}
w.WriteStartObject();
w.WriteNumber( "frameCount", clip.SolvedFrames.Count );
w.WriteStartObject( "mapping" );
w.WriteStartObject( "targetRoles" );
for ( var i = 0; i < skeleton.Count; i++ )
{
if ( rig.RoleOf( i ) is { } role )
w.WriteString( role.ToString(), skeleton[i].Name );
}
w.WriteEndObject();
w.WriteEndObject();
w.WriteStartArray( "targetBones" );
for ( var i = 0; i < skeleton.Count; i++ )
{
w.WriteStartObject();
w.WriteString( "name", skeleton[i].Name );
w.WriteString( "parent", skeleton[i].ParentIndex >= 0
? skeleton[skeleton[i].ParentIndex].Name : "" );
w.WriteStartObject( "rest" );
Xf( skeleton[i].RestLocal );
w.WriteEndObject();
w.WriteEndObject();
}
w.WriteEndArray();
w.WriteStartArray( "frames" );
foreach ( var frame in clip.SolvedFrames )
{
w.WriteStartArray();
for ( var i = 0; i < skeleton.Count; i++ )
{
w.WriteStartObject();
Xf( frame[i] );
w.WriteEndObject();
}
w.WriteEndArray();
}
w.WriteEndArray();
w.WriteEndObject();
w.Flush();
Note( $"rig+solve dumped: {fileName} ({skeleton.Count} bones)" );
}
catch ( Exception e )
{
Note( $"rig dump failed: {e.Message}" );
}
}
static void DumpPng( Func<byte[]> render, string name )
{
try
{
var png = render();
if ( png is null )
return;
var directory = Path.Combine( Path.GetDirectoryName( _resultPath ), "preview_dumps" );
Directory.CreateDirectory( directory );
File.WriteAllBytes( Path.Combine( directory, name + ".png" ), png );
Note( $"preview render dumped: preview_dumps/{name}.png" );
}
catch ( Exception e )
{
Note( $"preview render dump failed ({name}): {e.Message}" );
}
}
/// <summary>Converts the motion fixture onto <paramref name="target"/>, probes the
/// PreviewWidget (model visible + posed pelvis matching the solved frame), writes +
/// compiles a standalone vmdl and probes actual sequence playback on the compiled
/// model. Fills the customModel*/customFbx* result fields.</summary>
static async Task ProbeCustomTargetAsync(
SourceFileEntry motionEntry, TargetPickers.ResolvedTarget target,
string dmxFolder, string vmdlName, bool isModelTarget )
{
var tag = isModelTarget ? "custom model" : "custom fbx";
// Custom FBX targets: same mesh-embed preparation the window's convert path runs
// (copy the FBX into the output folder + set the spec's MeshFilePath) - without it
// the standalone vmdl compiles into an empty model.
if ( !EditorPipeline.PrepareFbxTargetMesh( target, dmxFolder, out var meshError ) )
Note( $"{tag}: PrepareFbxTargetMesh FAILED: {meshError}" );
else if ( target.FbxAbsolutePath is not null )
Note( $"{tag}: mesh embedded as '{target.Spec.MeshFilePath}' importScale={target.Spec.MeshImportScale}" );
var request = new RetargetRequest
{
SourceData = motionEntry.Bytes,
SourceFileName = motionEntry.FileName,
SourceId = motionEntry.FilePath,
MappingOverride = motionEntry.Mapping,
RootMotion = Core.Cleanup.RootMotionMode.Off,
FootPlantCleanup = true,
ArmEffectorIk = false,
LoopingOverride = null,
};
var requests = new List<RetargetRequest> { request };
requests.AddRange( EditorPipeline.BuildEmbeddedTakeRequests( target ) );
var batch = await Task.Run( () => Retargeter.ConvertBatch(
requests, target.Spec,
new BatchOptions { DmxFolderRelative = dmxFolder } ) );
var clip = batch.Clips.FirstOrDefault( c => c.Success && c.SolvedFrames is { Count: > 0 } );
Note( $"{tag}: convert clips={batch.Clips.Count} solved={clip is not null} "
+ $"errors=[{string.Join( "; ", batch.Clips.Where( c => !c.Success ).Select( c => c.Error ) )}]" );
if ( clip is null )
return;
// Diagnostic dump of the (possibly engine-rebuilt) rig + this solve, in the same
// schema the Blender render harness reads - lets a headless-vs-editor solve be
// diffed bone by bone when the two disagree visually.
if ( !isModelTarget )
TryDumpRigSolve( target.Spec.Rig, clip, $"{vmdlName}_rig_solve.json" );
// Visual bisect hook: HR_UI_SMOKE_REST_BONES=<substr>[,<substr>] forces matching
// bones back to their rest locals in the SOLVED frames before the preview and
// compile - isolates which bone group causes an engine-side render artifact.
var restPatterns = Environment.GetEnvironmentVariable( "HR_UI_SMOKE_REST_BONES" );
if ( !string.IsNullOrWhiteSpace( restPatterns ) && !isModelTarget )
{
// "!pattern" = INVERTED: rest everything that does NOT match (single-bone repro).
var inverted = restPatterns.StartsWith( '!' );
var patterns = restPatterns.TrimStart( '!' )
.Split( ',', StringSplitOptions.RemoveEmptyEntries );
var skeleton = target.Spec.Rig.Skeleton;
var rested = 0;
for ( var i = 0; i < skeleton.Count; i++ )
{
var matches = patterns.Any( p =>
skeleton[i].Name.Contains( p.Trim(), StringComparison.OrdinalIgnoreCase ) );
if ( matches != inverted )
{
foreach ( var frame in clip.SolvedFrames )
frame[i] = skeleton[i].RestLocal;
rested++;
}
}
// Re-serialize so the COMPILE reflects the bisect too (the DMX was written
// inside ConvertBatch before this hook ran) - mirrors EmitClip incl. the
// embedded-mesh root-yaw compensation.
clip.OverrideDmxContent( HumanoidRetargeter.Core.Formats.Dmx.DmxWriter.Write(
skeleton,
new HumanoidRetargeter.Core.Skeleton.Clip( clip.ClipName, clip.Fps, clip.Looping,
HumanoidRetargeter.Core.Retargeter.TestHook_CompensateEmbeddedMeshRootYaw(
clip.SolvedFrames, target.Spec ) ),
new HumanoidRetargeter.Core.Formats.Dmx.DmxWriteOptions
{
Name = clip.ClipName,
SourceNote = "rest-bones bisect",
UpAxisY = target.Spec.UpAxis == TargetUpAxis.YUpCm,
} ) );
Note( $"REST-BONES bisect: {rested} bones forced to rest ({restPatterns}) - DMX re-serialized" );
}
// Expected pelvis (rig space) at a mid frame, FK'd from the solved locals.
var rig = target.Spec.Rig;
var hipsIndex = rig.BoneForRole( HumanoidRetargeter.Core.Mapping.BoneRole.Hips ) ?? 0;
var pelvisName = rig.Skeleton[hipsIndex].Name;
var frameIndex = Math.Min( clip.SolvedFrames.Count - 1, clip.SolvedFrames.Count / 2 );
var rigPelvis = new HumanoidRetargeter.Core.Skeleton.Pose( clip.SolvedFrames[frameIndex] )
.ToWorld( rig.Skeleton )[hipsIndex].Pos;
// Engine-space expectation mirrors PreviewWidget.RigWorldToEngine (Y-up rigs get
// the basis rotation; Z-up rigs - engine models AND Z-up FBX exports - do not).
var expectedEngine = target.Spec.UpAxis == TargetUpAxis.YUpCm
? new Vector3( rigPelvis.X, -rigPelvis.Z, rigPelvis.Y ) * target.PreviewPositionScale
: new Vector3( rigPelvis.X, rigPelvis.Y, rigPelvis.Z ) * target.PreviewPositionScale;
// ---- preview widget probe ---------------------------------------------------
PreviewWidget probePreview = null;
try
{
var preview = probePreview = new PreviewWidget(
null, rig, target.PreviewModelPath, target.PreviewPositionScale, target.Spec.UpAxis );
var hasModel = preview.HasModel;
preview.SetClip( clip );
preview.Scrub( frameIndex );
preview.ApplyCurrentFrame();
var actual = preview.GetModelBoneTransform( pelvisName )?.Position;
var poseOk = actual is { } a && a.Distance( expectedEngine ) < 0.5f;
// No bone may sit farther from the pelvis than ~3 character heights: exploded
// FK (world-as-local bind transforms) kept the pelvis exactly right while the
// head compounded to 220in away - pelvis-only asserts can never catch it.
var wildRadius = MathF.Max( 150f, expectedEngine.z * 3f );
var wildReport = hasModel ? preview.WildBoneReport( pelvisName, wildRadius ) : "(no model)";
var wildOk = !hasModel || wildReport == "(none)";
if ( hasModel )
{
Note( $"{tag} wild bones (>{wildRadius:0}in from pelvis): {wildReport}" );
Note( $"{tag} rig-vs-bind: {preview.BindMismatchReport()}" );
}
// TEXTURE CORRECTNESS, asserted on the FINAL RENDERED PIXELS (the fool-proof
// ground truth): no error-magenta anywhere, and a real amount of chromatic
// (textured) surface - a placeholder-gray or error-material model fails both.
var errorPixels = 0;
var coloredPixels = 0;
if ( hasModel )
{
try
{
errorPixels = preview.CountRenderedPixels(
c => c.r > 0.7f && c.b > 0.7f && c.g < 0.35f );
coloredPixels = preview.CountRenderedPixels(
c => MathF.Abs( c.r - c.g ) + MathF.Abs( c.g - c.b ) > 0.15f );
}
catch ( Exception e )
{
Note( $"{tag} texture pixel probe threw: {e.Message}" );
}
}
DumpPreviewRender( preview, $"{vmdlName}_skinned" );
// Multi-frame skinned dumps: progressive-stretch reports ("it stretched MORE
// when playing") need more than one pose to diagnose.
foreach ( var fraction in new[] { 0.25f, 0.75f } )
{
preview.Scrub( (int)((clip.SolvedFrames.Count - 1) * fraction) );
preview.ApplyCurrentFrame();
DumpPreviewRender( preview, $"{vmdlName}_skinned_{(int)(fraction * 100)}" );
}
preview.Scrub( frameIndex );
preview.ApplyCurrentFrame();
// The embedded take's solved pose (round-trips through the role cascade now -
// wrist quality must hold there too, not just on retargeted clips).
if ( !isModelTarget && target.EmbeddedTakeNames is { Count: > 0 } embeddedNames )
{
var embeddedClip = batch.Clips.FirstOrDefault( c =>
c.Success && c.ClipName == embeddedNames[0] && c.SolvedFrames is { Count: > 0 } );
if ( embeddedClip is not null )
{
preview.SetClip( embeddedClip );
preview.Scrub( embeddedClip.SolvedFrames.Count / 2 );
preview.ApplyCurrentFrame();
DumpPreviewRender( preview, $"{vmdlName}_embedded" );
if ( rig.BoneForRole( HumanoidRetargeter.Core.Mapping.BoneRole.HandR ) is { } embHand )
{
var embBone = rig.Skeleton[embHand].Name;
var embRadius = MathF.Max( expectedEngine.z * 0.22f, 8f );
DumpPng( () => preview.RenderBoneCloseUpPng( embBone, embRadius ),
$"{vmdlName}_embedded_hand_R" );
}
preview.SetClip( clip );
preview.Scrub( frameIndex );
preview.ApplyCurrentFrame();
}
}
// Close-ups (user reports: "fingers and wrist still look weird", "the makeup
// around the eye is white" - full-body renders are too small to judge).
if ( hasModel )
{
foreach ( var (role, suffix, scale) in new[]
{
(HumanoidRetargeter.Core.Mapping.BoneRole.HandR, "hand_R", 0.22f),
(HumanoidRetargeter.Core.Mapping.BoneRole.HandL, "hand_L", 0.22f),
// Head camera needs more distance than the hands: at 0.28x the near plane
// sat INSIDE large cartoon skulls and the dump rendered a white wall.
(HumanoidRetargeter.Core.Mapping.BoneRole.Head, "head", 0.4f),
} )
{
if ( rig.BoneForRole( role ) is { } boneIndex )
{
var boneName = rig.Skeleton[boneIndex].Name;
var radius = MathF.Max( expectedEngine.z * scale, 8f );
DumpPng( () => preview.RenderBoneCloseUpPng( boneName, radius ),
$"{vmdlName}_{suffix}" );
}
}
}
// Source ghost with THIS motion fixture (user reports "two bars" instead of a
// skeleton) - rendered for visual diagnosis alongside the pose asserts.
try
{
preview.SetSourceGhost( motionEntry.Scene.Skeleton, motionEntry.Scene.Clips[0],
motionEntry.Mapping );
preview.ShowSourceGhost = true;
preview.ApplyCurrentFrame();
DumpPreviewRender( preview, $"{vmdlName}_ghost" );
preview.ShowSourceGhost = false;
}
catch ( Exception e )
{
Note( $"{tag} ghost dump failed: {e.Message}" );
}
// Wireframe-skeleton view: model targets must be able to switch onto it (for
// FBX targets it is the fallback while the preview model compiles). Verified at
// the PIXEL level - line counts cannot see a missing line material (user
// report: the view rendered as a single purple blob).
preview.SkeletonOnly = true;
preview.ApplyCurrentFrame();
var skeletonLines = preview.SkeletonLineCount;
var skeletonPixels = 0;
try
{
skeletonPixels = preview.CountRenderedPixels(
c => c.r > 0.35f && MathF.Abs( c.r - c.g ) < 0.08f && MathF.Abs( c.g - c.b ) < 0.08f ); // Blender-gray bones
}
catch ( Exception e )
{
Note( $"{tag} skeleton pixel probe threw: {e.Message}" );
}
DumpPreviewRender( preview, $"{vmdlName}_skeleton" );
Note( $"{tag} skeleton geometry: {preview.SkeletonDebug}" );
preview.SkeletonOnly = false;
preview.ApplyCurrentFrame();
Note( $"{tag} preview: hasModel={hasModel} pelvis actual={actual} "
+ $"expected={expectedEngine} poseOk={poseOk} skeletonLines={skeletonLines} "
+ $"skeletonPixels={skeletonPixels} errorPixels={errorPixels} coloredPixels={coloredPixels}" );
if ( isModelTarget )
{
Result.customModelPreviewHasModel = hasModel;
Result.customModelPreviewPoseOk = poseOk;
Result.customModelSkeletonLines = skeletonLines;
Result.customModelSkeletonPixels = skeletonPixels;
Result.customModelNoWildBones = wildOk;
}
else
{
Result.customFbxPreviewHasModel = hasModel;
Result.customFbxPreviewPoseOk = poseOk;
Result.customFbxSkeletonLines = skeletonLines;
Result.customFbxSkeletonPixels = skeletonPixels;
Result.customFbxErrorPixels = errorPixels;
Result.customFbxColoredPixels = coloredPixels;
Result.customFbxNoWildBones = wildOk;
}
preview.Destroy();
}
catch ( Exception e )
{
Note( $"{tag} preview FAILED: {e}" );
}
Flush();
// ---- write + compile + playback probe ----------------------------------------
var write = await EditorPipeline.WriteAndCompileAsync(
batch, dmxFolder, augmentVmdlPath: null, standaloneVmdlName: vmdlName,
compileTimeoutSeconds: string.IsNullOrEmpty( target.Spec.MeshFilePath )
? 120f : EditorPipeline.MeshCompileTimeoutSeconds );
var compiled = write.Compiled && write.VmdlAsset is not null;
Note( $"{tag} write+compile: vmdl={write.VmdlPath} compiled={write.Compiled} "
+ $"errors=[{string.Join( "; ", write.Errors )}]" );
var sequenceVisible = false;
var playbackMoved = false;
var playbackUpright = false;
var lateralAngle = 0f;
string playbackDetail = null;
if ( compiled )
{
var model = Model.Load( write.VmdlAsset.Path );
var names = model?.AnimationNames?.ToArray() ?? Array.Empty<string>();
sequenceVisible = model is not null && !model.IsError
&& names.Any( n => string.Equals( n, clip.ClipName, StringComparison.OrdinalIgnoreCase ) );
Note( $"{tag} compiled model: error={model?.IsError} bones={model?.BoneCount} "
+ $"anims=[{string.Join( ", ", names )}] sequenceVisible={sequenceVisible}" );
if ( model is not null && !model.IsError )
{
// The target FBX's own embedded animations must be ON the compiled model
// and actually play (user: "it should have two animations"). Take-less
// files (mesh-only exports) trivially satisfy the requirement.
if ( !isModelTarget && target.EmbeddedTakeNames is not { Count: > 0 } )
{
Result.customFbxEmbeddedVisible = true;
Result.customFbxEmbeddedMoves = true;
}
if ( !isModelTarget && target.EmbeddedTakeNames is { Count: > 0 } embedded )
{
Result.customFbxEmbeddedVisible = embedded.All( e =>
names.Any( n => string.Equals( n, e, StringComparison.OrdinalIgnoreCase ) ) );
var embeddedProbe = ProbeSequencePlayback( model, embedded[0], pelvisName );
// A STATIC take (zero-length bind-pose AnimStack, common in Sketchfab
// exports - the catgirl ships one) legitimately moves nothing.
var staticTake = embeddedProbe.Detail.Contains( "duration=0s" );
Result.customFbxEmbeddedMoves = embeddedProbe.Moved || staticTake;
Note( $"{tag} embedded animation(s) [{string.Join( ", ", embedded )}]: "
+ $"visible={Result.customFbxEmbeddedVisible} playback: {embeddedProbe.Detail} "
+ $"moved={embeddedProbe.Moved} staticTake={staticTake}" );
}
// Finger-integrity probe ON THE COMPILED MODEL: hand→fingertip distance mid-
// sequence vs the rig rest distance. Catches finger corruption the pelvis/
// upright checks can't see (user: "fingers stretched like crazy" - the
// headless solve measures clean, so where the corruption enters matters).
if ( rig.BoneForRole( HumanoidRetargeter.Core.Mapping.BoneRole.HandR ) is { } probeHand
&& rig.BoneForRole( HumanoidRetargeter.Core.Mapping.BoneRole.MiddleDistR ) is { } probeTip )
{
SceneWorld probeWorld = null;
SceneModel probeModel = null;
try
{
probeWorld = new SceneWorld();
probeModel = new SceneModel( probeWorld, model, Transform.Zero );
probeModel.UseAnimGraph = false;
probeModel.CurrentSequence.Name = clip.ClipName;
probeModel.CurrentSequence.Time = probeModel.CurrentSequence.Duration * 0.75f;
probeModel.Update( 0.001f );
var handT = probeModel.GetBoneWorldTransform( rig.Skeleton[probeHand].Name );
var tipT = probeModel.GetBoneWorldTransform( rig.Skeleton[probeTip].Name );
var engineDist = handT.Position.Distance( tipT.Position );
var restDist = (rig.Skeleton.RestWorld[probeTip].Pos
- rig.Skeleton.RestWorld[probeHand].Pos).Length()
* target.PreviewPositionScale;
Note( $"{tag} COMPILED finger probe: hand→middle tip {engineDist:0.##} "
+ $"engine-units vs rest {restDist:0.##} (ratio {engineDist / MathF.Max( restDist, 0.001f ):0.##})" );
// THE check the bind audit skips: ROOT-LEVEL bones (the hips on
// re-rooted rigs!). Compiled world vs the anim compiler's declared
// conversion (+90°X for Y-up DMX) of OUR solved local - any delta
// here rotates the whole body in ModelDoc while every pelvis-height
// and hips-relative metric stays blind to it.
{
var rootFrame = Math.Min( clip.SolvedFrames.Count - 1, clip.SolvedFrames.Count / 2 );
probeModel.CurrentSequence.Time = rootFrame / MathF.Max( clip.Fps, 1f );
probeModel.Update( 0.001f );
var yUp = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitX, MathF.PI * 0.5f );
var worstRoot = "";
var worstAngle = 0f;
var offRoots = 0;
for ( var i = 0; i < rig.Skeleton.Count; i++ )
{
if ( rig.Skeleton[i].ParentIndex >= 0 )
continue;
var local = clip.SolvedFrames[rootFrame][i];
var expectedRot = target.Spec.UpAxis == TargetUpAxis.YUpCm
? System.Numerics.Quaternion.Normalize( yUp * local.Rot )
: local.Rot;
var actual = probeModel.GetBoneWorldTransform( rig.Skeleton[i].Name ).Rotation;
var dot = MathF.Min( MathF.Abs(
expectedRot.X * actual.x + expectedRot.Y * actual.y
+ expectedRot.Z * actual.z + expectedRot.W * actual.w ), 1f );
var angle = 2f * MathF.Acos( dot ) * 180f / MathF.PI;
if ( angle > 2f )
offRoots++;
if ( angle > worstAngle )
{
worstAngle = angle;
worstRoot = rig.Skeleton[i].Name;
// Delta = actual ∘ expected⁻¹ (engine space): its axis
// names the missing conversion in the rebuild fold.
var delta = new System.Numerics.Quaternion( actual.x, actual.y, actual.z, actual.w )
* System.Numerics.Quaternion.Conjugate( expectedRot );
if ( delta.W < 0 ) delta = new System.Numerics.Quaternion( -delta.X, -delta.Y, -delta.Z, -delta.W );
var axis = System.Numerics.Vector3.Normalize(
new System.Numerics.Vector3( delta.X, delta.Y, delta.Z ) );
worstRoot += $" axis=({axis.X:0.##},{axis.Y:0.##},{axis.Z:0.##})";
}
}
Note( $"{tag} COMPILED ROOT-BONE audit (f{rootFrame}): {offRoots} root bones >2deg off; "
+ $"worst '{worstRoot}' {worstAngle:0.#}deg" );
}
// Full-skeleton ROTATION audit: compiled playback world rotations vs
// +90X·FK(compensated frames) — the exact space the compiler plays
// (the root channels ship pre-yawed, see CompensateEmbeddedMeshRootYaw).
// Names the bones whose compiled result diverges from our data.
{
var frameIdx = Math.Min( clip.SolvedFrames.Count - 1, clip.SolvedFrames.Count / 2 );
probeModel.CurrentSequence.Time = frameIdx / MathF.Max( clip.Fps, 1f );
probeModel.Update( 0.001f );
var yawFix = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitY, -MathF.PI * 0.5f );
var upConv = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitX, MathF.PI * 0.5f );
var locals = new HumanoidRetargeter.Core.Maths.XForm[rig.Skeleton.Count];
for ( var i = 0; i < rig.Skeleton.Count; i++ )
{
var local = clip.SolvedFrames[frameIdx][i];
locals[i] = rig.Skeleton[i].ParentIndex < 0
&& target.Spec.UpAxis == TargetUpAxis.YUpCm
&& !string.IsNullOrEmpty( target.Spec.MeshFilePath )
? new HumanoidRetargeter.Core.Maths.XForm(
System.Numerics.Vector3.Transform( local.Pos, yawFix ),
System.Numerics.Quaternion.Normalize( yawFix * local.Rot ) )
: local;
}
var world = new HumanoidRetargeter.Core.Skeleton.Pose( locals ).ToWorld( rig.Skeleton );
// The compiled skeleton lives in a GLOBALLY yawed frame (the same
// +90° about engine-up the root compensation targets) - fold it
// into the expectation, and skip ConstraintDriven bones (a bare
// SceneModel probe zeroes their constraints; false 90-100° reads).
var globalYaw = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitZ, MathF.PI * 0.5f );
var offenders = new List<(float Err, string Line)>();
for ( var i = 0; i < rig.Skeleton.Count; i++ )
{
if ( rig.ClassOf( i ) == HumanoidRetargeter.Core.Target.BoneClass.ConstraintDriven )
continue;
var expectedRot = target.Spec.UpAxis == TargetUpAxis.YUpCm
? System.Numerics.Quaternion.Normalize( globalYaw * (upConv * world[i].Rot) )
: world[i].Rot;
var actual = probeModel.GetBoneWorldTransform( rig.Skeleton[i].Name ).Rotation;
var dot = MathF.Min( MathF.Abs(
expectedRot.X * actual.x + expectedRot.Y * actual.y
+ expectedRot.Z * actual.z + expectedRot.W * actual.w ), 1f );
var angle = 2f * MathF.Acos( dot ) * 180f / MathF.PI;
if ( angle > 5f )
offenders.Add( (angle, $"{rig.Skeleton[i].Name} {angle:0.#}deg") );
}
Note( $"{tag} COMPILED ROTATION audit (f{frameIdx}): {offenders.Count} bones >5deg off; worst: "
+ string.Join( ", ", offenders.OrderByDescending( o => o.Err )
.Take( 12 ).Select( o => o.Line ) ) );
}
// Renders of the COMPILED model mid-sequence - the ModelDoc ground
// truth, next to the PreviewWidget dumps (the two have disagreed).
// A FRESH widget every time: the probe-section widget is destroyed
// by the frame pump between sections (parentless editor widget).
if ( write.VmdlAsset is not null )
{
var compiledPreview = new PreviewWidget( null, rig,
target.PreviewModelPath, target.PreviewPositionScale, target.Spec.UpAxis );
foreach ( var fraction in new[] { 0.25f, 0.5f, 0.75f } )
{
var png = compiledPreview.RenderCompiledSequencePng(
write.VmdlAsset.Path, clip.ClipName, fraction );
if ( png is null )
Note( $"{tag} compiled render {fraction:P0} returned NULL" );
else
DumpPng( () => png, $"{vmdlName}_compiled_{(int)(fraction * 100)}" );
}
}
}
catch ( Exception e )
{
Note( $"{tag} compiled finger probe threw: {e.Message}" );
}
finally
{
probeModel?.Delete();
probeWorld?.Delete();
}
}
var legL = rig.BoneForRole( HumanoidRetargeter.Core.Mapping.BoneRole.UpperLegL );
var legR = rig.BoneForRole( HumanoidRetargeter.Core.Mapping.BoneRole.UpperLegR );
var probe = ProbeSequencePlayback( model, clip.ClipName, pelvisName,
legL is { } l ? rig.Skeleton[l].Name : null,
legR is { } r ? rig.Skeleton[r].Name : null );
playbackMoved = probe.Moved;
playbackDetail = probe.Detail;
// Upright check: compiled pelvis height must match the solved frames
// (an axis double-conversion lays the character down / buries it).
playbackUpright = probe.PelvisMid is { } mid
&& MathF.Abs( mid.z - expectedEngine.z ) < 3f;
// Axis diagnostic (informational, not gated): angle between the compiled
// mid-frame hip line and the SOLVED mid frame's (engine-converted). A
// GLOBAL yaw here is benign - the skin follows the bones, the character
// just faces a different compass direction (the compiler's Z-up handling
// includes one); per-bone axis garbage is caught by upright + preview-pose.
if ( probe.HipLineMid is { } actualHip && legL is { } el && legR is { } er )
{
var world = new HumanoidRetargeter.Core.Skeleton.Pose( clip.SolvedFrames[frameIndex] )
.ToWorld( rig.Skeleton );
var rigHip = world[el].Pos - world[er].Pos;
var expectedHip = target.Spec.UpAxis == TargetUpAxis.YUpCm
? new Vector3( rigHip.X, -rigHip.Z, rigHip.Y )
: new Vector3( rigHip.X, rigHip.Y, rigHip.Z );
var a = actualHip.WithZ( 0 );
var e2 = expectedHip.WithZ( 0 );
if ( a.Length > 0.1f && e2.Length > 0.1f )
{
var dot = Math.Clamp( a.Normal.Dot( e2.Normal ), -1f, 1f );
lateralAngle = MathX.RadianToDegree( MathF.Acos( dot ) );
}
}
Note( $"{tag} playback: {probe.Detail} moved={probe.Moved} "
+ $"pelvisMid={probe.PelvisMid} expectedZ={expectedEngine.z:0.##} upright={playbackUpright} "
+ $"solvedVsCompiledHipLine={lateralAngle:0.#}deg" );
}
}
if ( isModelTarget )
{
Result.customModelCompiled = compiled;
Result.customModelSequenceVisible = sequenceVisible;
Result.customModelPlaybackMoves = playbackMoved;
Result.customModelPlaybackUpright = playbackUpright;
Result.customModelPlaybackDetail = playbackDetail;
Result.customModelSolvedVsCompiledHipDeg = lateralAngle;
Result.customModelWriteErrors = write.Errors.ToArray();
}
else
{
Result.customFbxCompiled = compiled;
Result.customFbxSequenceVisible = sequenceVisible;
Result.customFbxPlaybackMoves = playbackMoved;
Result.customFbxPlaybackUpright = playbackUpright;
Result.customFbxPlaybackDetail = playbackDetail;
Result.customFbxSolvedVsCompiledHipDeg = lateralAngle;
Result.customFbxWriteErrors = write.Errors.ToArray();
}
Flush();
}
/// <summary>Drives a compiled sequence directly (SceneModel.CurrentSequence - the same
/// mechanism ModelDoc playback uses) and measures whether any bone actually moves
/// between t=0 and the sequence midpoint. When <paramref name="lateralBoneL"/>/<paramref name="lateralBoneR"/>
/// are given (the rig's upper legs), additionally returns the mid-frame hip line
/// (L−R, engine space) - the caller compares it against the SOLVED frames' hip line at
/// the same normalized time to prove the compiled animation plays in the axes the
/// solver produced (a ~90° disagreement is the Z-up mismatch class).</summary>
static (bool Moved, string Detail, Vector3? PelvisMid, Vector3? HipLineMid) ProbeSequencePlayback(
Model model, string sequenceName, string pelvisName,
string lateralBoneL = null, string lateralBoneR = null )
{
SceneWorld world = null;
SceneModel sceneModel = null;
try
{
world = new SceneWorld();
sceneModel = new SceneModel( world, model, Transform.Zero );
sceneModel.UseAnimGraph = false;
var boneCount = model.BoneCount;
sceneModel.CurrentSequence.Name = sequenceName;
var duration = sceneModel.CurrentSequence.Duration;
Vector3[] Sample( float time )
{
sceneModel.CurrentSequence.Time = time;
sceneModel.Update( 0.001f );
var positions = new Vector3[boneCount];
for ( var i = 0; i < boneCount; i++ )
positions[i] = sceneModel.GetBoneWorldTransform( i ).Position;
return positions;
}
var start = Sample( 0f );
var midTime = MathF.Max( duration * 0.5f, 0.05f );
var mid = Sample( midTime );
float maxDelta = 0;
var maxBone = -1;
for ( var i = 0; i < boneCount; i++ )
{
var d = start[i].Distance( mid[i] );
if ( d > maxDelta ) { maxDelta = d; maxBone = i; }
}
Vector3? pelvisMid = null;
var pelvisBone = model.Bones?.GetBone( pelvisName );
if ( pelvisBone is not null )
pelvisMid = mid[pelvisBone.Index];
Vector3? hipLineMid = null;
var boneL = lateralBoneL is not null ? model.Bones?.GetBone( lateralBoneL ) : null;
var boneR = lateralBoneR is not null ? model.Bones?.GetBone( lateralBoneR ) : null;
if ( boneL is not null && boneR is not null )
hipLineMid = mid[boneL.Index] - mid[boneR.Index];
var detail = $"seq='{sequenceName}' duration={duration:0.###}s bones={boneCount} "
+ $"maxDelta={maxDelta:0.###}in (bone "
+ $"{(maxBone >= 0 ? model.Bones.AllBones.ElementAtOrDefault( maxBone )?.Name ?? maxBone.ToString() : "none")})";
return (maxDelta > 0.5f, detail, pelvisMid, hipLineMid);
}
catch ( Exception e )
{
return (false, $"playback probe threw: {e.Message}", null, null);
}
finally
{
sceneModel?.Delete();
world?.Delete();
}
}
/// <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";
/// <summary>Sequence name of the stale AnimFile planted into the augment target in an5
/// mode (its DMX deliberately never exists - the user-report repro).</summary>
const string StaleProbeName = "hr_stale_probe";
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 List<RetargetRequest>
{
new RetargetRequest
{
SourceData = entry.Bytes,
SourceFileName = entry.FileName,
SourceId = entry.FilePath,
MappingOverride = entry.Mapping,
RootMotion = Core.Cleanup.RootMotionMode.Off,
FootPlantCleanup = true,
ArmEffectorIk = false,
LoopingOverride = null,
},
};
// User-report batch shape (HR_UI_FIXTURE_AN5): a BVH plus EVERY take of a
// RenderWare .an5 bank, one request per take row exactly like the window's
// Convert All (TakeIndex per row, companion .dff as SkeletonData). This is the
// mixed batch that produced "vmdl did not compile" on a real citizen vmdl copy;
// with it armed the augment gate REQUIRES the recompile to succeed.
var an5Path = Environment.GetEnvironmentVariable( "HR_UI_FIXTURE_AN5" );
if ( !string.IsNullOrWhiteSpace( an5Path ) && File.Exists( an5Path ) )
{
Result.augmentAn5Mode = true;
var assetsPath = Project.Current.GetAssetsPath();
var bvhPath = Environment.GetEnvironmentVariable( "HR_UI_FIXTURE_STEPS" );
if ( !string.IsNullOrWhiteSpace( bvhPath ) && File.Exists( bvhPath ) )
{
var bvhEntry = SourceFileEntry.Load( bvhPath, assetsPath );
if ( bvhEntry.Scene is not null && bvhEntry.Mapping is not null )
{
requests.Add( new RetargetRequest
{
SourceData = bvhEntry.Bytes,
SourceFileName = bvhEntry.FileName,
SourceId = bvhEntry.FilePath,
MappingOverride = bvhEntry.Mapping,
RootMotion = Core.Cleanup.RootMotionMode.Off,
FootPlantCleanup = true,
ArmEffectorIk = false,
LoopingOverride = null,
} );
}
else
Note( $"augment an5 mode: bvh fixture unreadable: {bvhEntry.StatusDetail}" );
}
var an5Entry = SourceFileEntry.Load( an5Path, assetsPath );
if ( an5Entry.Scene is null || an5Entry.Mapping is null )
{
Note( $"augment an5 mode: an5 fixture unreadable: {an5Entry.StatusDetail} "
+ $"(skeleton={an5Entry.SkeletonPath ?? "none"})" );
}
else
{
Result.augmentAn5Takes = an5Entry.ClipCount;
for ( var i = 0; i < an5Entry.ClipCount; i++ )
{
requests.Add( new RetargetRequest
{
SourceData = an5Entry.Bytes,
SourceFileName = an5Entry.FileName,
SkeletonData = an5Entry.SkeletonBytes,
SourceId = an5Entry.FilePath + "#" + i,
MappingOverride = an5Entry.Mapping,
TakeIndex = an5Entry.ClipCount > 1 ? i : null,
RootMotion = Core.Cleanup.RootMotionMode.Off,
FootPlantCleanup = true,
ArmEffectorIk = false,
LoopingOverride = null,
} );
}
Note( $"augment an5 mode: {an5Entry.FileName} takes={an5Entry.ClipCount} "
+ $"skeleton={Path.GetFileName( an5Entry.SkeletonPath ?? "none" )} "
+ $"profile={an5Entry.Mapping.ProfileName} requests={requests.Count}" );
}
// USER-REPORT REPRO: plant a stale AnimFile into the augment target BEFORE
// converting - an entry from an "earlier batch" whose DMX no longer exists on
// disk (the user's vmdl had accumulated animations/retargeted/*.dmx entries,
// then the files went away). One such entry fails the ENTIRE recompile
// ("Node 'X' resolve failure" -> ResourceCompilerSystem [FAIL]); the fix must
// prune it so the augmented vmdl still compiles.
try
{
var staleVmdl = Core.Target.VmdlAugmenter.Augment(
File.ReadAllText( augmentVmdlPath ),
new[]
{
new Core.Target.AnimEntry
{
Name = StaleProbeName,
SourceFilename = "animations/retargeted/" + StaleProbeName + ".dmx",
},
},
out _,
new Core.Target.AugmentOptions { DefaultRootBone = "pelvis" } );
File.WriteAllText( augmentVmdlPath, staleVmdl );
Result.augmentStalePlanted = true;
Note( $"planted stale AnimFile '{StaleProbeName}' (missing dmx) into the augment target" );
}
catch ( Exception e )
{
Note( $"could not plant stale AnimFile: {e.Message}" );
}
}
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 ) );
Result.augmentClipNames = batch.Clips
.Where( c => c.Success ).Select( c => c.ClipName ).ToArray();
Result.augmentFailedClips = batch.Clips
.Where( c => !c.Success ).Select( c => $"{c.ClipName}: {c.Error}" ).ToArray();
// The ACTUAL resourcecompiler verdict for the augment vmdl: every error-looking
// line of the per-run log slice that mentions it (the editor console shows these;
// the log carries at least the recompile ERROR line).
var vmdlFileName = Path.GetFileName( augmentVmdlPath );
Result.augmentCompileErrors = (EditorPipeline.ReadLogSlice( logOffset ) ?? "")
.Split( '\n' )
.Select( l => l.TrimEnd( '\r' ) )
.Where( l => l.Length > 0
&& (l.Contains( "error", StringComparison.OrdinalIgnoreCase )
|| l.Contains( "fail", StringComparison.OrdinalIgnoreCase ))
&& (l.Contains( vmdlFileName, StringComparison.OrdinalIgnoreCase )
|| l.Contains( "resourcecompiler", StringComparison.OrdinalIgnoreCase )
|| l.Contains( "ModelDoc", StringComparison.OrdinalIgnoreCase )) )
.ToArray();
// 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 ) );
// Stale-entry verdict: the planted missing-DMX AnimFile must be GONE from
// the written vmdl and reported as a batch warning - otherwise the recompile
// above could only have failed.
Result.augmentWarnings = batch.Warnings.ToArray();
if ( Result.augmentStalePlanted )
{
Result.augmentStalePruned =
!text.Contains( StaleProbeName, StringComparison.OrdinalIgnoreCase )
&& batch.Warnings.Any( w =>
w.Contains( StaleProbeName, StringComparison.Ordinal ) );
Note( $"stale AnimFile probe: pruned={Result.augmentStalePruned} "
+ $"warnings=[{string.Join( " | ", batch.Warnings )}]" );
}
}
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
// The user-report repro (an5 mode) proves the augmented citizen vmdl really
// COMPILES - the scratch project resolves the citizen addon's meshes/prefabs,
// so a compile failure here is a genuine regression, never noise - and that
// the planted stale (missing-DMX) entry was pruned rather than left to fail
// the recompile.
&& (!Result.augmentAn5Mode
|| (Result.augmentCompiled
&& Result.augmentStalePlanted && Result.augmentStalePruned));
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 bool citizenAnimationSetupOk { 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 bool augmentAn5Mode { get; set; }
public int augmentAn5Takes { get; set; }
public string[] augmentClipNames { get; set; } = Array.Empty<string>();
public string[] augmentFailedClips { get; set; } = Array.Empty<string>();
public string[] augmentCompileErrors { get; set; } = Array.Empty<string>();
public bool augmentStalePlanted { get; set; }
public bool augmentStalePruned { get; set; }
public string[] augmentWarnings { get; set; } = Array.Empty<string>();
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 previewSkeletonViewOk { get; set; }
public int previewSkeletonPixels { get; set; }
public int previewGhostPixels { get; set; }
// custom-target repro (HR_UI_SMOKE_CUSTOM)
public bool customMode { get; set; }
public bool baselinePlaybackMoves { get; set; }
public string baselinePlaybackDetail { get; set; }
public bool customModelTargetResolved { get; set; }
public string customModelTargetError { get; set; }
public bool customModelPreviewHasModel { get; set; }
public bool customModelPreviewPoseOk { get; set; }
public int customModelSkeletonLines { get; set; }
public int customModelSkeletonPixels { get; set; }
public bool customModelNoWildBones { get; set; }
public bool customFbxNoWildBones { get; set; }
public bool customModelCompiled { get; set; }
public bool customModelSequenceVisible { get; set; }
public bool customModelPlaybackMoves { get; set; }
public bool customModelPlaybackUpright { get; set; }
public float customModelSolvedVsCompiledHipDeg { get; set; }
public string customModelPlaybackDetail { get; set; }
public string[] customModelWriteErrors { get; set; } = Array.Empty<string>();
public bool customFbxTargetResolved { get; set; }
public string customFbxTargetError { get; set; }
public bool customFbxPreviewHasModel { get; set; }
public bool customFbxPreviewPoseOk { get; set; }
public int customFbxSkeletonLines { get; set; }
public int customFbxSkeletonPixels { get; set; }
public bool customFbxPreviewModelCompiled { get; set; }
public int customFbxMissingVmats { get; set; }
public int customFbxTextureSpamLines { get; set; }
public int customFbxErrorPixels { get; set; }
public int customFbxColoredPixels { get; set; }
public bool customFbxHasTextures { get; set; }
public bool customFbxEmbeddedVisible { get; set; }
public bool customFbxEmbeddedMoves { get; set; }
public bool customFbxCompiled { get; set; }
public bool customFbxSequenceVisible { get; set; }
public bool customFbxPlaybackMoves { get; set; }
public bool customFbxPlaybackUpright { get; set; }
public float customFbxSolvedVsCompiledHipDeg { get; set; }
public string customFbxPlaybackDetail { get; set; }
public string[] customFbxWriteErrors { get; set; } = Array.Empty<string>();
public bool customOk { 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();
}
}