// Animancer // https://kybernetik.com.au/animancer // Copyright 2018-2024 Kybernetik // #if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Text; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using static Animancer.AnimancerEvent.Sequence.Serializable; using Object = UnityEngine.Object; namespace Animancer.Editor { /// [Editor-Only] /// A system for migrating old asset data to the current version of Animancer. /// /// https://kybernetik.com.au/animancer/api/Animancer.Editor/AnimancerDataMigrator public class AnimancerDataMigrator { /************************************************************************************************************************/ /// The directory where any newly created files will be saved. public const string MigratedDataDirectory = "Animancer Migrated Data"; /************************************************************************************************************************/ #region Entry Point /************************************************************************************************************************/ [InitializeOnLoadMethod] private static void Initialize() { AnimancerReadMe.Editor.MigrateOldAssetData += text => new AnimancerDataMigrator(text); } /************************************************************************************************************************/ /// /// Shows dialogue windows explaining what the migration process will involve /// and confirming that the user wants to proceed before doing so. /// /// /// /// Save the scene if dirty. /// Make a backup. /// /// public AnimancerDataMigrator(string functionName) { if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return; if (EditorApplication.isCompiling) { Debug.LogError(functionName + " failed: unable to migrate while scripts are compiling."); EditorApplication.Beep(); return; } if (EditorUtility.scriptCompilationFailed) { Debug.LogError(functionName + " failed: all compile errors must be fixed first."); EditorApplication.Beep(); return; } if (!AskForBackup(functionName)) return; BeginMigration(); } /************************************************************************************************************************/ /// /// Explains what migration will involve /// and confirms that the user has backed up their project. /// private static bool AskForBackup(string functionName) { const string Message = "This process scans through your entire project" + " for assets containing data from an older version of Animancer" + " and attempts to convert that data to the format required by the current version." + "\n• This operation cannot be undone and may not be 100% reliable." + "\n• DO NOT proceed unless you have made a backup of your project" + " (Version Control such as Git is recommended)." + "\n• All files changed will be logged." + "\n• Please report any issues to " + Strings.DocsURLs.DeveloperEmail; var result = EditorUtility.DisplayDialogComplex( functionName, Message, "I have made a backup -> Migrate Data", "Cancel", "Open Upgrade Guide"); switch (result) { case 0: return true; default: case 1: return false; case 2: Application.OpenURL(Strings.DocsURLs.UpgradeGuideURL); return false; } } /************************************************************************************************************************/ private void BeginMigration() => MigrateData(GatherTargetFiles()); /************************************************************************************************************************/ private static List GatherTargetFiles() { AssetDatabase.SaveAssets(); var directory = Environment.CurrentDirectory; var targetFiles = new List(); GatherTargetFiles(Path.Combine(directory, "Assets"), targetFiles); GatherTargetFiles(Path.Combine(directory, "Packages"), targetFiles); return targetFiles; } private static void GatherTargetFiles(string directory, List targetFiles) { var allFiles = Directory.GetFiles(directory, "*", SearchOption.AllDirectories); for (int i = 0; i < allFiles.Length; i++) { var file = allFiles[i]; if (file.EndsWith(".asset", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".unity", StringComparison.OrdinalIgnoreCase)) targetFiles.Add(file); } } /************************************************************************************************************************/ private void MigrateData(List files) { try { Debug.Log("Data Migration Started"); var modifiedFileCount = 0; var timer = SimpleTimer.Start(); for (int i = 0; i < files.Count; i++) { var file = files[i]; if (ShowProgressBar(i, files.Count, file)) return; if (MigrateData(file)) modifiedFileCount++; } Debug.Log( $"Data Migration Complete." + $" Modified {modifiedFileCount} files in {timer}." + $" Please check the modified files and report any issues to {Strings.DocsURLs.DeveloperEmail}"); WarnAboutUnSharedChanges(); AssetDatabase.Refresh(); } finally { EditorUtility.ClearProgressBar(); } } /************************************************************************************************************************/ private static bool ShowProgressBar(int index, int count, string currentFile) => EditorUtility.DisplayCancelableProgressBar( "Migrating Asset Data", $"{index} / {count}: {currentFile}", index / (float)count); /************************************************************************************************************************/ private bool MigrateData(string filePath) { try { var originalText = File.ReadAllText(filePath); var modifiedText = originalText; MigrateSerializedReferences(ref modifiedText); MigrateEvents(ref modifiedText); MigrateUnSharedTransitionAssets(filePath, ref modifiedText); MigrateTransitionAssets(ref modifiedText); if (!ReferenceEquals(originalText, modifiedText)) { File.WriteAllText(filePath, modifiedText); filePath = GetRelativeFilePath(filePath); AssetDatabase.ImportAsset(filePath, ImportAssetOptions.ForceSynchronousImport); var asset = AssetDatabase.LoadAssetAtPath(filePath); Debug.Log($"Migrated: {filePath}", asset); return true; } } catch (Exception exception) { Debug.LogException(exception); } return false; } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Serialized References /************************************************************************************************************************/ /// [Animancer v8.0] /// Animancer assemblies renamed when moving to a package. /// private static void MigrateSerializedReferences(ref string fileText) { fileText = fileText.Replace( ", ns: Animancer, asm: Animancer}", ", ns: Animancer, asm: Kybernetik.Animancer}"); } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Events /************************************************************************************************************************/ /// [Animancer v8.0] /// Event callbacks changed from to . /// Event names changed from to . /// private static void MigrateEvents(ref string text) { const string Prefix = "_" + nameof(ITransitionWithEvents.Events) + ":"; var index = 0; while ((index = text.IndexOf(Prefix, index)) >= 0) { var callbacks = IndexOfEventField(text, index, CallbacksField); if (callbacks >= 0) MigrateEventCallbacks(ref text, callbacks); var names = IndexOfEventField(text, index, NamesField); if (names >= 0) MigrateEventNames(ref text, names); index += Prefix.Length; } } /************************************************************************************************************************/ /// Finds the indices of the serialized event fields and returns true if successful. private static int IndexOfEventField( string text, int start, string fieldName) { var baseIndentation = CountConsecutiveCharacters(text, start - 1, -1, ' '); while (true) { start = text.IndexOf('\n', start); if (start < 0) break; start++; var lineIndentation = CountConsecutiveCharacters(text, start, 1, ' '); if (lineIndentation <= baseIndentation) break; start += lineIndentation; if (text[start] != '_') continue; if (SubstringEquals(text, start, fieldName + ":")) return start; } return -1; } /************************************************************************************************************************/ #region Event Names /************************************************************************************************************************/ /// [Animancer v8.0] /// Event names changed from to . /// private static void MigrateEventNames(ref string text, int start) { start = text.IndexOf('\n', start); if (start < 0) return; start++; while (start < text.Length) { var end = text.IndexOf('\n', start + 1); if (start < 0) end = text.Length; start += CountConsecutiveCharacters(text, start, 1, ' '); var character = text[start]; if (character != '-') return; start++; // If not already migrated. if (!SubstringEquals(text, start, " {fileID: ")) { var name = text[start..end]; name = name.Trim(); start = text.IndexOf(name, start); MigrateEventName(ref text, start, name, out end); } start = end + 1; } } /************************************************************************************************************************/ private static void MigrateEventName(ref string text, int start, string name, out int end) { GetOrCreateStringAsset(name, out var path, out var guid); var fileID = GetFileID(path); end = start + name.Length; var before = text[..start]; var after = text[end..]; var newReference = $"{{fileID: {fileID}, guid: {guid}, type: 2}}"; end = start + newReference.Length; text = $"{before}{newReference}{after}"; } /************************************************************************************************************************/ private static StringAsset GetOrCreateStringAsset(StringReference name, out string path, out string guid) { var filter = $"{name} t:{nameof(StringAsset)}"; var guids = AssetDatabase.FindAssets(filter); for (int i = 0; i < guids.Length; i++) { guid = guids[i]; path = AssetDatabase.GUIDToAssetPath(guid); var asset = AssetDatabase.LoadAssetAtPath(path); if (asset != null && asset.Name == name) return asset; } var key = ScriptableObject.CreateInstance(); key.name = name; AssetDatabase.CreateFolder("Assets", MigratedDataDirectory); path = Path.Combine("Assets", MigratedDataDirectory, name + ".asset"); AssetDatabase.CreateAsset(key, path); guid = AssetDatabase.AssetPathToGUID(path); Debug.Log($"Created {nameof(StringAsset)} for event name: {name}", key); return key; } /************************************************************************************************************************/ private static string GetFileID(string assetPath) { assetPath += ".meta"; if (!File.Exists(assetPath)) return null; var meta = File.ReadAllText(assetPath); const string Prefix = "mainObjectFileID: "; var start = meta.IndexOf(Prefix, StringComparison.Ordinal); if (start < 0) return null; start += Prefix.Length; var end = meta.IndexOf('\n', start); if (end < 0) end = meta.Length; var id = meta[start..end]; id = id.Trim(); return id; } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Event Callbacks /************************************************************************************************************************/ /// [Animancer v8.0] /// Event callbacks changed from to . /// private static void MigrateEventCallbacks(ref string text, int start) { start = text.IndexOf('\n', start); if (start < 0) return; start++; while (start < text.Length) { var end = text.IndexOf('\n', start + 1); if (start < 0) end = text.Length; var indentation = CountConsecutiveCharacters(text, start, 1, ' '); start += indentation; var character = text[start]; if (character != '-') return; start++; // If already migrated, skip over it. if (SubstringEquals(text, start, " rid:")) { start = end; } else// Otherwise, turn it into a serialized reference. { var callback = ReIndentFieldData(text, start, indentation, 6, out end); var id = RandomLong(); var newText = StringBuilderPool.Instance.Acquire(); newText.Append(text, 0, start - indentation - 1); newText.Append(' ', indentation); newText.Append("- rid: "); newText.Append(id); newText.Append('\n'); start = newText.Length; newText.Append(text, end, text.Length - end); text = newText.ReleaseToString(); AddSerializedReference(ref text, start, id, typeof(Animancer.UnityEvent), callback); } } } /************************************************************************************************************************/ private static string ReIndentFieldData( string text, int start, int oldIndentation, int newIndentation, out int end) { var callback = StringBuilderPool.Instance.Acquire(); end = start; while (start < text.Length) { var indentation = CountConsecutiveCharacters(text, start, 1, ' '); // Remove the dash from the start of the first line. if (callback.Length == 0) { start += indentation; indentation = newIndentation + 2; } else// Adjust the indentation of other lines. { if (indentation <= oldIndentation) { end = start; break; } start += indentation; indentation = indentation - oldIndentation + newIndentation; } end = text.IndexOf('\n', start); if (end < 0) end = text.Length; callback.Append(' ', indentation); callback.Append(text, start, end - start); callback.Append('\n'); start = end + 1; } return callback.ReleaseToString(); } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region UnShared Transition Assets /************************************************************************************************************************/ private List _UnSharedFilePaths; /************************************************************************************************************************/ /// [Animancer v8.0] /// UnShared classes removed, /// data replaced with direct references. /// private void MigrateUnSharedTransitionAssets(string filePath, ref string fileText) { var isChanged = false; var start = 0; while (start < fileText.Length) { const string AssetReferencePrefix = " _Asset: ", FileReferencePrefix = "{fileID: ", GuidPrefix = ", guid: ", TypePrefix = ", type: "; start = fileText.IndexOf(AssetReferencePrefix + FileReferencePrefix, start, StringComparison.Ordinal); if (start < 0) break; var end = fileText.IndexOf( '\n', start + AssetReferencePrefix.Length + FileReferencePrefix.Length); if (end < 0) end = fileText.Length; var guid = fileText.IndexOf( GuidPrefix, start + AssetReferencePrefix.Length, end - (start + AssetReferencePrefix.Length), StringComparison.Ordinal); if (guid < 0) break; var type = fileText.IndexOf( TypePrefix, guid + GuidPrefix.Length, end - (guid + GuidPrefix.Length), StringComparison.Ordinal); if (type < 0) break; var guidReference = fileText[(guid + GuidPrefix.Length)..type]; var assetPath = AssetDatabase.GUIDToAssetPath(guidReference); if (string.IsNullOrEmpty(assetPath) || AssetDatabase.LoadAssetAtPath(assetPath) == null) { start++; continue; } var indent = CountConsecutiveCharacters(fileText, start, -1, ' '); var reference = fileText[(start + AssetReferencePrefix.Length)..end]; fileText = $"{fileText[..(start - indent)]} {reference}{fileText[end..]}"; if (!isChanged) { isChanged = true; _UnSharedFilePaths ??= new(); _UnSharedFilePaths.Add(GetRelativeFilePath(filePath)); } start += reference.Length; } } /************************************************************************************************************************/ private void WarnAboutUnSharedChanges() { if (_UnSharedFilePaths == null) return; var text = StringBuilderPool.Instance.Acquire(); AppendUnSharedFilePaths(text, int.MaxValue); var message = text.ToString(); Debug.LogWarning(message + '\n'); if (_UnSharedFilePaths.Count > 10) { text.Length = 0; AppendUnSharedFilePaths(text, 10); message = text.ToString(); } EditorUtility.DisplayDialog( "UnShared Transition Assets", message + "\n\nThis warning will be logged.", "I will review the listed files"); StringBuilderPool.Instance.Release(text); } private void AppendUnSharedFilePaths(StringBuilder text, int maxCount) { text.Append("Data resembling UnShared Transition Assets has been detected in the following files:"); var count = Math.Min(maxCount, _UnSharedFilePaths.Count); for (int i = 0; i < count; i++) { text.Append("\n• ") .Append(_UnSharedFilePaths[i]); } if (count < _UnSharedFilePaths.Count) text.Append("\n• And ") .Append(_UnSharedFilePaths.Count - count) .Append(" more (see the log)."); text.Append( "\n\nThe UnShared system has been removed so this data has been modified" + " to the format of a direct reference to a Transition Asset." + $"\n\nFor example, if you had a ClipTransitionAsset.UnShared field" + $" and you changed it to a direct ClipTransitionAsset" + $" then this modification should allow the field to keep its referenced asset." + $"\n\nThis may have falsely identified unrelated data which looks similar" + $" so please review the listed files to check your references."); } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Transition Assets /************************************************************************************************************************/ private const string AnimancerTransitionAssetGUID = "c5a8877f26e7a6a43aaf06fade1a064a"; private static readonly string[] RemovedTransitionAssetGUIDs = { "65baa284d24adb24b90b39482364509c",// ClipTransitionAsset. "6cb514e38f7bd084383a7355a6273a33",// ControllerTransitionAsset. "15ed7dccdb910ec4896f03f7cc2ede35",// Float1ControllerTransitionAsset. "f283fbe305b90dc489b5dec987ecad09",// Float2ControllerTransitionAsset. "435c1d26374dbbb498fa45fe394dfe10",// Float3ControllerTransitionAsset. "a472a4806da7f9147a4a5cd7eee170df",// LinearMixerTransitionAsset. "9785324053a1e39449b4e579bfb71f76",// ManualMixerTransitionAsset. "8a2d01d4f425b3848938f199392c9afb",// MixerTransition2DAsset. "5415cf2115901c345af7680b044d4604",// PlayableAssetTransitionAsset. }; /// [Animancer v8.0] /// Removed the classes derived from /// for each individual transition type except /// since its [] can hold any of them without changing the asset type. /// private void MigrateTransitionAssets(ref string fileText) { const string ScriptReferencePrefix = "m_Script: {fileID: 11500000, guid: ", ScriptReferenceSuffix = ", type: 3}"; var index = 0; while (index < fileText.Length) { index = fileText.IndexOf(ScriptReferencePrefix, index); if (index < 0) return; index += ScriptReferencePrefix.Length; var guidLength = AnimancerTransitionAssetGUID.Length; var guidEnd = index + guidLength; if (guidEnd >= fileText.Length) return; for (int i = 0; i < RemovedTransitionAssetGUIDs.Length; i++) { if (string.CompareOrdinal(fileText, index, RemovedTransitionAssetGUIDs[i], 0, guidLength) == 0) { fileText = $"{fileText[..index]}{AnimancerTransitionAssetGUID}{fileText[guidEnd..]}"; break; } } index = guidEnd + ScriptReferenceSuffix.Length; } } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Utilities /************************************************************************************************************************/ private static int CountConsecutiveCharacters( string text, int start, int direction, char character) { var count = 0; while (start >= 0 && start < text.Length) { if (text[start] == character) { count++; start += direction; } else { break; } } return count; } /************************************************************************************************************************/ private static bool SubstringEquals( string text, int start, string substring) => string.CompareOrdinal( text, start, substring, 0, substring.Length) == 0; /************************************************************************************************************************/ private static readonly System.Random Random = new(); private static long RandomLong() => (uint)Random.Next() | ((long)Random.Next() << 32); /************************************************************************************************************************/ private static void AddSerializedReference( ref string text, int index, long id, Type type, string reference) { const string ObjectHeader = "\n--- !u!"; var start = text.LastIndexOf(ObjectHeader, index) + 1; var end = text.IndexOf(ObjectHeader, start) + 1; if (end <= 0) end = text.Length; var newText = StringBuilderPool.Instance.Acquire(); newText.Append(text, 0, end); if (text[^1] != '\n') newText.Append('\n'); const string ReferencesHeader = " references:\n"; const string VersionHeader = " version: 2\n"; // If the references block is missing, add it. var referencesIndex = text.LastIndexOf("\n" + ReferencesHeader, end, end - start); if (referencesIndex < 0) { newText.Append(ReferencesHeader + VersionHeader + " RefIds:\n"); } else { // Or if it exists but has the wrong version, complain. if (!SubstringEquals(text, referencesIndex + ReferencesHeader.Length + 1, VersionHeader)) Debug.LogWarning("Unknown serialized reference format. Expected version 2."); } newText.Append(" - rid: "); newText.Append(id); newText.Append("\n type: {class: "); newText.Append(type.Name); newText.Append(", ns: "); newText.Append(type.Namespace); newText.Append(", asm: "); newText.Append(type.Assembly.GetName().Name); newText.Append("}\n"); newText.Append(" data:\n"); newText.Append(reference); newText.Append(text, end, text.Length - end); text = newText.ReleaseToString(); } /************************************************************************************************************************/ private static string GetRelativeFilePath(string filePath) { var currentDirectory = Environment.CurrentDirectory; if (filePath.StartsWith(currentDirectory)) filePath = filePath[(currentDirectory.Length + 1)..]; return filePath; } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Example Data /************************************************************************************************************************/ // Old Transition Asset Example /************************************************************************************************************************/ /* %YAML 1.1 %TAG !u! tag:unity3d.com,2011: --- !u!114 &11400000 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 0} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 65baa284d24adb24b90b39482364509c, type: 3} m_Name: New Clip Transition Asset m_EditorClassIdentifier: _Transition: rid: 2481247569047191554 references: version: 2 RefIds: - rid: 2481247569047191554 type: {class: ClipTransition, ns: Animancer, asm: Animancer} data: _FadeDuration: 0.25 _Events: _NormalizedTimes: - 0.653912 - NaN _Callbacks: - m_PersistentCalls: m_Calls: - m_Target: {fileID: 11400000} m_TargetAssemblyTypeName: UnityEngine.Object, UnityEngine m_MethodName: set_name m_Mode: 5 m_Arguments: m_ObjectArgument: {fileID: 0} m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine m_IntArgument: 0 m_FloatArgument: 0 m_StringArgument: m_BoolArgument: 0 m_CallState: 2 - m_Target: {fileID: 11400000} m_TargetAssemblyTypeName: UnityEngine.Object, UnityEngine m_MethodName: set_name m_Mode: 5 m_Arguments: m_ObjectArgument: {fileID: 0} m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine m_IntArgument: 0 m_FloatArgument: 0 m_StringArgument: SecondCall m_BoolArgument: 0 m_CallState: 2 - m_PersistentCalls: m_Calls: - m_Target: {fileID: 11400000} m_TargetAssemblyTypeName: UnityEngine.Object, UnityEngine m_MethodName: set_name m_Mode: 5 m_Arguments: m_ObjectArgument: {fileID: 0} m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine m_IntArgument: 0 m_FloatArgument: 0 m_StringArgument: Ended m_BoolArgument: 0 m_CallState: 2 _Names: - Fire _Clip: {fileID: 7400000, guid: c9ffd254c8ebfae4fbe97ac489238d8d, type: 2} _Speed: 1 _NormalizedStartTime: 0 */ /************************************************************************************************************************/ // New Transition Asset Example /************************************************************************************************************************/ /* %YAML 1.1 %TAG !u! tag:unity3d.com,2011: --- !u!114 &11400000 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 0} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 65baa284d24adb24b90b39482364509c, type: 3} m_Name: New Clip Transition Asset m_EditorClassIdentifier: _Transition: rid: 2481247569047191554 references: version: 2 RefIds: - rid: 2481247569047191554 type: {class: ClipTransition, ns: Animancer, asm: Kybernetik.Animancer} data: _FadeDuration: 0.25 _Speed: 1 _Events: _NormalizedTimes: - 0.653912 - NaN _Callbacks: - rid: 2481247573131657217 - rid: 2481247573131657219 _Names: - {fileID: 11400000, guid: f0b72c8b47f88b2449a2300915f08c72, type: 2} _Clip: {fileID: 7400000, guid: c9ffd254c8ebfae4fbe97ac489238d8d, type: 2} _NormalizedStartTime: 0 - rid: 2481247573131657217 type: {class: UnityEvent, ns: Animancer, asm: Kybernetik.Animancer} data: m_PersistentCalls: m_Calls: - m_Target: {fileID: 11400000} m_TargetAssemblyTypeName: UnityEngine.Object, UnityEngine m_MethodName: set_name m_Mode: 5 m_Arguments: m_ObjectArgument: {fileID: 0} m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine m_IntArgument: 0 m_FloatArgument: 0 m_StringArgument: m_BoolArgument: 0 m_CallState: 2 - m_Target: {fileID: 11400000} m_TargetAssemblyTypeName: UnityEngine.Object, UnityEngine m_MethodName: set_name m_Mode: 5 m_Arguments: m_ObjectArgument: {fileID: 0} m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine m_IntArgument: 0 m_FloatArgument: 0 m_StringArgument: SecondCall m_BoolArgument: 0 m_CallState: 2 - rid: 2481247573131657219 type: {class: UnityEvent, ns: Animancer, asm: Kybernetik.Animancer} data: m_PersistentCalls: m_Calls: - m_Target: {fileID: 11400000} m_TargetAssemblyTypeName: UnityEngine.Object, UnityEngine m_MethodName: set_name m_Mode: 5 m_Arguments: m_ObjectArgument: {fileID: 0} m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine m_IntArgument: 0 m_FloatArgument: 0 m_StringArgument: Ended m_BoolArgument: 0 m_CallState: 2 */ /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ } } #endif