Editor/CitizenRetarget/CitizenRetargetPipeline.cs
using System.Text.Json;
#nullable enable
namespace Editor.CitizenRetarget;
internal sealed class CitizenRetargetPipeline : IDisposable
{
private const int MaxRecentRuns = 40;
private static readonly JsonSerializerOptions ProfileJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
private readonly Ual2SourceAdapter _sourceAdapter = new();
private readonly CitizenVmdlWriter _vmdlWriter = new();
private readonly BlenderRetargetBackend _backend = new();
private readonly BlenderDmxExportBridge _dmxBridge = new();
private readonly record struct StandardizedBoneName( string Original, string Standardized );
public CitizenRetargetJob LoadOrCreateJob()
{
EnsureSeedProfilesExist();
var relativePath = NormalizeResourcePath( CitizenTargetProfile.DefaultJobAssetPath );
var absolutePath = ToAbsoluteAssetPath( relativePath );
Directory.CreateDirectory( Path.GetDirectoryName( absolutePath )! );
if ( File.Exists( absolutePath ) )
{
AssetSystem.RegisterFile( absolutePath );
var existing = ResourceLibrary.Get<CitizenRetargetJob>( relativePath );
if ( existing is not null )
{
var decoded = CloneJobForRuntime( existing );
var migratedDefaultSourceProfile = IsDefaultSourceProfilePath( decoded.SourceProfilePath );
var migratedLegacySourcePath = IsLegacyDefaultSourceFbxPath( decoded.SourceFbxPath );
if ( migratedDefaultSourceProfile )
decoded.SourceProfilePath = string.Empty;
if ( migratedLegacySourcePath )
decoded.SourceFbxPath = string.Empty;
if ( migratedDefaultSourceProfile || migratedLegacySourcePath || NeedsJobPersistenceMigration( existing ) )
SaveJob( decoded );
EnsureGeneratedTargetExists( decoded );
return decoded;
}
}
var asset = AssetSystem.CreateResource( "crtjob", absolutePath );
var job = new CitizenRetargetJob
{
SourceFbxPath = string.Empty,
SourceProfilePath = string.Empty,
MappingProfilePath = CitizenTargetProfile.DefaultMappingProfileAssetPath,
TargetVmdlPath = CitizenTargetProfile.DefaultTargetVmdlPath,
OutputAnimationFolder = CitizenTargetProfile.DefaultOutputAnimationFolder,
SequencePrefix = CitizenTargetProfile.DefaultSequencePrefix,
TargetPosePresetId = CitizenTargetProfile.DefaultTargetPosePresetId,
RootMotionMode = CitizenRetargetRootMotionMode.Keep,
ImportHands = true
};
asset.SaveToDisk( PrepareJobForPersistence( job ) );
AssetSystem.RegisterFile( absolutePath );
EnsureGeneratedTargetExists( job );
return CloneJobForRuntime( job );
}
public RetargetSourceProfile LoadSourceProfile( string? resourcePath )
{
EnsureSeedProfilesExist();
if ( string.IsNullOrWhiteSpace( resourcePath ) )
return BuildGenericSourceProfile();
var normalized = NormalizeResourcePath( resourcePath );
var absolutePath = ResolveAssetOrPluginAssetPath( normalized );
RegisterFileIfExists( absolutePath );
var loaded = ResourceLibrary.Get<RetargetSourceProfile>( normalized ) ?? TryLoadJsonProfile<RetargetSourceProfile>( absolutePath );
if ( loaded is null )
throw new FileNotFoundException( $"Unable to load source profile '{normalized}'." );
var decoded = CloneSourceProfileForRuntime( loaded );
if ( NeedsSourceProfilePersistenceMigration( loaded ) )
SaveSourceProfile( decoded, normalized );
return decoded;
}
public RetargetMappingProfile LoadMappingProfile( string? resourcePath )
{
EnsureSeedProfilesExist();
var normalized = NormalizeResourcePath( string.IsNullOrWhiteSpace( resourcePath ) ? CitizenTargetProfile.DefaultMappingProfileAssetPath : resourcePath );
var absolutePath = ResolveAssetOrPluginAssetPath( normalized );
RegisterFileIfExists( absolutePath );
return ResourceLibrary.Get<RetargetMappingProfile>( normalized )
?? TryLoadJsonProfile<RetargetMappingProfile>( absolutePath )
?? throw new FileNotFoundException( $"Unable to load mapping profile '{normalized}'." );
}
public IReadOnlyList<RetargetClipDescriptor> ScanClips( CitizenRetargetJob job )
{
var normalizedSourcePath = NormalizeSourceFilePath( job.SourceFbxPath );
return _sourceAdapter.ScanClips( normalizedSourcePath );
}
public string ResolveSourceFbxPath( CitizenRetargetJob job )
{
return NormalizeSourceFilePath( job.SourceFbxPath );
}
public RetargetSourceInspection InspectSourceSkeleton( CitizenRetargetJob job )
{
var normalizedSourcePath = NormalizeSourceFilePath( job.SourceFbxPath );
var audit = _sourceAdapter.InspectScene( normalizedSourcePath, TargetBindProfile.Current );
return new RetargetSourceInspection
{
SourcePath = normalizedSourcePath,
Audit = audit,
BonesByName = audit.Bones.ToDictionary( bone => bone.Name, StringComparer.OrdinalIgnoreCase )
};
}
public string DetectSourceProfilePath( CitizenRetargetJob job, RetargetSourceInspection inspection, IEnumerable<RetargetClipDescriptor>? clips = null )
{
var sourcePath = job.SourceFbxPath ?? string.Empty;
var boneNames = inspection?.Audit?.Bones?.Select( bone => bone.Name ).Where( name => !string.IsNullOrWhiteSpace( name ) ).ToList()
?? new List<string>();
var clipNames = clips?
.SelectMany( clip => new[] { clip.SourceName, clip.DisplayName } )
.Where( name => !string.IsNullOrWhiteSpace( name ) )
.ToList()
?? new List<string>();
if ( !string.IsNullOrWhiteSpace( sourcePath ) && sourcePath.Contains( "mixamo", StringComparison.OrdinalIgnoreCase ) )
return CitizenTargetProfile.MixamoSourceProfileAssetPath;
if ( boneNames.Any( name => name.StartsWith( "mixamorig:", StringComparison.OrdinalIgnoreCase ) ) )
return CitizenTargetProfile.MixamoSourceProfileAssetPath;
if ( clipNames.Any( name => name.Contains( "mixamo.com", StringComparison.OrdinalIgnoreCase ) ) )
return CitizenTargetProfile.MixamoSourceProfileAssetPath;
return string.Empty;
}
private static bool IsDefaultSourceProfilePath( string? resourcePath )
{
if ( string.IsNullOrWhiteSpace( resourcePath ) )
return false;
return NormalizeResourcePath( resourcePath ).Equals(
NormalizeResourcePath( CitizenTargetProfile.DefaultSourceProfileAssetPath ),
StringComparison.OrdinalIgnoreCase );
}
private static bool IsLegacyDefaultSourceFbxPath( string? sourceFbxPath )
{
var decoded = CitizenRetargetPaths.DecodeExternalPath( sourceFbxPath ?? string.Empty )
.Replace( '\\', '/' )
.Trim();
return decoded.EndsWith( "Universal Animation Library 2[Source]/Unity/UAL2.fbx", StringComparison.OrdinalIgnoreCase );
}
private static RetargetSourceProfile BuildGenericSourceProfile()
{
return new RetargetSourceProfile
{
ProfileId = "generic_humanoid",
BackendSourceProfileId = CitizenTargetProfile.GenericBackendSourceProfileId,
DisplayName = "Generic Humanoid",
DefaultMappingProfilePath = CitizenTargetProfile.DefaultMappingProfileAssetPath,
DefaultPoseReferenceAction = string.Empty,
DefaultPoseReferenceFrame = 0,
RootBoneCandidates = new List<string>
{
"mixamorig:Root",
"mixamorig:Hips",
"B-root",
"B-hips",
"root",
"Root",
"hips",
"Hips",
"pelvis",
"Pelvis",
"Armature"
},
CanonicalSourceAliases = new List<RetargetSlotAliasSet>(),
FingerChainHints = new List<RetargetFingerChainHint>(),
Notes = new List<string>
{
"Runtime default. Uses Rokoko-style auto detection plus manual overrides."
}
};
}
public List<RetargetSlotAssignmentState> BuildMappingState(
CitizenRetargetJob job,
RetargetSourceProfile sourceProfile,
RetargetMappingProfile mappingProfile,
RetargetSourceInspection inspection )
{
var aliasLookup = sourceProfile.CanonicalSourceAliases.ToDictionary( alias => alias.SlotId, StringComparer.OrdinalIgnoreCase );
var sourceBoneLookup = inspection.BonesByName;
var detectedCandidatesBySlot = BuildRokokoStyleAutoMapCandidates( sourceBoneLookup.Keys.ToList() );
var states = new List<RetargetSlotAssignmentState>();
foreach ( var rule in mappingProfile.Slots.OrderBy( slot => slot.Required ? 0 : 1 ).ThenBy( slot => slot.SlotId, StringComparer.OrdinalIgnoreCase ) )
{
var sourceAliasCandidates = new List<string>();
if ( !string.IsNullOrWhiteSpace( rule.AssignedSourceBone ) )
sourceAliasCandidates.Add( rule.AssignedSourceBone );
if ( rule.SourceAliases is { Count: > 0 } )
sourceAliasCandidates.AddRange( rule.SourceAliases );
if ( aliasLookup.TryGetValue( rule.SlotId, out var aliasSet ) && aliasSet.Aliases.Count > 0 )
sourceAliasCandidates.AddRange( aliasSet.Aliases );
var combinedCandidates = new List<string>( sourceAliasCandidates );
if ( detectedCandidatesBySlot.TryGetValue( rule.SlotId, out var detectedCandidates ) && detectedCandidates.Count > 0 )
combinedCandidates.AddRange( detectedCandidates );
var candidates = combinedCandidates
.Where( alias => !string.IsNullOrWhiteSpace( alias ) )
.Distinct( StringComparer.OrdinalIgnoreCase )
.Where( alias => sourceBoneLookup.ContainsKey( alias ) )
.ToList();
var assignedExists = !string.IsNullOrWhiteSpace( rule.AssignedSourceBone ) && sourceBoneLookup.ContainsKey( rule.AssignedSourceBone );
var effectiveGroup = string.IsNullOrWhiteSpace( rule.Group ) ? InferSlotGroup( rule.SlotId ) : rule.Group;
var enabled = rule.Enabled && (job.ImportHands || !IsFingerSlot( effectiveGroup, rule.SlotId ));
states.Add( new RetargetSlotAssignmentState
{
SlotId = rule.SlotId,
TargetBone = string.IsNullOrWhiteSpace( rule.TargetBone ) ? rule.SlotId : rule.TargetBone,
Group = effectiveGroup,
Required = rule.Required,
Enabled = enabled,
Locked = rule.Locked,
MirrorSlotId = rule.MirrorSlotId,
AssignedSourceBone = assignedExists ? rule.AssignedSourceBone : string.Empty,
AutoSourceBone = candidates.FirstOrDefault() ?? string.Empty,
IsManualOverride = assignedExists,
Confidence = assignedExists ? 1.0f : candidates.Count > 0 ? 0.85f : 0.0f,
CandidateBones = candidates,
SourceAliases = sourceAliasCandidates.Distinct( StringComparer.OrdinalIgnoreCase ).ToList(),
Notes = rule.Notes ?? string.Empty
} );
}
return states;
}
private static Dictionary<string, List<string>> BuildRokokoStyleAutoMapCandidates( IReadOnlyList<string> sourceBoneNames )
{
var candidatesBySlot = new Dictionary<string, List<string>>( StringComparer.OrdinalIgnoreCase );
var standardizedBones = sourceBoneNames
.Where( name => !string.IsNullOrWhiteSpace( name ) )
.Select( name => new StandardizedBoneName( name, StandardizeRokokoBoneName( name ) ) )
.ToList();
void AddCandidate( string slotId, string boneName )
{
if ( string.IsNullOrWhiteSpace( slotId ) || string.IsNullOrWhiteSpace( boneName ) )
return;
if ( !candidatesBySlot.TryGetValue( slotId, out var list ) )
{
list = new List<string>();
candidatesBySlot[slotId] = list;
}
if ( !list.Contains( boneName, StringComparer.OrdinalIgnoreCase ) )
list.Add( boneName );
}
foreach ( var bone in standardizedBones )
{
if ( ScoreExactNameMatch( bone.Standardized, "hips", "hip", "pelvis", "waist", "lowerbody", "lower_body" ) )
AddCandidate( "pelvis", bone.Original );
if ( ScoreExactNameMatch( bone.Standardized, "neck", "necklower", "headneck", "neck00" ) )
AddCandidate( "neck_0", bone.Original );
if ( ScoreExactNameMatch( bone.Standardized, "head", "head01", "head001" ) )
AddCandidate( "head", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: true, "clavicle", "collar", "shoulder" ) )
AddCandidate( "clavicle_L", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: false, "clavicle", "collar", "shoulder" ) )
AddCandidate( "clavicle_R", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: true, "upperarm", "uparm", "arm" ) && !ContainsAny( bone.Standardized, "forearm", "lowerarm", "hand", "clavicle", "shoulder", "finger", "thumb" ) )
AddCandidate( "arm_upper_L", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: false, "upperarm", "uparm", "arm" ) && !ContainsAny( bone.Standardized, "forearm", "lowerarm", "hand", "clavicle", "shoulder", "finger", "thumb" ) )
AddCandidate( "arm_upper_R", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: true, "forearm", "lowerarm", "elbow" ) )
AddCandidate( "arm_lower_L", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: false, "forearm", "lowerarm", "elbow" ) )
AddCandidate( "arm_lower_R", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: true, "hand", "wrist", "palm" ) && !ContainsAny( bone.Standardized, "finger", "thumb", "index", "middle", "ring", "pinky", "prop" ) )
AddCandidate( "hand_L", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: false, "hand", "wrist", "palm" ) && !ContainsAny( bone.Standardized, "finger", "thumb", "index", "middle", "ring", "pinky", "prop" ) )
AddCandidate( "hand_R", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: true, "thigh", "upleg", "upperleg", "leg" ) && !ContainsAny( bone.Standardized, "calf", "lowerleg", "foot", "toe", "knee", "shin" ) )
AddCandidate( "leg_upper_L", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: false, "thigh", "upleg", "upperleg", "leg" ) && !ContainsAny( bone.Standardized, "calf", "lowerleg", "foot", "toe", "knee", "shin" ) )
AddCandidate( "leg_upper_R", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: true, "calf", "lowerleg", "shin", "knee", "leg" ) && !ContainsAny( bone.Standardized, "thigh", "upleg", "upperleg", "foot", "toe" ) )
AddCandidate( "leg_lower_L", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: false, "calf", "lowerleg", "shin", "knee", "leg" ) && !ContainsAny( bone.Standardized, "thigh", "upleg", "upperleg", "foot", "toe" ) )
AddCandidate( "leg_lower_R", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: true, "foot" ) && !ContainsAny( bone.Standardized, "toe", "ball" ) )
AddCandidate( "ankle_L", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: false, "foot" ) && !ContainsAny( bone.Standardized, "toe", "ball" ) )
AddCandidate( "ankle_R", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: true, "toe", "ball" ) )
AddCandidate( "ball_L", bone.Original );
if ( MatchesSideBone( bone.Standardized, left: false, "toe", "ball" ) )
AddCandidate( "ball_R", bone.Original );
AddFingerCandidates( candidatesBySlot, bone );
}
var spineBones = standardizedBones
.Where( bone => IsSpineLikeBone( bone.Standardized ) )
.OrderBy( bone => GetSpineSortOrder( bone.Standardized ) )
.Select( bone => bone.Original )
.Distinct( StringComparer.OrdinalIgnoreCase )
.ToList();
if ( spineBones.Count > 0 )
AddCandidate( "spine_0", spineBones[0] );
if ( spineBones.Count > 1 )
AddCandidate( "spine_1", spineBones[Math.Min( 1, spineBones.Count - 1 )] );
if ( spineBones.Count > 2 )
AddCandidate( "spine_2", spineBones[Math.Min( 2, spineBones.Count - 1 )] );
else if ( spineBones.Count > 1 )
AddCandidate( "spine_2", spineBones[^1] );
return candidatesBySlot;
}
private static void AddFingerCandidates( IDictionary<string, List<string>> candidatesBySlot, StandardizedBoneName bone )
{
var fingerId = GetFingerId( bone.Standardized );
if ( string.IsNullOrWhiteSpace( fingerId ) )
return;
var side = GetBoneSide( bone.Standardized );
if ( side is null )
return;
var segment = GetFingerSegmentIndex( bone.Standardized );
if ( segment is null )
return;
var slotId = $"finger_{fingerId}_{segment.Value}_{(side.Value ? "L" : "R")}";
if ( !candidatesBySlot.TryGetValue( slotId, out var list ) )
{
list = new List<string>();
candidatesBySlot[slotId] = list;
}
if ( !list.Contains( bone.Original, StringComparer.OrdinalIgnoreCase ) )
list.Add( bone.Original );
}
private static string StandardizeRokokoBoneName( string name )
{
if ( string.IsNullOrWhiteSpace( name ) )
return string.Empty;
var normalized = name.Trim()
.Replace( ' ', '_' )
.Replace( '-', '_' )
.Replace( '.', '_' );
while ( normalized.Contains( "__", StringComparison.Ordinal ) )
normalized = normalized.Replace( "__", "_", StringComparison.Ordinal );
foreach ( var replacement in new[]
{
("_", string.Empty),
("B_", string.Empty),
("ValveBiped_", string.Empty),
("Valvebiped_", string.Empty),
("Bip1_", "Bip_"),
("Bip01_", "Bip_"),
("Bip001_", "Bip_"),
("Character1_", string.Empty),
("HLP_", string.Empty),
("JD_", string.Empty),
("JU_", string.Empty),
("Armature|", string.Empty),
("Bone_", string.Empty),
("C_", string.Empty),
("Cf_S_", string.Empty),
("Cf_J_", string.Empty),
("G_", string.Empty),
("Joint_", string.Empty),
("DEF_", string.Empty),
("CC_Base_", string.Empty)
})
{
if ( normalized.StartsWith( replacement.Item1, StringComparison.OrdinalIgnoreCase ) )
{
normalized = replacement.Item2 + normalized[replacement.Item1.Length..];
break;
}
}
var split = normalized.Split( '_', StringSplitOptions.RemoveEmptyEntries );
if ( split.Length > 1 && int.TryParse( split[0], out _ ) )
normalized = string.Join( "_", split.Skip( 1 ) );
if ( normalized.Contains( ':', StringComparison.Ordinal ) )
normalized = string.Concat( normalized.Split( ':', StringSplitOptions.RemoveEmptyEntries ).Skip( 1 ) );
if ( normalized.EndsWith( "S0", StringComparison.OrdinalIgnoreCase ) )
normalized = normalized[..^2];
if ( normalized.EndsWith( "_Jnt", StringComparison.OrdinalIgnoreCase ) )
normalized = normalized[..^4];
return normalized.ToLowerInvariant();
}
private static T? TryLoadJsonProfile<T>( string absolutePath ) where T : class
{
if ( string.IsNullOrWhiteSpace( absolutePath ) || !File.Exists( absolutePath ) )
return null;
try
{
var json = File.ReadAllText( absolutePath );
return JsonSerializer.Deserialize<T>( json, ProfileJsonOptions );
}
catch
{
return null;
}
}
private static bool ScoreExactNameMatch( string standardizedName, params string[] aliases )
{
foreach ( var alias in aliases )
{
if ( standardizedName.Equals( StandardizeRokokoBoneName( alias ), StringComparison.OrdinalIgnoreCase ) )
return true;
}
return false;
}
private static bool MatchesSideBone( string standardizedName, bool left, params string[] aliases )
{
var side = GetBoneSide( standardizedName );
if ( side is null || side.Value != left )
return false;
var sideTokens = left
? new[] { "left", "_l", "l_", "l" }
: new[] { "right", "_r", "r_", "r" };
foreach ( var alias in aliases )
{
var rawCandidates = left
? new[]
{
$"left{alias}",
$"{alias}left",
$"{alias}_l",
$"l_{alias}",
$"{alias}l",
$"l{alias}"
}
: new[]
{
$"right{alias}",
$"{alias}right",
$"{alias}_r",
$"r_{alias}",
$"{alias}r",
$"r{alias}"
};
if ( rawCandidates.Any( candidate =>
standardizedName.Equals( StandardizeRokokoBoneName( candidate ), StringComparison.OrdinalIgnoreCase ) ) )
return true;
if ( standardizedName.Contains( alias, StringComparison.OrdinalIgnoreCase )
&& sideTokens.Any( token => standardizedName.Contains( token, StringComparison.OrdinalIgnoreCase ) ) )
return true;
}
return false;
}
private static bool ContainsAny( string standardizedName, params string[] fragments )
{
return fragments.Any( fragment => standardizedName.Contains( fragment, StringComparison.OrdinalIgnoreCase ) );
}
private static bool IsSpineLikeBone( string standardizedName )
{
return ContainsAny( standardizedName, "spine", "chest", "upperchest", "abdomen", "torso", "spineproxy" );
}
private static int GetSpineSortOrder( string standardizedName )
{
if ( standardizedName.Contains( "spineproxy", StringComparison.OrdinalIgnoreCase ) )
return 15;
if ( standardizedName.Contains( "upperchest", StringComparison.OrdinalIgnoreCase ) )
return 40;
if ( standardizedName.Contains( "chest", StringComparison.OrdinalIgnoreCase ) )
return 30;
if ( standardizedName.Contains( "abdomen", StringComparison.OrdinalIgnoreCase ) )
return 5;
var digitMatch = standardizedName.Reverse().FirstOrDefault( char.IsDigit );
if ( digitMatch != default && char.IsDigit( digitMatch ) )
return digitMatch - '0';
return standardizedName.Contains( "spine", StringComparison.OrdinalIgnoreCase ) ? 10 : 20;
}
private static string GetFingerId( string standardizedName )
{
if ( ContainsAny( standardizedName, "thumb" ) )
return "thumb";
if ( ContainsAny( standardizedName, "indexfinger", "index" ) )
return "index";
if ( ContainsAny( standardizedName, "middlefinger", "middle" ) )
return "middle";
if ( ContainsAny( standardizedName, "ringfinger", "ring" ) )
return "ring";
if ( ContainsAny( standardizedName, "pinky", "littlefinger", "pinkie" ) )
return "pinky";
return string.Empty;
}
private static int? GetFingerSegmentIndex( string standardizedName )
{
if ( ContainsAny( standardizedName, "01", "1" ) )
return 0;
if ( ContainsAny( standardizedName, "02", "2" ) )
return 1;
if ( ContainsAny( standardizedName, "03", "3" ) )
return 2;
return null;
}
private static bool? GetBoneSide( string standardizedName )
{
if ( standardizedName.Contains( "left", StringComparison.OrdinalIgnoreCase )
|| standardizedName.Contains( "_l", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "l_", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "lhand", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "larm", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "lleg", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "lshoulder", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "lthigh", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "lforearm", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "lupperarm", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "lfoot", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "ltoe", StringComparison.OrdinalIgnoreCase ) )
return true;
if ( standardizedName.Contains( "right", StringComparison.OrdinalIgnoreCase )
|| standardizedName.Contains( "_r", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "r_", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "rhand", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "rarm", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "rleg", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "rshoulder", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "rthigh", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "rforearm", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "rupperarm", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "rfoot", StringComparison.OrdinalIgnoreCase )
|| standardizedName.StartsWith( "rtoe", StringComparison.OrdinalIgnoreCase ) )
return false;
return null;
}
public RetargetDiagnosticSummary BuildDiagnostics( IReadOnlyList<RetargetSlotAssignmentState> mappingState )
{
var duplicateSourceBones = mappingState
.Where( slot => slot.Enabled && !string.IsNullOrWhiteSpace( slot.EffectiveSourceBone ) )
.GroupBy( slot => slot.EffectiveSourceBone, StringComparer.OrdinalIgnoreCase )
.Where( group => group.Count() > 1 && !IsAllowedSharedSourceAssignment( group ) )
.Select( group => $"{group.Key} -> {string.Join( ", ", group.Select( slot => slot.SlotId ) )}" )
.OrderBy( text => text, StringComparer.OrdinalIgnoreCase )
.ToList();
var duplicateTargetBones = mappingState
.Where( slot => slot.Enabled && !string.IsNullOrWhiteSpace( slot.TargetBone ) )
.GroupBy( slot => slot.TargetBone, StringComparer.OrdinalIgnoreCase )
.Where( group => group.Count() > 1 )
.Select( group => $"{group.Key} -> {string.Join( ", ", group.Select( slot => slot.SlotId ) )}" )
.OrderBy( text => text, StringComparer.OrdinalIgnoreCase )
.ToList();
return new RetargetDiagnosticSummary
{
MissingRequiredSlots = mappingState
.Where( slot => slot.Enabled && slot.Required && string.IsNullOrWhiteSpace( slot.EffectiveSourceBone ) )
.Select( slot => slot.SlotId )
.OrderBy( slot => slot, StringComparer.OrdinalIgnoreCase )
.ToList(),
DuplicateSourceBones = duplicateSourceBones,
DuplicateTargetBones = duplicateTargetBones,
DisabledFingerSlots = mappingState
.Where( slot => !slot.Enabled && IsFingerSlot( slot.Group, slot.SlotId ) )
.Select( slot => slot.SlotId )
.OrderBy( slot => slot, StringComparer.OrdinalIgnoreCase )
.ToList()
};
}
public string SaveJob( CitizenRetargetJob job )
{
EnsureSeedProfilesExist();
var relativePath = NormalizeResourcePath( CitizenTargetProfile.DefaultJobAssetPath );
var absolutePath = ToAbsoluteAssetPath( relativePath );
Directory.CreateDirectory( Path.GetDirectoryName( absolutePath )! );
var asset = AssetSystem.FindByPath( relativePath ) ?? AssetSystem.CreateResource( "crtjob", absolutePath );
asset.SaveToDisk( PrepareJobForPersistence( job ) );
AssetSystem.RegisterFile( absolutePath );
return absolutePath;
}
public string SaveMappingProfile( RetargetMappingProfile mappingProfile, IReadOnlyList<RetargetSlotAssignmentState> mappingState, string? resourcePath = null )
{
var bySlot = mappingState.ToDictionary( slot => slot.SlotId, StringComparer.OrdinalIgnoreCase );
foreach ( var rule in mappingProfile.Slots )
{
if ( !bySlot.TryGetValue( rule.SlotId, out var state ) )
continue;
rule.AssignedSourceBone = state.IsManualOverride ? state.AssignedSourceBone : string.Empty;
rule.Enabled = state.Enabled;
rule.Locked = state.Locked;
if ( state.SourceAliases.Count > 0 )
rule.SourceAliases = state.SourceAliases.ToList();
}
var normalized = NormalizeResourcePath( string.IsNullOrWhiteSpace( resourcePath ) ? CitizenTargetProfile.DefaultMappingProfileAssetPath : resourcePath );
var absolute = ToAbsoluteAssetPath( normalized );
Directory.CreateDirectory( Path.GetDirectoryName( absolute )! );
var asset = AssetSystem.FindByPath( normalized ) ?? AssetSystem.CreateResource( "crtmap", absolute );
asset.SaveToDisk( mappingProfile );
AssetSystem.RegisterFile( absolute );
return absolute;
}
public RetargetImportResult ImportClip(
CitizenRetargetJob job,
RetargetClipDescriptor clip,
IReadOnlyList<RetargetSlotAssignmentState> mappingState,
Vector3 sourceFacingEulerDegrees = default )
{
var sourceProfile = LoadSourceProfile( job.SourceProfilePath );
var mappingProfile = LoadMappingProfile( job.MappingProfilePath );
var diagnostics = ValidateImportDiagnostics( mappingState );
var runtimeJob = CreateRuntimeJobSnapshot( job );
var result = RunBackendRetargetOnly( runtimeJob, clip, sourceProfile, mappingProfile, mappingState, sourceFacingEulerDegrees );
return FinalizeImportedResult( job, clip, result, diagnostics );
}
public CitizenRetargetJob CreateRuntimeJobSnapshot( CitizenRetargetJob job )
{
return CloneJobForRuntime( job );
}
public RetargetDiagnosticSummary ValidateImportDiagnostics( IReadOnlyList<RetargetSlotAssignmentState> mappingState )
{
var diagnostics = BuildDiagnostics( mappingState );
if ( diagnostics.MissingRequiredSlots.Count > 0 )
throw new InvalidOperationException( $"Missing required slots: {string.Join( ", ", diagnostics.MissingRequiredSlots )}" );
if ( diagnostics.DuplicateSourceBones.Count > 0 )
throw new InvalidOperationException( $"Duplicate source-bone assignments detected: {diagnostics.DuplicateSourceBones[0]}" );
return diagnostics;
}
public RetargetImportResult RunBackendRetargetOnly(
CitizenRetargetJob job,
RetargetClipDescriptor clip,
RetargetSourceProfile sourceProfile,
RetargetMappingProfile mappingProfile,
IReadOnlyList<RetargetSlotAssignmentState> mappingState,
Vector3 sourceFacingEulerDegrees = default )
{
PrepareRuntimeJobForImport( job, mappingProfile );
var result = _backend.RunRetarget( job, clip, sourceProfile, mappingProfile, mappingState, sourceFacingEulerDegrees );
result.SequenceName = RetargetSequenceNames.Build( job.SequencePrefix, clip.DisplayName );
result.VmdlResourcePath = job.TargetVmdlPath;
return result;
}
public RetargetImportResult FinalizeImportedResult(
CitizenRetargetJob job,
RetargetClipDescriptor clip,
RetargetImportResult result,
RetargetDiagnosticSummary diagnostics )
{
var runtimeJob = CreateRuntimeJobSnapshot( job );
PrepareRuntimeJobForImport( runtimeJob, null );
var importedAnimationAbsolutePath = string.Empty;
var vmdlAbsolutePath = string.Empty;
if ( !string.IsNullOrWhiteSpace( result.Manifest.Outputs.ExportPath ) && File.Exists( result.Manifest.Outputs.ExportPath ) )
{
try
{
importedAnimationAbsolutePath = MaterializeImportedAnimation(
runtimeJob.OutputAnimationFolder,
runtimeJob.SequencePrefix,
clip,
result.Manifest.Outputs.ExportPath,
result.Manifest.RetargetedActionName );
result.OutputFormat = RetargetOutputFormat.FbxBackend;
var generatedSources = _vmdlWriter.EnumerateSourcesFromFolder( runtimeJob.OutputAnimationFolder, runtimeJob.SequencePrefix, ".fbx" );
vmdlAbsolutePath = _vmdlWriter.WriteSharedVmdl( runtimeJob.TargetVmdlPath, generatedSources );
var importedAnimationAsset = AssetSystem.RegisterFile( importedAnimationAbsolutePath );
var vmdlAsset = AssetSystem.RegisterFile( vmdlAbsolutePath );
importedAnimationAsset?.Compile( true );
vmdlAsset?.Compile( true );
}
catch ( Exception exception )
{
result.PostImportError = $"{exception.GetType().Name}: {exception.Message}";
}
}
result.GeneratedClipAbsolutePath = importedAnimationAbsolutePath;
result.VmdlAbsolutePath = vmdlAbsolutePath;
job.SourceFbxPath = runtimeJob.SourceFbxPath;
job.TargetVmdlPath = runtimeJob.TargetVmdlPath;
job.OutputAnimationFolder = runtimeJob.OutputAnimationFolder;
job.SequencePrefix = runtimeJob.SequencePrefix;
job.TargetPosePresetId = runtimeJob.TargetPosePresetId;
result.JobAbsolutePath = SaveJob( UpdateJobWithResult( job, clip, result, importedAnimationAbsolutePath ) );
result.ArtifactLinks = new RetargetArtifactLinks
{
RunDirectoryPath = result.Manifest.Outputs.RunDir,
ManifestPath = result.ManifestAbsolutePath,
ExportPath = result.Manifest.Outputs.ExportPath,
ImportedAnimationPath = importedAnimationAbsolutePath,
VmdlPath = vmdlAbsolutePath,
BoneMapOverridePath = result.Manifest.Outputs.BoneMapOverridesPath
};
result.Log = BuildImportLog( clip, result, diagnostics, importedAnimationAbsolutePath, vmdlAbsolutePath );
result.ArtifactLinks.RunLogPath = WriteRunLogArtifact( result.Manifest.Outputs.RunDir, result.Log );
return result;
}
public void OpenResultInModelDoc( CitizenRetargetJob job )
{
if ( !HasImportedAnimations( job ) )
throw new InvalidOperationException( "No imported Citizen animation sources are available yet. Run a successful retarget first." );
EnsureGeneratedTargetExists( job );
OpenModelAsset( job.TargetVmdlPath );
}
public void OpenModelAsset( string assetPath )
{
var relativePath = NormalizeResourcePath( assetPath );
var asset = AssetSystem.FindByPath( relativePath );
if ( asset is null )
throw new FileNotFoundException( $"Unable to locate generated VMDL asset '{relativePath}'." );
asset.OpenInEditor();
}
public bool HasImportedAnimations( CitizenRetargetJob job )
{
try
{
var normalizedAnimationFolder = NormalizeResourcePath( job.OutputAnimationFolder );
var normalizedPrefix = string.IsNullOrWhiteSpace( job.SequencePrefix ) ? CitizenTargetProfile.DefaultSequencePrefix : job.SequencePrefix.Trim();
return _vmdlWriter.EnumerateSourcesFromFolder( normalizedAnimationFolder, normalizedPrefix, ".fbx" ).Count > 0;
}
catch
{
return false;
}
}
public List<GeneratedAnimationSource> GetTargetAnimationSources( CitizenRetargetJob job )
{
try
{
var normalizedAnimationFolder = NormalizeResourcePath( job.OutputAnimationFolder );
var normalizedPrefix = string.IsNullOrWhiteSpace( job.SequencePrefix ) ? CitizenTargetProfile.DefaultSequencePrefix : job.SequencePrefix.Trim();
return _vmdlWriter.EnumerateSourcesFromFolder( normalizedAnimationFolder, normalizedPrefix, ".fbx" );
}
catch
{
return new List<GeneratedAnimationSource>();
}
}
public void EnsureTargetAssetExists( CitizenRetargetJob job )
{
EnsureGeneratedTargetExists( job );
}
public void RefreshTargetAssetDefinition( CitizenRetargetJob job )
{
var normalizedTargetVmdlPath = NormalizeResourcePath( job.TargetVmdlPath );
var normalizedAnimationFolder = NormalizeResourcePath( job.OutputAnimationFolder );
var normalizedPrefix = string.IsNullOrWhiteSpace( job.SequencePrefix ) ? CitizenTargetProfile.DefaultSequencePrefix : job.SequencePrefix.Trim();
var vmdlAbsolutePath = _vmdlWriter.WriteSharedVmdl( normalizedTargetVmdlPath, normalizedAnimationFolder, normalizedPrefix, ".fbx" );
AssetSystem.RegisterFile( vmdlAbsolutePath );
}
public string ResolvePreviewableTargetVmdlPath( CitizenRetargetJob job )
{
try
{
var normalizedTargetVmdlPath = NormalizeResourcePath( job.TargetVmdlPath );
var absoluteTargetVmdlPath = ToAbsoluteAssetPath( normalizedTargetVmdlPath );
return File.Exists( absoluteTargetVmdlPath )
? normalizedTargetVmdlPath
: CitizenTargetProfile.CitizenBaseModelPath;
}
catch
{
return CitizenTargetProfile.CitizenBaseModelPath;
}
}
public List<string> GetModelAnimationNames( string vmdlResourcePath )
{
if ( string.IsNullOrWhiteSpace( vmdlResourcePath ) )
return new List<string>();
try
{
var model = Model.Load( vmdlResourcePath );
if ( model is null || model.IsError || model.AnimationCount <= 0 )
return new List<string>();
var names = new List<string>();
for ( var animationIndex = 0; animationIndex < model.AnimationCount; animationIndex++ )
{
var name = model.GetAnimationName( animationIndex ) ?? string.Empty;
if ( string.IsNullOrWhiteSpace( name ) )
continue;
if ( names.Contains( name, StringComparer.OrdinalIgnoreCase ) )
continue;
names.Add( name );
}
return names;
}
catch
{
return new List<string>();
}
}
public void OpenPath( string absolutePath )
{
if ( string.IsNullOrWhiteSpace( absolutePath ) )
return;
if ( File.Exists( absolutePath ) )
{
EditorUtility.OpenFile( absolutePath );
return;
}
if ( Directory.Exists( absolutePath ) )
{
EditorUtility.OpenFileFolder( absolutePath );
}
}
public void Dispose()
{
_sourceAdapter.Dispose();
}
private void EnsureSeedProfilesExist()
{
EnsureSeedSourceProfileExists();
EnsureSeedMappingProfileExists();
}
private void EnsureSeedSourceProfileExists()
{
var normalized = NormalizeResourcePath( CitizenTargetProfile.DefaultSourceProfileAssetPath );
var absolute = ResolveAssetOrPluginAssetPath( normalized );
if ( File.Exists( absolute ) )
{
RegisterFileIfExists( absolute );
return;
}
absolute = ToAbsoluteAssetPath( normalized );
Directory.CreateDirectory( Path.GetDirectoryName( absolute )! );
var asset = AssetSystem.CreateResource( "crtsrc", absolute );
asset.SaveToDisk( PrepareSourceProfileForPersistence( BuildDefaultSourceProfile() ) );
RegisterFileIfExists( absolute );
}
private void SaveSourceProfile( RetargetSourceProfile sourceProfile, string resourcePath )
{
var normalized = NormalizeResourcePath( resourcePath );
var absolute = ToAbsoluteAssetPath( normalized );
Directory.CreateDirectory( Path.GetDirectoryName( absolute )! );
var asset = AssetSystem.FindByPath( normalized ) ?? AssetSystem.CreateResource( "crtsrc", absolute );
asset.SaveToDisk( PrepareSourceProfileForPersistence( sourceProfile ) );
AssetSystem.RegisterFile( absolute );
}
private void EnsureSeedMappingProfileExists()
{
var normalized = NormalizeResourcePath( CitizenTargetProfile.DefaultMappingProfileAssetPath );
var absolute = ResolveAssetOrPluginAssetPath( normalized );
if ( File.Exists( absolute ) )
{
RegisterFileIfExists( absolute );
return;
}
absolute = ToAbsoluteAssetPath( normalized );
Directory.CreateDirectory( Path.GetDirectoryName( absolute )! );
var asset = AssetSystem.CreateResource( "crtmap", absolute );
asset.SaveToDisk( BuildDefaultMappingProfile() );
RegisterFileIfExists( absolute );
}
private RetargetSourceProfile BuildDefaultSourceProfile()
{
var mappingPairs = ApplyCitizenFingerChainIndexShift( LoadLegacySeedMapping() );
var aliasSets = mappingPairs
.Select( pair => new RetargetSlotAliasSet
{
SlotId = pair.Value,
Group = InferSlotGroup( pair.Value ),
Required = IsRequiredBodySlot( pair.Value, InferSlotGroup( pair.Value ) ),
Aliases = new List<string> { pair.Key }
} )
.OrderBy( alias => alias.Required ? 0 : 1 )
.ThenBy( alias => alias.SlotId, StringComparer.OrdinalIgnoreCase )
.ToList();
return new RetargetSourceProfile
{
ProfileId = "quaternius_ual2",
BackendSourceProfileId = "quaternius_ual2",
DisplayName = "Quaternius UAL2 (Unity FBX)",
SampleReferenceFbxPath = string.Empty,
DefaultMappingProfilePath = CitizenTargetProfile.DefaultMappingProfileAssetPath,
DefaultPoseReferenceAction = "A_TPose",
DefaultPoseReferenceFrame = 0,
RootBoneCandidates = new List<string> { "B-root", "B-hips", "root", "hips", "pelvis" },
CanonicalSourceAliases = aliasSets,
FingerChainHints = BuildFingerChainHints( mappingPairs ),
Notes = new List<string>
{
"Seed profile generated from the legacy UAL2 -> Citizen mapping JSON.",
"Core body slots are required. Neck and clavicle slots stay optional for simplified humanoid rigs.",
"UAL2 finger chains use the Citizen 0/1/2 shift so source proximal bones skip the Citizen meta bones."
}
};
}
private RetargetMappingProfile BuildDefaultMappingProfile()
{
var mappingPairs = ApplyCitizenFingerChainIndexShift( LoadLegacySeedMapping() );
var slots = mappingPairs
.Select( pair =>
{
var group = InferSlotGroup( pair.Value );
return new RetargetSlotMappingRule
{
SlotId = pair.Value,
TargetBone = pair.Value,
Group = group,
Required = IsRequiredBodySlot( pair.Value, group ),
Enabled = true,
Locked = !IsFingerSlot( group, pair.Value ),
MirrorSlotId = InferMirrorSlot( pair.Value ),
AssignedSourceBone = pair.Key,
SourceAliases = new List<string> { pair.Key }
};
} )
.OrderBy( slot => slot.Required ? 0 : 1 )
.ThenBy( slot => slot.SlotId, StringComparer.OrdinalIgnoreCase )
.ToList();
return new RetargetMappingProfile
{
ProfileId = "ual2_to_citizen",
DisplayName = "UAL2 -> Citizen",
TargetProfileId = "citizen",
TargetPosePresetId = CitizenTargetProfile.DefaultTargetPosePresetId,
Slots = slots,
Notes = new List<string>
{
"Seed mapping generated from the legacy UAL2 -> Citizen mapping JSON.",
"Finger chains are optional and can be toggled from the workstation UI.",
"UAL2 finger chains map onto Citizen finger 0/1/2 segments and intentionally skip Citizen meta finger bones."
}
};
}
private static bool IsRequiredBodySlot( string slotId, string? group = null )
{
if ( IsFingerSlot( group ?? InferSlotGroup( slotId ), slotId ) )
return false;
return !slotId.Equals( "neck_0", StringComparison.OrdinalIgnoreCase )
&& !slotId.Equals( "clavicle_L", StringComparison.OrdinalIgnoreCase )
&& !slotId.Equals( "clavicle_R", StringComparison.OrdinalIgnoreCase );
}
private static bool IsAllowedSharedSourceAssignment( IGrouping<string, RetargetSlotAssignmentState> group )
{
var slotIds = group
.Select( slot => slot.SlotId )
.Distinct( StringComparer.OrdinalIgnoreCase )
.ToList();
if ( slotIds.Count < 2 )
return true;
return slotIds.All( slotId =>
slotId.Equals( "spine_0", StringComparison.OrdinalIgnoreCase )
|| slotId.Equals( "spine_1", StringComparison.OrdinalIgnoreCase )
|| slotId.Equals( "spine_2", StringComparison.OrdinalIgnoreCase ) );
}
private static List<RetargetFingerChainHint> BuildFingerChainHints( IReadOnlyDictionary<string, string> mappingPairs )
{
return mappingPairs
.Where( pair => pair.Value.Contains( "finger_", StringComparison.OrdinalIgnoreCase ) )
.GroupBy( pair => pair.Value.Contains( "_L", StringComparison.OrdinalIgnoreCase ) ? "L" : "R" )
.SelectMany( sideGroup =>
sideGroup
.GroupBy( pair => pair.Value.Split( '_' )[1], StringComparer.OrdinalIgnoreCase )
.Select( fingerGroup => new RetargetFingerChainHint
{
HandSide = sideGroup.Key,
FingerId = fingerGroup.Key,
Bones = fingerGroup.Select( pair => pair.Key ).OrderBy( bone => bone, StringComparer.OrdinalIgnoreCase ).ToList()
} ) )
.OrderBy( hint => hint.HandSide, StringComparer.OrdinalIgnoreCase )
.ThenBy( hint => hint.FingerId, StringComparer.OrdinalIgnoreCase )
.ToList();
}
private static Dictionary<string, string> LoadLegacySeedMapping()
{
var absolutePath = Path.Combine( CitizenRetargetPaths.DataRoot, "ual2_to_citizen_mapping.json" );
if ( !File.Exists( absolutePath ) )
throw new FileNotFoundException( $"Missing legacy seed mapping at '{absolutePath}'." );
using var document = JsonDocument.Parse( File.ReadAllText( absolutePath ) );
var mapping = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
foreach ( var property in document.RootElement.GetProperty( "mapping" ).EnumerateObject() )
{
var targetBone = property.Value.GetString();
if ( string.IsNullOrWhiteSpace( targetBone ) )
continue;
mapping[property.Name] = targetBone;
}
return mapping;
}
private static Dictionary<string, string> ApplyCitizenFingerChainIndexShift( IReadOnlyDictionary<string, string> mappingPairs )
{
var remappedTargets = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase )
{
["finger_index_meta_L"] = "finger_index_0_L",
["finger_index_meta_R"] = "finger_index_0_R",
["finger_index_0_L"] = "finger_index_1_L",
["finger_index_0_R"] = "finger_index_1_R",
["finger_index_1_L"] = "finger_index_2_L",
["finger_index_1_R"] = "finger_index_2_R",
["finger_middle_meta_L"] = "finger_middle_0_L",
["finger_middle_meta_R"] = "finger_middle_0_R",
["finger_middle_0_L"] = "finger_middle_1_L",
["finger_middle_0_R"] = "finger_middle_1_R",
["finger_middle_1_L"] = "finger_middle_2_L",
["finger_middle_1_R"] = "finger_middle_2_R",
["finger_ring_meta_L"] = "finger_ring_0_L",
["finger_ring_meta_R"] = "finger_ring_0_R",
["finger_ring_0_L"] = "finger_ring_1_L",
["finger_ring_0_R"] = "finger_ring_1_R",
["finger_ring_1_L"] = "finger_ring_2_L",
["finger_ring_1_R"] = "finger_ring_2_R",
};
var shifted = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
foreach ( var pair in mappingPairs )
shifted[pair.Key] = remappedTargets.TryGetValue( pair.Value, out var targetBone ) ? targetBone : pair.Value;
return shifted;
}
private CitizenRetargetJob UpdateJobWithResult(
CitizenRetargetJob job,
RetargetClipDescriptor clip,
RetargetImportResult result,
string importedAnimationAbsolutePath )
{
job.LastManifestPath = result.ManifestAbsolutePath;
job.SelectedClipNames = new List<string> { clip.DisplayName };
var importedSuccessfully =
result.Manifest.Status.Equals( "completed", StringComparison.OrdinalIgnoreCase )
&& !string.IsNullOrWhiteSpace( importedAnimationAbsolutePath )
&& File.Exists( importedAnimationAbsolutePath );
if ( importedSuccessfully )
{
job.LastImportedClip = clip.DisplayName;
job.LastSuccessfulRunId = result.Manifest.RunId;
job.LastSuccessfulSequenceName = result.SequenceName;
}
job.RecentRuns ??= new List<RetargetRunHistoryEntry>();
job.RecentRuns.Insert( 0, new RetargetRunHistoryEntry
{
RunId = result.Manifest.RunId,
ClipName = clip.DisplayName,
SequenceName = result.SequenceName,
Status = importedSuccessfully
? result.Manifest.Status
: string.IsNullOrWhiteSpace( result.PostImportError ) ? result.Manifest.Status : "import_failed",
TargetVmdlPath = job.TargetVmdlPath,
ManifestPath = result.ManifestAbsolutePath,
ExportPath = result.Manifest.Outputs.ExportPath,
PreviewVideoPath = result.PreviewArtifacts.PreviewVideoPath,
ComparisonVideoPath = result.PreviewArtifacts.ComparisonVideoPath,
ImportedAssetPath = importedAnimationAbsolutePath,
CreatedUtc = DateTime.UtcNow.ToString( "O" )
} );
if ( job.RecentRuns.Count > MaxRecentRuns )
job.RecentRuns = job.RecentRuns.Take( MaxRecentRuns ).ToList();
return job;
}
private static string FormatStageNameForLog( string stageId )
{
if ( string.IsNullOrWhiteSpace( stageId ) )
return "unknown stage";
var readable = stageId.Replace( '_', ' ' ).Replace( '-', ' ' ).Trim();
return string.IsNullOrWhiteSpace( readable ) ? stageId : readable;
}
private static string BuildPipelineRunSummary( RetargetImportResult result )
{
var failedStage = result.Manifest.Stages.FirstOrDefault( stage => stage.Status.Equals( "failed", StringComparison.OrdinalIgnoreCase ) );
if ( !string.IsNullOrWhiteSpace( result.PostImportError ) )
return $"Failed during target import for '{result.SequenceName}'.";
if ( failedStage is not null )
return $"Failed during stage '{FormatStageNameForLog( failedStage.StageId )}' for '{result.SequenceName}'.";
if ( result.Manifest.MotionTrajectoryAnalysis.Failures.Count > 0 )
return $"Completed with motion validation failures for '{result.SequenceName}'.";
if ( result.Manifest.Status.Equals( "completed", StringComparison.OrdinalIgnoreCase ) )
return $"Completed successfully for '{result.SequenceName}'.";
return string.IsNullOrWhiteSpace( result.Manifest.Status )
? $"Run '{result.SequenceName}' finished with an unknown status."
: $"Run '{result.SequenceName}' finished with status '{result.Manifest.Status}'.";
}
private static string BuildPipelineNextStep( RetargetImportResult result, RetargetDiagnosticSummary diagnostics )
{
var failedStage = result.Manifest.Stages.FirstOrDefault( stage => stage.Status.Equals( "failed", StringComparison.OrdinalIgnoreCase ) );
if ( !string.IsNullOrWhiteSpace( result.PostImportError ) )
return "Open Raw Export and Manifest first, then inspect the target import step and generated asset paths.";
if ( failedStage is not null )
return $"Inspect the '{FormatStageNameForLog( failedStage.StageId )}' stage and its error/warnings first.";
if ( diagnostics.MissingRequiredSlots.Count > 0 || result.Manifest.UnmappedRequiredSlots.Count > 0 )
return "Fix the required mapping coverage first, then run the retarget again.";
if ( result.Manifest.MotionTrajectoryAnalysis.Failures.Count > 0 )
return "Review the motion failures and verify pose preset, mapping, and root motion settings.";
if ( result.Manifest.BackendWarnings.Count > 0 || result.Manifest.Warnings.Count > 0 )
return "Review the warnings in this log if the animation quality looks wrong; otherwise preview the result in the generated model.";
return "Preview the animation in the generated model. Use this log only if the result looks wrong.";
}
private static void AppendLogSection( List<string> lines, string title, bool addLeadingSpacing = true )
{
if ( addLeadingSpacing && lines.Count > 0 )
lines.Add( string.Empty );
lines.Add( title );
lines.Add( new string( '-', title.Length ) );
}
private string BuildImportLog(
RetargetClipDescriptor clip,
RetargetImportResult result,
RetargetDiagnosticSummary diagnostics,
string importedAnimationAbsolutePath,
string vmdlAbsolutePath )
{
var lines = new List<string>();
AppendLogSection( lines, "Run Status", addLeadingSpacing: false );
lines.AddRange(
[
$"Summary: {BuildPipelineRunSummary( result )}",
$"Run ID: {result.Manifest.RunId}",
$"Source clip: {clip.DisplayName}",
$"Generated sequence: {result.SequenceName}",
$"Backend status: {result.Manifest.Status}"
] );
if ( !string.IsNullOrWhiteSpace( result.PostImportError ) )
lines.Add( $"Primary failure: {result.PostImportError}" );
AppendLogSection( lines, "Prerequisites" );
if ( !string.IsNullOrWhiteSpace( result.Manifest.SourceFile ) )
lines.Add( $"Source file: {result.Manifest.SourceFile}" );
if ( !string.IsNullOrWhiteSpace( result.VmdlResourcePath ) )
lines.Add( $"Target VMDL: {result.VmdlResourcePath}" );
if ( !string.IsNullOrWhiteSpace( result.Manifest.PoseNormalization.TargetPosePresetId ) )
lines.Add( $"Pose preset: {result.Manifest.PoseNormalization.TargetPosePresetId}" );
lines.Add( $"Required mapping: {result.Manifest.MappingCoverage.MappedRequiredSlotCount}/{result.Manifest.MappingCoverage.RequiredSlotCount}" );
lines.Add( $"Optional mapping: {result.Manifest.MappingCoverage.MappedOptionalSlotCount}/{result.Manifest.MappingCoverage.OptionalSlotCount}" );
lines.Add( $"User overrides: {result.Manifest.MappingCoverage.UserOverrideSlotCount}" );
if ( diagnostics.DisabledFingerSlots.Count > 0 )
lines.Add( $"Disabled finger slots: {string.Join( ", ", diagnostics.DisabledFingerSlots )}" );
if ( result.Manifest.UnmappedRequiredSlots.Count > 0 )
lines.Add( $"Unmapped required slots: {string.Join( ", ", result.Manifest.UnmappedRequiredSlots )}" );
AppendLogSection( lines, "Process" );
if ( result.Manifest.Stages.Count == 0 )
{
lines.Add( "- No stage data was recorded for this run." );
}
else
{
foreach ( var stage in result.Manifest.Stages )
{
lines.Add( $"- {FormatStageNameForLog( stage.StageId )}: {stage.Status}" );
if ( stage.Warnings.Count > 0 )
lines.AddRange( stage.Warnings.Select( warning => $" warning: {warning}" ) );
if ( !string.IsNullOrWhiteSpace( stage.Error ) )
lines.Add( $" error: {stage.Error}" );
}
}
AppendLogSection( lines, "Outputs" );
lines.Add( $"Run dir: {result.Manifest.Outputs.RunDir}" );
lines.Add( $"Manifest: {result.ManifestAbsolutePath}" );
lines.Add( $"Raw export: {(string.IsNullOrWhiteSpace( result.Manifest.Outputs.ExportPath ) ? "missing" : result.Manifest.Outputs.ExportPath)}" );
lines.Add( $"Imported FBX: {(string.IsNullOrWhiteSpace( importedAnimationAbsolutePath ) ? "missing" : importedAnimationAbsolutePath)}" );
lines.Add( $"Generated VMDL: {(string.IsNullOrWhiteSpace( vmdlAbsolutePath ) ? "missing" : vmdlAbsolutePath)}" );
var warningLines = new List<string>();
if ( result.Manifest.BackendWarnings.Count > 0 )
warningLines.AddRange( result.Manifest.BackendWarnings.Select( warning => $"- Backend: {warning}" ) );
if ( result.Manifest.Warnings.Count > 0 )
warningLines.AddRange( result.Manifest.Warnings.Select( warning => $"- Run: {warning}" ) );
if ( result.Manifest.MotionTrajectoryAnalysis.Warnings.Count > 0 )
warningLines.AddRange( result.Manifest.MotionTrajectoryAnalysis.Warnings.Select( warning => $"- Motion warning: {warning}" ) );
if ( result.Manifest.MotionTrajectoryAnalysis.Failures.Count > 0 )
warningLines.AddRange( result.Manifest.MotionTrajectoryAnalysis.Failures.Select( failure => $"- Motion failure: {failure}" ) );
if ( warningLines.Count > 0 )
{
AppendLogSection( lines, "Warnings" );
lines.AddRange( warningLines );
}
AppendLogSection( lines, "What To Check Next" );
lines.Add( BuildPipelineNextStep( result, diagnostics ) );
if ( !string.IsNullOrWhiteSpace( result.BackendInvocation.BlenderExecutablePath ) )
{
AppendLogSection( lines, "Log Context" );
lines.Add( $"Blender executable: {result.BackendInvocation.BlenderExecutablePath}" );
}
return string.Join( Environment.NewLine, lines );
}
private static string WriteRunLogArtifact( string runDirectoryPath, string log )
{
if ( string.IsNullOrWhiteSpace( runDirectoryPath ) || string.IsNullOrWhiteSpace( log ) )
return string.Empty;
try
{
Directory.CreateDirectory( runDirectoryPath );
var logPath = Path.Combine( runDirectoryPath, CitizenRetargetPaths.EditorRunLogFileName );
File.WriteAllText( logPath, log );
return logPath;
}
catch
{
return string.Empty;
}
}
private string MaterializeImportedAnimation(
string animationFolder,
string sequencePrefix,
RetargetClipDescriptor clip,
string exportPath,
string sourceActionName )
{
if ( string.IsNullOrWhiteSpace( exportPath ) || !File.Exists( exportPath ) )
throw new FileNotFoundException( $"The backend export FBX does not exist: '{exportPath}'." );
var sequenceName = RetargetSequenceNames.Build( sequencePrefix, clip.DisplayName );
var outputAnimationFolder = NormalizeResourcePath( $"{animationFolder}/anims" );
DeleteStaleGeneratedAnimationVariants( outputAnimationFolder, sequenceName );
var absoluteFolder = CitizenRetargetPaths.GetAssetAbsolutePath( outputAnimationFolder );
Directory.CreateDirectory( absoluteFolder );
var absolutePath = Path.Combine( absoluteFolder, $"{sequenceName}.fbx" );
return _dmxBridge.ExportSolvedCitizenFbxToTemplateFbx( exportPath, sequenceName, absolutePath, sourceActionName );
}
private void EnsureGeneratedTargetExists( CitizenRetargetJob job )
{
var normalizedTargetVmdlPath = NormalizeResourcePath( job.TargetVmdlPath );
var normalizedAnimationFolder = NormalizeResourcePath( job.OutputAnimationFolder );
var normalizedPrefix = string.IsNullOrWhiteSpace( job.SequencePrefix ) ? CitizenTargetProfile.DefaultSequencePrefix : job.SequencePrefix.Trim();
var vmdlAbsolutePath = _vmdlWriter.WriteSharedVmdl( normalizedTargetVmdlPath, normalizedAnimationFolder, normalizedPrefix, ".fbx" );
AssetSystem.RegisterFile( vmdlAbsolutePath )?.Compile( true );
}
private static void DeleteStaleGeneratedAnimationVariants( string outputAnimationFolder, string sequenceName )
{
var absoluteFolder = CitizenRetargetPaths.GetAssetAbsolutePath( outputAnimationFolder );
Directory.CreateDirectory( absoluteFolder );
foreach ( var extension in new[] { ".fbx", ".smd", ".dmx" } )
{
var candidate = Path.Combine( absoluteFolder, $"{sequenceName}{extension}" );
if ( File.Exists( candidate ) )
File.Delete( candidate );
}
}
private void PrepareRuntimeJobForImport( CitizenRetargetJob job, RetargetMappingProfile? mappingProfile )
{
job.SourceFbxPath = NormalizeSourceFilePath( job.SourceFbxPath );
job.TargetVmdlPath = NormalizeResourcePath( job.TargetVmdlPath );
job.OutputAnimationFolder = NormalizeResourcePath( job.OutputAnimationFolder );
job.SequencePrefix = string.IsNullOrWhiteSpace( job.SequencePrefix ) ? CitizenTargetProfile.DefaultSequencePrefix : job.SequencePrefix.Trim();
job.TargetPosePresetId = string.IsNullOrWhiteSpace( job.TargetPosePresetId )
? (mappingProfile?.TargetPosePresetId ?? CitizenTargetProfile.DefaultTargetPosePresetId)
: job.TargetPosePresetId.Trim();
}
private static string SanitizeResourceToken( string value )
{
var token = string.IsNullOrWhiteSpace( value ) ? "default" : value.Trim().TrimEnd( '_' );
if ( string.IsNullOrWhiteSpace( token ) )
token = "default";
var builder = new StringBuilder();
foreach ( var character in token )
builder.Append( char.IsLetterOrDigit( character ) || character is '_' or '-' ? character : '_' );
return builder.ToString();
}
private static CitizenRetargetJob PrepareJobForPersistence( CitizenRetargetJob source )
{
return new CitizenRetargetJob
{
SourceFbxPath = CitizenRetargetPaths.EncodeExternalPath( source.SourceFbxPath ),
SourceProfilePath = source.SourceProfilePath,
MappingProfilePath = source.MappingProfilePath,
TargetVmdlPath = source.TargetVmdlPath,
OutputAnimationFolder = source.OutputAnimationFolder,
SequencePrefix = source.SequencePrefix,
TargetPosePresetId = source.TargetPosePresetId,
RootMotionMode = source.RootMotionMode,
ImportHands = source.ImportHands,
SelectedClipNames = source.SelectedClipNames?.ToList() ?? new List<string>(),
LastImportedClip = source.LastImportedClip,
LastSuccessfulRunId = source.LastSuccessfulRunId,
LastSuccessfulSequenceName = source.LastSuccessfulSequenceName,
LastManifestPath = CitizenRetargetPaths.EncodeExternalPath( source.LastManifestPath ),
RecentRuns = source.RecentRuns?.Select( PrepareHistoryForPersistence ).ToList() ?? new List<RetargetRunHistoryEntry>()
};
}
private static CitizenRetargetJob CloneJobForRuntime( CitizenRetargetJob source )
{
return new CitizenRetargetJob
{
SourceFbxPath = CitizenRetargetPaths.DecodeExternalPath( source.SourceFbxPath ),
SourceProfilePath = source.SourceProfilePath,
MappingProfilePath = source.MappingProfilePath,
TargetVmdlPath = source.TargetVmdlPath,
OutputAnimationFolder = source.OutputAnimationFolder,
SequencePrefix = source.SequencePrefix,
TargetPosePresetId = source.TargetPosePresetId,
RootMotionMode = source.RootMotionMode,
ImportHands = source.ImportHands,
SelectedClipNames = source.SelectedClipNames?.ToList() ?? new List<string>(),
LastImportedClip = source.LastImportedClip,
LastSuccessfulRunId = source.LastSuccessfulRunId,
LastSuccessfulSequenceName = source.LastSuccessfulSequenceName,
LastManifestPath = CitizenRetargetPaths.DecodeExternalPath( source.LastManifestPath ),
RecentRuns = source.RecentRuns?.Select( CloneHistoryForRuntime ).ToList() ?? new List<RetargetRunHistoryEntry>()
};
}
private static RetargetSourceProfile PrepareSourceProfileForPersistence( RetargetSourceProfile source )
{
return new RetargetSourceProfile
{
ProfileId = source.ProfileId,
BackendSourceProfileId = source.BackendSourceProfileId,
DisplayName = source.DisplayName,
SampleReferenceFbxPath = CitizenRetargetPaths.EncodeExternalPath( source.SampleReferenceFbxPath ),
DefaultMappingProfilePath = source.DefaultMappingProfilePath,
DefaultPoseReferenceAction = source.DefaultPoseReferenceAction,
DefaultPoseReferenceFrame = source.DefaultPoseReferenceFrame,
RootBoneCandidates = source.RootBoneCandidates?.ToList() ?? new List<string>(),
CanonicalSourceAliases = source.CanonicalSourceAliases?.Select( CloneAliasSet ).ToList() ?? new List<RetargetSlotAliasSet>(),
FingerChainHints = source.FingerChainHints?.Select( CloneFingerHint ).ToList() ?? new List<RetargetFingerChainHint>(),
Notes = source.Notes?.ToList() ?? new List<string>()
};
}
private static RetargetSourceProfile CloneSourceProfileForRuntime( RetargetSourceProfile source )
{
return new RetargetSourceProfile
{
ProfileId = source.ProfileId,
BackendSourceProfileId = source.BackendSourceProfileId,
DisplayName = source.DisplayName,
SampleReferenceFbxPath = CitizenRetargetPaths.DecodeExternalPath( source.SampleReferenceFbxPath ),
DefaultMappingProfilePath = source.DefaultMappingProfilePath,
DefaultPoseReferenceAction = source.DefaultPoseReferenceAction,
DefaultPoseReferenceFrame = source.DefaultPoseReferenceFrame,
RootBoneCandidates = source.RootBoneCandidates?.ToList() ?? new List<string>(),
CanonicalSourceAliases = source.CanonicalSourceAliases?.Select( CloneAliasSet ).ToList() ?? new List<RetargetSlotAliasSet>(),
FingerChainHints = source.FingerChainHints?.Select( CloneFingerHint ).ToList() ?? new List<RetargetFingerChainHint>(),
Notes = source.Notes?.ToList() ?? new List<string>()
};
}
private static RetargetRunHistoryEntry PrepareHistoryForPersistence( RetargetRunHistoryEntry entry )
{
return new RetargetRunHistoryEntry
{
RunId = entry.RunId,
ClipName = entry.ClipName,
SequenceName = entry.SequenceName,
Status = entry.Status,
TargetVmdlPath = entry.TargetVmdlPath,
ManifestPath = CitizenRetargetPaths.EncodeExternalPath( entry.ManifestPath ),
ExportPath = CitizenRetargetPaths.EncodeExternalPath( entry.ExportPath ),
PreviewVideoPath = CitizenRetargetPaths.EncodeExternalPath( entry.PreviewVideoPath ),
ComparisonVideoPath = CitizenRetargetPaths.EncodeExternalPath( entry.ComparisonVideoPath ),
ImportedAssetPath = CitizenRetargetPaths.EncodeExternalPath( entry.ImportedAssetPath ),
CreatedUtc = entry.CreatedUtc
};
}
private static RetargetRunHistoryEntry CloneHistoryForRuntime( RetargetRunHistoryEntry entry )
{
return new RetargetRunHistoryEntry
{
RunId = entry.RunId,
ClipName = entry.ClipName,
SequenceName = entry.SequenceName,
Status = entry.Status,
TargetVmdlPath = entry.TargetVmdlPath,
ManifestPath = CitizenRetargetPaths.DecodeExternalPath( entry.ManifestPath ),
ExportPath = CitizenRetargetPaths.DecodeExternalPath( entry.ExportPath ),
PreviewVideoPath = CitizenRetargetPaths.DecodeExternalPath( entry.PreviewVideoPath ),
ComparisonVideoPath = CitizenRetargetPaths.DecodeExternalPath( entry.ComparisonVideoPath ),
ImportedAssetPath = CitizenRetargetPaths.DecodeExternalPath( entry.ImportedAssetPath ),
CreatedUtc = entry.CreatedUtc
};
}
private static RetargetSlotAliasSet CloneAliasSet( RetargetSlotAliasSet alias )
{
return new RetargetSlotAliasSet
{
SlotId = alias.SlotId,
Group = alias.Group,
Required = alias.Required,
Aliases = alias.Aliases?.ToList() ?? new List<string>()
};
}
private static RetargetFingerChainHint CloneFingerHint( RetargetFingerChainHint hint )
{
return new RetargetFingerChainHint
{
FingerId = hint.FingerId,
HandSide = hint.HandSide,
Bones = hint.Bones?.ToList() ?? new List<string>()
};
}
private static bool NeedsJobPersistenceMigration( CitizenRetargetJob job )
{
return IsPlainAbsolutePath( job.SourceFbxPath )
|| IsPlainAbsolutePath( job.LastManifestPath )
|| (job.RecentRuns?.Any( entry =>
IsPlainAbsolutePath( entry.ManifestPath )
|| IsPlainAbsolutePath( entry.ExportPath )
|| IsPlainAbsolutePath( entry.PreviewVideoPath )
|| IsPlainAbsolutePath( entry.ComparisonVideoPath )
|| IsPlainAbsolutePath( entry.ImportedAssetPath )) ?? false);
}
private static bool NeedsSourceProfilePersistenceMigration( RetargetSourceProfile sourceProfile )
{
return IsPlainAbsolutePath( sourceProfile.SampleReferenceFbxPath );
}
private static bool IsPlainAbsolutePath( string? path )
{
if ( string.IsNullOrWhiteSpace( path ) )
return false;
var trimmed = path.Trim();
return Path.IsPathRooted( trimmed ) && !trimmed.StartsWith( "__external__:", StringComparison.Ordinal );
}
private static string InferSlotGroup( string slotId )
{
return slotId.Contains( "finger_", StringComparison.OrdinalIgnoreCase ) ? "fingers" : "body";
}
private static bool IsFingerSlot( string? group, string slotId )
{
return (group ?? string.Empty).Contains( "finger", StringComparison.OrdinalIgnoreCase )
|| slotId.Contains( "finger_", StringComparison.OrdinalIgnoreCase );
}
private static string InferMirrorSlot( string slotId )
{
if ( slotId.EndsWith( "_L", StringComparison.OrdinalIgnoreCase ) )
return slotId[..^2] + "_R";
if ( slotId.EndsWith( "_R", StringComparison.OrdinalIgnoreCase ) )
return slotId[..^2] + "_L";
return string.Empty;
}
private static string NormalizeSourceFilePath( string path )
{
if ( string.IsNullOrWhiteSpace( path ) )
throw new InvalidOperationException( "Source FBX path is empty." );
var normalized = CitizenRetargetPaths.DecodeExternalPath( path ).Trim().Trim( '"' );
if ( !Path.IsPathRooted( normalized ) )
normalized = Path.GetFullPath( Path.Combine( CitizenRetargetPaths.ProjectRoot, normalized ) );
if ( !File.Exists( normalized ) )
throw new FileNotFoundException( $"Could not find source FBX '{normalized}'." );
return normalized;
}
private static string NormalizeResourcePath( string path )
{
if ( string.IsNullOrWhiteSpace( path ) )
throw new InvalidOperationException( "Expected a non-empty asset path." );
var normalized = path.Replace( '\\', '/' ).Trim();
if ( Path.IsPathRooted( normalized ) )
{
var assetsRoot = CitizenRetargetPaths.ProjectAssetsRoot.Replace( '\\', '/' );
if ( normalized.StartsWith( assetsRoot, StringComparison.OrdinalIgnoreCase ) )
return normalized[assetsRoot.Length..].TrimStart( '/' );
var pluginAssetsRoot = CitizenRetargetPaths.PluginAssetsRoot.Replace( '\\', '/' );
if ( normalized.StartsWith( pluginAssetsRoot, StringComparison.OrdinalIgnoreCase ) )
return normalized[pluginAssetsRoot.Length..].TrimStart( '/' );
throw new InvalidOperationException( $"Asset path '{path}' is outside the project Assets folder." );
}
if ( normalized.StartsWith( "Assets/", StringComparison.OrdinalIgnoreCase ) )
return normalized["Assets/".Length..];
return normalized.TrimStart( '/' );
}
private static string ToAbsoluteAssetPath( string relativeAssetPath )
{
return CitizenRetargetPaths.GetAssetAbsolutePath( relativeAssetPath );
}
private static string ResolveAssetOrPluginAssetPath( string relativeAssetPath )
{
var projectPath = CitizenRetargetPaths.GetAssetAbsolutePath( relativeAssetPath );
if ( File.Exists( projectPath ) )
return projectPath;
var pluginPath = CitizenRetargetPaths.GetPluginAssetAbsolutePath( relativeAssetPath );
if ( File.Exists( pluginPath ) )
return pluginPath;
return projectPath;
}
private static void RegisterFileIfExists( string absolutePath )
{
if ( string.IsNullOrWhiteSpace( absolutePath ) || !File.Exists( absolutePath ) )
return;
try
{
AssetSystem.RegisterFile( absolutePath );
}
catch
{
// Library files may already be mounted. JSON fallback loading still works.
}
}
}