Editor/Services/WeaponAnimationMigration.cs

Editor utility for migrating and repairing WeaponAnimationDocument assets to the current schema. It fixes material metadata, recovers source dimensions, reorders keys, migrates rig/track data for older schemas, repairs legacy idle bind poses, creates backups, and returns a summary result.

File Access
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

public sealed class WeaponAnimationMigrationResult
{
	public bool Migrated { get; init; }
	public bool CurveSchemaMigrated { get; init; }
	public bool RepairedLegacyIdle { get; init; }
	public bool RepairedMaterialMetadata { get; init; }
	public bool RepairedSequenceNames { get; init; }
	public bool RepairedSourceDimensions { get; init; }
	public bool RepairedKeyOrder { get; init; }
	public int SourceSchemaVersion { get; init; }
	public int PreservedWeaponTracks { get; init; }
	public int RemovedTracks { get; init; }
	public int RemovedConstraints { get; init; }
	public bool Changed =>
		Migrated
		|| RepairedLegacyIdle
		|| RepairedMaterialMetadata
		|| RepairedSequenceNames
		|| RepairedSourceDimensions
		|| RepairedKeyOrder;

	public string Summary
	{
		get
		{
			if ( Migrated )
			{
				if ( CurveSchemaMigrated && SourceSchemaVersion >= 3 )
				{
					return $"Migrated schema {SourceSchemaVersion} → {WeaponAnimationDocument.CurrentSchemaVersion}. "
						+ "Existing interpolation was preserved; editable curve data will be created only when changed.";
				}

				return $"Migrated schema {SourceSchemaVersion} → {WeaponAnimationDocument.CurrentSchemaVersion}. "
					+ $"Preserved {PreservedWeaponTracks} weapon tracks; reset {RemovedTracks} arm or incompatible tracks"
					+ (RemovedConstraints > 0 ? $" and {RemovedConstraints} constraints." : ".");
			}

			if ( RepairedLegacyIdle )
			{
				return "Repaired a legacy Idle bind pose that stored weapon bones in model space. "
					+ "The original version will be backed up when this project is saved.";
			}
			if ( RepairedSequenceNames )
			{
				return "Assigned stable, readable sequence names to custom clips. "
					+ "The original version will be backed up when saved.";
			}
			if ( RepairedSourceDimensions )
			{
				return "Recovered source-model dimensions for calibration validation. "
					+ "The original version will be backed up when saved.";
			}
			if ( RepairedKeyOrder )
			{
				return "Normalized animation key order for efficient playback. "
					+ "The original version will be backed up when saved.";
			}
			return RepairedMaterialMetadata
				? "Repaired imported material slot metadata so source labels are not treated "
					+ "as asset dependencies. The original version will be backed up when saved."
				: "";
		}
	}
}

public static class WeaponAnimationMigration
{
	public static WeaponAnimationMigrationResult MigrateAndRepair(
		WeaponAnimationDocument document )
	{
		document.Source ??= new SourceModelSettings();
		document.Source.Materials ??= [];

		if ( document.SchemaVersion > WeaponAnimationDocument.CurrentSchemaVersion )
		{
			throw new InvalidOperationException(
				$"This .wepanim uses schema {document.SchemaVersion}, newer than supported schema "
				+ $"{WeaponAnimationDocument.CurrentSchemaVersion}." );
		}

		var sourceVersion = document.SchemaVersion;
		var migrated = sourceVersion < WeaponAnimationDocument.CurrentSchemaVersion;
		var separatedRigMigration = sourceVersion < 3;
		var curveSchemaMigration = sourceVersion < 4;
		var preservedTracks = 0;
		var removedTracks = 0;
		var removedConstraints = 0;
		var repairedMaterialMetadata = false;
		var repairedSourceDimensions = false;
		var repairedKeyOrder = false;
		foreach ( var material in document.Source.Materials )
		{
			var storedSlot = WeaponMaterialPipeline.StoredMaterialSlot(
				material.SourceMaterialPath );
			if ( !storedSlot.Equals(
				material.SourceMaterialPath,
				StringComparison.Ordinal ) )
			{
				material.SourceMaterialPath = storedSlot;
				repairedMaterialMetadata = true;
			}
		}
		if ( document.Source.OriginalModelDimensions.Length <= 0
			&& !string.IsNullOrWhiteSpace( document.Source.CompiledModelPath ) )
		{
			try
			{
				var sourceModel = Model.Load( document.Source.CompiledModelPath );
				if ( sourceModel is not null
					&& !sourceModel.IsError
					&& sourceModel.Bounds.Size.Length > 0 )
				{
					document.Source.OriginalModelDimensions = sourceModel.Bounds.Size;
					repairedSourceDimensions = true;
				}
			}
			catch
			{
				// The preview recovery path can populate this after the missing asset returns.
			}
		}

		if ( separatedRigMigration )
		{
			WeaponRigHierarchy.RepairMetadata( document.Rig, true );
			foreach ( var bone in document.Rig.Bones )
			{
				bone.Inclusion = bone.Classification == WeaponBoneClassification.Ignored
					? WeaponBoneInclusion.Excluded
					: WeaponBoneInclusion.Included;
			}

			var root = document.Rig.Bones.FirstOrDefault( x =>
				x.Classification == WeaponBoneClassification.WeaponRoot )
				?? document.Rig.FindBone( document.Rig.RootBone )
				?? document.Rig.Bones.FirstOrDefault();
			if ( root is not null )
			{
				root.Classification = WeaponBoneClassification.WeaponRoot;
				root.Inclusion = WeaponBoneInclusion.Included;
				document.Rig.RootBone = root.Name;
				document.Rig.WeaponSubtreeRootId = root.Id;
			}

			var retainedNames = document.Rig.RetainedBones()
				.Select( x => x.Name )
				.ToHashSet( StringComparer.OrdinalIgnoreCase );
			retainedNames.Add( "weapon_root" );
			foreach ( var clip in document.Clips )
			{
				foreach ( var track in clip.Tracks.ToArray() )
				{
					var isOldRoot = root is not null
						&& track.Target.Equals( root.Name, StringComparison.OrdinalIgnoreCase );
					var preserve = track.Kind == RigControlKind.Weapon
						&& (isOldRoot || retainedNames.Contains( track.Target ));
					if ( !preserve )
					{
						clip.Tracks.Remove( track );
						removedTracks++;
						continue;
					}

					if ( isOldRoot )
						track.Target = "weapon_root";
					preservedTracks++;
				}

				removedConstraints += clip.Constraints.RemoveAll( constraint =>
					!retainedNames.Contains( constraint.TargetBone ) );
			}

			var configuration = document.Binding.Configuration;
			document.Binding = new ArmBindingDefinition
			{
				Configuration = configuration
			};
			document.Calibration.Confirmed = false;
			document.ActiveStage = WeaponAnimatorStage.Calibrate;
			document.Source.PreviewHostCompiled = false;
			document.Rig.ReviewRequired = true;
			document.Rig.FilteredPreviewConfirmed = false;
			document.Rig.ProfileHash = WeaponSourceImporter.HashText(
				WeaponRigHierarchy.ProfileText( document.Rig ) );
		}
		else
		{
			WeaponRigHierarchy.RepairMetadata( document.Rig, false );
		}

		foreach ( var track in document.Clips.SelectMany( x => x.Tracks ) )
		{
			repairedKeyOrder |= !KeysAreSorted( track.Keys.Select( key => key.Time ) );
			track.Keys.Sort( ( left, right ) => left.Time.CompareTo( right.Time ) );
			foreach ( var key in track.Keys )
			{
				key.CurveTangents ??= new TransformCurveTangents();
				if ( curveSchemaMigration )
				{
					key.CurveTangents.PositionIn = key.InTangent;
					key.CurveTangents.PositionOut = key.OutTangent;
				}
			}
			WeaponAnimationMath.RepairCurveSpans( track );
		}
		foreach ( var track in document.Clips.SelectMany( x => x.VisibilityTracks ) )
		{
			repairedKeyOrder |= !KeysAreSorted( track.Keys.Select( key => key.Time ) );
			track.Keys.Sort( ( left, right ) => left.Time.CompareTo( right.Time ) );
		}
		var repairedSequenceNames =
			WeaponAnimationNames.RepairCustomSequenceNames( document )
			| WeaponAnimationNames.RepairCustomAnchorNames( document );

		var repairedLegacyIdle = !separatedRigMigration && RepairLegacyIdleBindPose( document );
		document.SchemaVersion = WeaponAnimationDocument.CurrentSchemaVersion;
		foreach ( var role in WeaponAnimationDocument.StandardClips() )
			document.EnsureClip( role );
		if ( document.Workspace.SelectedClipId == Guid.Empty
			|| document.Clips.All( x => x.Id != document.Workspace.SelectedClipId ) )
			document.Workspace.SelectedClipId = document.EnsureClip( WeaponClipRole.Idle ).Id;
		if ( string.IsNullOrWhiteSpace( document.Output.AssetName ) )
			document.Output.AssetName = WeaponAnimationDocument.Slugify( document.Name );

		return new WeaponAnimationMigrationResult
		{
			Migrated = migrated,
			CurveSchemaMigrated = curveSchemaMigration,
			RepairedLegacyIdle = repairedLegacyIdle,
			RepairedMaterialMetadata = repairedMaterialMetadata,
			RepairedSequenceNames = repairedSequenceNames,
			RepairedSourceDimensions = repairedSourceDimensions,
			RepairedKeyOrder = repairedKeyOrder,
			SourceSchemaVersion = sourceVersion,
			PreservedWeaponTracks = preservedTracks,
			RemovedTracks = removedTracks,
			RemovedConstraints = removedConstraints
		};
	}

	private static bool KeysAreSorted( IEnumerable<float> times )
	{
		var hasPrevious = false;
		var previous = 0.0f;
		foreach ( var time in times )
		{
			if ( hasPrevious && previous > time )
				return false;
			previous = time;
			hasPrevious = true;
		}
		return true;
	}

	internal static bool RepairLegacyIdleBindPose(
		WeaponAnimationDocument document,
		HostSkeleton? authoritativeSkeleton = null )
	{
		var idle = document.Clips.FirstOrDefault( x => x.Role == WeaponClipRole.Idle );
		var retained = document.Rig.RetainedBones().ToList();
		if ( idle is null || retained.Count < 2
			|| idle.Constraints.Count > 0
			|| idle.Tags.Count > 0
			|| idle.VisibilityTracks.Any( x => x.Keys.Count > 0 )
			|| idle.ParameterEvents.Count > 0 )
			return false;

		var skeleton = authoritativeSkeleton
			?? HostSkeletonBuilder.Build( document, includeArmProfile: false );
		var weaponBones = skeleton.Bones.Where( x => x.IsWeaponBone ).ToList();
		if ( weaponBones.Count != retained.Count )
			return false;

		var tracks = weaponBones
			.Select( bone => idle.Tracks.FirstOrDefault( track =>
				track.Kind == RigControlKind.Weapon
				&& track.Target.Equals( bone.Name, StringComparison.OrdinalIgnoreCase ) ) )
			.ToList();
		if ( tracks.Any( track => track is null
			|| track.Keys.Count != 1
			|| MathF.Abs( track.Keys[0].Time ) > 0.0001f ) )
			return false;

		var legacyMatches = 0;
		var localMatches = 0;
		var comparableChildren = 0;
		foreach ( var definition in retained.Where( x =>
			!x.Id.Equals( document.Rig.SourceSkeletonRootId, StringComparison.OrdinalIgnoreCase ) ) )
		{
			var track = idle.Tracks.FirstOrDefault( x =>
				x.Target.Equals( definition.Name, StringComparison.OrdinalIgnoreCase ) );
			if ( track is null )
				continue;

			comparableChildren++;
			var keyed = new Transform(
				track.Keys[0].Position,
				track.Keys[0].Rotation,
				track.Keys[0].Scale );
			if ( TransformNear( keyed, definition.BindModelTransform ) )
				legacyMatches++;
			if ( TransformNear( keyed, definition.BindLocalTransform ) )
				localMatches++;
		}

		var childrenUseLegacyModelSpace = comparableChildren >= 1
			&& legacyMatches * 4 >= comparableChildren * 3
			&& legacyMatches > localMatches;
		var sourceRoot = retained.FirstOrDefault( x => x.Id.Equals(
			document.Rig.SourceSkeletonRootId,
			StringComparison.OrdinalIgnoreCase ) );
		var hostRoot = weaponBones.FirstOrDefault( x => x.SourceBoneId.Equals(
			document.Rig.SourceSkeletonRootId,
			StringComparison.OrdinalIgnoreCase ) );
		var rootTrack = hostRoot is null
			? null
			: idle.Tracks.FirstOrDefault( x =>
				x.Target.Equals( hostRoot.Name, StringComparison.OrdinalIgnoreCase ) );
		var rootKey = rootTrack?.Keys.FirstOrDefault();
		var rootUsesLegacyNormalization = false;
		Transform correctedRoot = default;
		if ( sourceRoot is not null && hostRoot is not null && rootKey is not null )
		{
			var currentRoot = new Transform(
				rootKey.Position,
				rootKey.Rotation,
				rootKey.Scale );
			correctedRoot = WeaponAnimationMath.Compose(
				currentRoot,
				sourceRoot.BindModelTransform );
			rootUsesLegacyNormalization = childrenUseLegacyModelSpace;

			if ( authoritativeSkeleton is not null )
			{
				var expectedRoot = authoritativeSkeleton.GetBindLocal( hostRoot );
				rootUsesLegacyNormalization |= TransformNear( correctedRoot, expectedRoot )
					&& !TransformNear( currentRoot, expectedRoot );
				if ( rootUsesLegacyNormalization )
					correctedRoot = expectedRoot;
			}
		}

		// Affected projects omitted the imported root bind and seeded children in model space.
		if ( !childrenUseLegacyModelSpace && !rootUsesLegacyNormalization )
			return false;

		foreach ( var bone in weaponBones )
		{
			if ( bone.SourceBoneId.Equals(
				document.Rig.SourceSkeletonRootId,
				StringComparison.OrdinalIgnoreCase ) )
				continue;
			if ( !childrenUseLegacyModelSpace )
				continue;

			var track = idle.Tracks.First( x =>
				x.Target.Equals( bone.Name, StringComparison.OrdinalIgnoreCase ) );
			var local = skeleton.GetBindLocal( bone );
			var key = track.Keys[0];
			key.Position = local.Position;
			key.Rotation = local.Rotation.Normal;
			key.Scale = local.Scale;
		}

		if ( rootUsesLegacyNormalization && rootKey is not null )
		{
			rootKey.Position = correctedRoot.Position;
			rootKey.Rotation = correctedRoot.Rotation.Normal;
			rootKey.Scale = correctedRoot.Scale;
		}

		idle.IsBindPoseSeed = true;
		return true;
	}

	private static bool TransformNear( Transform left, Transform right, float tolerance = 0.001f )
	{
		return left.Position.Distance( right.Position ) <= tolerance
			&& (left.Scale - right.Scale).Length <= tolerance
			&& (left.Rotation.Forward - right.Rotation.Forward).Length <= tolerance
			&& (left.Rotation.Up - right.Rotation.Up).Length <= tolerance;
	}

	public static string CreateBackup( string absoluteAssetPath, int sourceSchemaVersion )
	{
		if ( string.IsNullOrWhiteSpace( absoluteAssetPath ) || !File.Exists( absoluteAssetPath ) )
			throw new FileNotFoundException( "The original .wepanim asset is unavailable for migration backup.", absoluteAssetPath );

		var backup = $"{absoluteAssetPath}.v{sourceSchemaVersion}.bak";
		if ( !File.Exists( backup ) )
			File.Copy( absoluteAssetPath, backup, false );
		return backup;
	}
}