using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using UnityEditor.Animations;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Rendering;
using IMaterial = UnityEditor.Rendering.UpgradeUtility.IMaterial;
using UID = UnityEditor.Rendering.UpgradeUtility.UID;
namespace UnityEditor.Rendering
{
///
/// A class containing static methods for updating assets with bindings for properties.
///
///
/// Animation clips store bindings for material properties by path name, but don't know whether those properties exist on their dependents.
/// Because property names did not change uniformly in the material/shader upgrade process, it is not possible to patch path names indiscriminately.
/// This class provides utilities for discovering how clips are used, so users can make decisions about whether or not to then update them.
/// It has the limitation that it only knows about:
/// - Clips that are directly referenced by an component
/// - Clips referenced by an used by an component
/// - Clips that are sub-assets of a used by a component with a single binding
/// It does not know about clips that might be referenced in other ways for run-time reassignment.
/// Recommended usage is to call from a menu item callback.
/// The utility can also provide faster, more reliable results if it knows what was used to upgrade specific materials.
///
static partial class AnimationClipUpgrader
{
static readonly Regex k_MatchMaterialPropertyName = new Regex(@"material.(\w+)(\.\w+)?", RegexOptions.Compiled);
///
/// Determines whether the specified is for a material property.
///
/// Internal only for testability.
/// An .
/// true if the binding is for a material property; false otherwise.
internal static bool IsMaterialBinding(EditorCurveBinding b) =>
(b.type?.IsSubclassOf(typeof(Renderer)) ?? false)
&& !string.IsNullOrEmpty(b.propertyName)
&& k_MatchMaterialPropertyName.IsMatch(b.propertyName);
static readonly IReadOnlyCollection k_ColorAttributeSuffixes =
new HashSet(new[] { ".r", ".g", ".b", ".a" });
///
/// Infer a shader property name and type from an .
///
/// Internal only for testability.
/// A binding presumed to map to a material property. (See also .)
/// A shader property name, and a guess of what type of shader property it targets.
internal static (string Name, ShaderPropertyType Type) InferShaderProperty(EditorCurveBinding binding)
{
var match = k_MatchMaterialPropertyName.Match(binding.propertyName);
var propertyName = match.Groups[1].Value;
var propertyType = match.Groups[2].Value;
return (propertyName,
k_ColorAttributeSuffixes.Contains(propertyType) ? ShaderPropertyType.Color : ShaderPropertyType.Float);
}
///
/// Gets asset data for all clip assets at the specified paths, which contain bindings for material properties.
/// (See also and .)
///
/// Paths to assets containing .
///
/// Lookup table mapping to its asset path, bindings, property rename table, and usage.
/// (Use to initialize rename table and usage.)
///
internal static IDictionary<
IAnimationClip,
(ClipPath Path, EditorCurveBinding[] Bindings, SerializedShaderPropertyUsage Usage, IDictionary PropertyRenames)
> GetAssetDataForClipsFiltered(
IEnumerable clipPaths
)
{
var result = new Dictionary<
IAnimationClip,
(ClipPath Path, EditorCurveBinding[] Bindings, SerializedShaderPropertyUsage Usage, IDictionary PropertyRenames)
>();
foreach (var clipPath in clipPaths)
{
foreach (var asset in AssetDatabase.LoadAllAssetsAtPath(clipPath))
{
if (!(asset is AnimationClip clip))
continue;
var bindings = AnimationUtility.GetCurveBindings(clip).Where(IsMaterialBinding).ToArray();
if (bindings.Length == 0)
continue;
result[(AnimationClipProxy)clip] =
(clipPath, bindings, SerializedShaderPropertyUsage.Unknown, new Dictionary());
}
}
return result;
}
///
/// Get dependency mappings between and their dependents.
///
/// Paths to clips to consider. (See also .)
/// Paths to assets to consider.
/// Mapping of clip paths to paths of their dependents.
/// Mapping of asset paths to their clip dependencies.
/// The type of asset path.
internal static void GetClipDependencyMappings(
IEnumerable clips,
IEnumerable assets,
out IReadOnlyDictionary> clipToDependentAssets,
out IReadOnlyDictionary> assetToClipDependencies
) where T : struct, IAssetPath
{
// ensure there are no duplicate keys
clips = new HashSet(clips);
assets = new HashSet(assets);
// create mutable builders
var clipsDependentsBuilder = clips.ToDictionary(c => c, c => new HashSet());
var assetsBuilder = new Dictionary>();
// build dependency tables
foreach (var asset in assets)
{
assetsBuilder[asset] = new HashSet();
foreach (var dependencyPath in AssetDatabase.GetDependencies(asset.Path))
{
if (!clipsDependentsBuilder.TryGetValue(dependencyPath, out var dependents))
continue;
dependents.Add(asset);
assetsBuilder[asset].Add(dependencyPath);
}
}
// return readonly results
clipToDependentAssets =
clipsDependentsBuilder.ToDictionary(kv => kv.Key, kv => kv.Value as IReadOnlyCollection);
assetToClipDependencies =
assetsBuilder.ToDictionary(kv => kv.Key, kv => kv.Value as IReadOnlyCollection);
}
// reusable buffers
static readonly List s_AnimationBuffer = new List(8);
static readonly List s_AnimatorBuffer = new List(8);
static readonly List s_CustomAnimationBuffer = new List(8);
static readonly List s_PlayableDirectorBuffer = new List(8);
///
/// Get information about a clip's usage among its dependent scenes to determine whether or not it should be upgraded.
///
///
/// A table mapping clip asset paths, to asset paths of their dependent prefabs.
/// (See .)
///
///
/// A table mapping prefab asset paths, to asset paths of their clip dependencies.
/// (See .)
///
///
/// A table mapping clips to other data about them. (See also .)
///
///
/// A table of new shader names and all known upgrade paths to them in the target pipeline.
/// (See also .)
///
///
/// Optional table of materials known to have gone through a specific upgrade path.
///
///
/// Optional functor to display a progress bar.
///
internal static void GatherClipsUsageInDependentPrefabs(
IReadOnlyDictionary> clipDependents,
// TODO: right now, clip dependencies are gathered in Animation/Animator, so this may not be needed...
IReadOnlyDictionary> assetDependencies,
IDictionary<
IAnimationClip,
(ClipPath Path, EditorCurveBinding[] Bindings, SerializedShaderPropertyUsage Usage, IDictionary PropertyRenames)
> clipData,
IReadOnlyDictionary> allUpgradePathsToNewShaders,
IReadOnlyDictionary upgradePathsUsedByMaterials = default,
Func progressFunctor = null
)
{
int clipIndex = 0;
int totalNumberOfClips = clipDependents.Count;
// check all dependents for usage
foreach (var kv in clipDependents)
{
float currentProgress = (float)++clipIndex / totalNumberOfClips;
if (progressFunctor != null && progressFunctor($"({clipIndex} of {totalNumberOfClips}) {kv.Key.Path}", currentProgress))
break;
foreach (var prefabPath in kv.Value)
{
var go = AssetDatabase.LoadAssetAtPath(prefabPath);
GatherClipsUsageForGameObject(go, clipData, allUpgradePathsToNewShaders, upgradePathsUsedByMaterials);
}
}
}
///
/// Get information about a clip's usage among its dependent scenes to determine whether or not it should be upgraded.
///
///
/// Because this method will open scenes to search for usages, it is recommended you first prompt for user input.
/// It is also a good idea to first call to generate usage data.
/// Clips that are already known to be unsafe for upgrading based on their prefab usage can be skipped here.
///
///
/// A table mapping clip asset paths, to asset paths of their dependent scenes.
/// (See .)
///
///
/// A table mapping scene asset paths, to asset paths of their clip dependencies.
/// (See .)
///
///
/// A table mapping clips to other data about them. (See also .)
///
///
/// A table of new shader names and all known upgrade paths to them in the target pipeline.
/// (See also .)
///
///
/// Optional table of materials known to have gone through a specific upgrade path.
///
///
/// Optional functor to display a progress bar.
///
internal static void GatherClipsUsageInDependentScenes(
IReadOnlyDictionary> clipDependents,
// TODO: right now, clip dependencies are gathered in Animation/Animator, so this may not be needed...
IReadOnlyDictionary> assetDependencies,
IDictionary<
IAnimationClip,
(ClipPath Path, EditorCurveBinding[] Bindings, SerializedShaderPropertyUsage Usage, IDictionary
PropertyRenames)
> clipData,
IReadOnlyDictionary> allUpgradePathsToNewShaders,
IReadOnlyDictionary upgradePathsUsedByMaterials = default,
Func progressFunctor = null
)
{
int clipIndex = 0;
int totalNumberOfClips = clipDependents.Count;
// check all dependents for usage
foreach (var kv in clipDependents)
{
float currentProgress = (float)++clipIndex / totalNumberOfClips;
if (progressFunctor != null && progressFunctor($"({clipIndex} of {totalNumberOfClips}) {kv.Key.Path}", currentProgress))
break;
foreach (var scenePath in kv.Value)
{
var scene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
foreach (var go in scene.GetRootGameObjects())
GatherClipsUsageForGameObject(go, clipData, allUpgradePathsToNewShaders, upgradePathsUsedByMaterials);
}
}
}
///
/// Update usage information about the specified clips in the clip data table.
///
/// A prefab, or a in a scene.
///
/// A table mapping clips to other data about them. (See also .)
///
///
/// A table of new shader names and all known upgrade paths to them in the target pipeline.
/// (See also .)
///
///
/// Optional table of materials known to have gone through a specific upgrade path.
///
static void GatherClipsUsageForGameObject(
GameObject go,
IDictionary<
IAnimationClip,
(ClipPath Path, EditorCurveBinding[] Bindings, SerializedShaderPropertyUsage Usage, IDictionary PropertyRenames)
> clipData,
IReadOnlyDictionary> allUpgradePathsToNewShaders,
IReadOnlyDictionary upgradePathsUsedByMaterials = default
)
{
go.GetComponentsInChildren(true, s_AnimationBuffer);
go.GetComponentsInChildren(true, s_AnimatorBuffer);
go.GetComponentsInChildren(true, s_CustomAnimationBuffer);
// first check clip usage among GameObjects with legacy Animation
var gameObjects = new HashSet(s_AnimationBuffer.Select(a => a.gameObject)
.Union(s_AnimatorBuffer.Select(a => a.gameObject))
.Union(s_CustomAnimationBuffer.Where(a => a is Component).Select(a => ((Component)a).gameObject)));
foreach (var gameObject in gameObjects)
{
var clips = AnimationUtility.GetAnimationClips(gameObject).Select(clip => (IAnimationClip)(AnimationClipProxy)clip);
GatherClipsUsageForAnimatedHierarchy(
gameObject.transform, clips, clipData, allUpgradePathsToNewShaders, upgradePathsUsedByMaterials
);
}
// next check clip usage among GameObjects with PlayableDirector
go.GetComponentsInChildren(true, s_PlayableDirectorBuffer);
foreach (var playableDirector in s_PlayableDirectorBuffer)
{
var playableAsset = playableDirector.playableAsset;
if (playableAsset == null)
continue;
var assetPath = AssetDatabase.GetAssetPath(playableAsset);
// get all clip sub-assets
var clips = new HashSet(
AssetDatabase.LoadAllAssetsAtPath(assetPath)
.Where(asset => asset is AnimationClip)
.Select(asset => (IAnimationClip)(AnimationClipProxy)(asset as AnimationClip))
);
// get all clip dependency-assets
// this will not handle nested clips in FBX like assets, but these are less likely to be editable
clips.UnionWith(AssetDatabase.GetDependencies(assetPath)
.Select(AssetDatabase.LoadAssetAtPath)
.Where(asset => asset is AnimationClip)
.Select(asset => (IAnimationClip)(AnimationClipProxy)asset));
// check if the value of a binding is an animator, and examines clip usage relative to it
// this is imprecise, but is suitable to catch the majority of cases (i.e., a single animator binding)
using (var so = new SerializedObject(playableDirector))
{
var clipsProp = so.FindProperty("m_SceneBindings");
for (int i = 0, count = clipsProp.arraySize; i < count; ++i)
{
var elementProp = clipsProp.GetArrayElementAtIndex(i);
var value = elementProp.FindPropertyRelative("value");
if (value.objectReferenceValue is Animator animator)
{
GatherClipsUsageForAnimatedHierarchy(
animator.transform, clips, clipData, allUpgradePathsToNewShaders, upgradePathsUsedByMaterials
);
}
}
}
}
// release UnityObject references
s_AnimationBuffer.Clear();
s_AnimatorBuffer.Clear();
s_CustomAnimationBuffer.Clear();
s_PlayableDirectorBuffer.Clear();
}
// reusable buffers
static readonly List s_RendererBuffer = new List(8);
static readonly Dictionary Materials)> s_RenderersByPath =
new Dictionary Materials)>();
///
/// Update usage information about the specified clips in the clip data table.
///
/// The root of the animated hierarchy (i.e., object with Animation or Animator).
/// Collection of animation clips
///
/// A table mapping clips to other data about them. (See also .)
///
///
/// A table of new shader names and all known upgrade paths to them in the target pipeline.
/// (See also .)
///
///
/// Optional table of materials known to have gone through a specific upgrade path.
///
static void GatherClipsUsageForAnimatedHierarchy(
Transform root,
IEnumerable clips,
IDictionary<
IAnimationClip,
(ClipPath Path, EditorCurveBinding[] Bindings, SerializedShaderPropertyUsage Usage, IDictionary PropertyRenames)
> clipData,
IReadOnlyDictionary> allUpgradePathsToNewShaders,
IReadOnlyDictionary upgradePathsUsedByMaterials
)
{
// TODO: report paths of specific assets that contribute to problematic results?
// find all renderers in the animated hierarchy
root.GetComponentsInChildren(true, s_RendererBuffer);
foreach (var renderer in s_RendererBuffer)
{
var path = AnimationUtility.CalculateTransformPath(renderer.transform, root);
var m = ListPool.Get();
var r = (RendererProxy)renderer;
r.GetSharedMaterials(m);
s_RenderersByPath[path] = (r, m);
}
// if there are any renderers, check all clips for usage
if (s_RendererBuffer.Count > 0)
{
foreach (var clip in clips)
GatherClipUsage(clip, clipData, s_RenderersByPath, allUpgradePathsToNewShaders, upgradePathsUsedByMaterials);
}
// release UnityObject references
s_RendererBuffer.Clear();
foreach (var (_, materials) in s_RenderersByPath.Values)
ListPool.Release(materials);
s_RenderersByPath.Clear();
}
///
/// Update usage information about the specified clip in the clip data table.
///
///
/// This method works by looking at shaders used by materials assigned to the specified renderers.
/// Usage and property renames for the clip are updated, if a binding in the clip matches an upgrader.
/// Internal only for testability.
///
/// An animation clip.
///
/// A table mapping clips to other data about them. (See also .)
///
///
/// A table mapping transform paths of renderers to lists of the materials they use.
///
///
/// A table of new shader names and all known upgrade paths to them in the target pipeline.
/// (See also .)
///
///
/// Optional table of materials known to have gone through a specific upgrade path.
///
internal static void GatherClipUsage(
IAnimationClip clip,
IDictionary<
IAnimationClip,
(ClipPath Path, EditorCurveBinding[] Bindings, SerializedShaderPropertyUsage Usage, IDictionary PropertyRenames)
> clipData,
IReadOnlyDictionary Materials)> renderersByPath,
IReadOnlyDictionary> allUpgradePathsToNewShaders,
IReadOnlyDictionary upgradePathsUsedByMaterials
)
{
// exit if clip is unknown; it may have been filtered at an earlier stage
if (!clipData.TryGetValue(clip, out var data))
return;
// see if any animated material bindings in the clip refer to renderers in animated hierarchy
foreach (var binding in data.Bindings)
{
// skip if binding is not for material, or refers to a nonexistent renderer
if (
!IsMaterialBinding(binding)
|| !renderersByPath.TryGetValue(binding.path, out var rendererData)
) continue;
// determine the shader property name and type from the binding
var shaderProperty = InferShaderProperty(binding);
var renameType = shaderProperty.Type == ShaderPropertyType.Color
? MaterialUpgrader.MaterialPropertyType.Color
: MaterialUpgrader.MaterialPropertyType.Float;
// material property animations apply to all materials, so check shader usage in all of them
foreach (var material in rendererData.Materials)
{
var usage = UpgradeUtility.GetNewPropertyName(
shaderProperty.Name,
material,
renameType,
allUpgradePathsToNewShaders,
upgradePathsUsedByMaterials,
out var newPropertyName
);
// if the property has already been upgraded with a different name, mark the upgrade as ambiguous
if (
usage == SerializedShaderPropertyUsage.UsedByUpgraded
&& data.PropertyRenames.TryGetValue(binding, out var propertyRename)
&& propertyRename != newPropertyName
)
usage |= SerializedShaderPropertyUsage.UsedByAmbiguouslyUpgraded;
data.Usage |= usage;
data.PropertyRenames[binding] = newPropertyName;
}
}
clipData[clip] = data;
}
///
/// Upgrade the specified clips using the associated property rename table.
///
///
/// A table mapping clips to property renaming tables that can be safely applied to their bindings.
///
/// Do not upgrade clips that have any of these flags set.
/// Collector for all clips that are upgraded.
/// Collector for all clips that are not upgraded.
/// Optional functor to display a progress bar.
internal static void UpgradeClips(
IDictionary PropertyRenames)> clipsToUpgrade,
SerializedShaderPropertyUsage excludeFlags,
HashSet<(IAnimationClip Clip, ClipPath Path, SerializedShaderPropertyUsage Usage)> upgraded,
HashSet<(IAnimationClip Clip, ClipPath Path, SerializedShaderPropertyUsage Usage)> notUpgraded,
Func progressFunctor = null
)
{
upgraded.Clear();
notUpgraded.Clear();
int clipIndex = 0;
int totalNumberOfClips = clipsToUpgrade.Count;
foreach (var kv in clipsToUpgrade)
{
float currentProgress = (float)++clipIndex / totalNumberOfClips;
if (progressFunctor != null && progressFunctor($"({clipIndex} of {totalNumberOfClips}) {kv.Value.Path.Path}", currentProgress))
break;
if (kv.Value.Usage == SerializedShaderPropertyUsage.Unknown || (kv.Value.Usage & excludeFlags) != 0)
{
notUpgraded.Add((kv.Key, kv.Value.Path, kv.Value.Usage));
continue;
}
var renames = kv.Value.PropertyRenames;
var bindings = kv.Key.GetCurveBindings().Where(IsMaterialBinding).ToArray();
if (bindings.Length > 0)
{
var newBindings = new EditorCurveBinding[bindings.Length];
for (int i = 0; i < bindings.Length; ++i)
{
var binding = bindings[i];
newBindings[i] = binding;
if (renames.TryGetValue(binding, out var newName))
newBindings[i].propertyName = k_MatchMaterialPropertyName.Replace(newBindings[i].propertyName, $"material.{newName}$2");
}
kv.Key.ReplaceBindings(bindings, newBindings);
}
upgraded.Add((kv.Key, kv.Value.Path, kv.Value.Usage));
}
}
///
/// A function to call from a menu item callback, which will upgrade all in the project.
///
/// All for the current render pipeline.
///
/// Optional mapping of materials to upgraders they are known to have used.
/// Without this mapping, the method makes inferences about how a material might have been upgraded.
/// Making these inferences is slower and possibly not sensitive to ambiguous upgrade paths.
///
///
/// Optional flags to filter out clips that are used in ways that are not safe for upgrading.
///
public static void DoUpgradeAllClipsMenuItem(
IEnumerable allUpgraders,
string progressBarName,
IReadOnlyDictionary knownUpgradePaths = default,
SerializedShaderPropertyUsage filterFlags = ~(SerializedShaderPropertyUsage.UsedByUpgraded | SerializedShaderPropertyUsage.UsedByNonUpgraded)
)
{
var clipPaths = AssetDatabase.FindAssets("t:AnimationClip")
.Select(p => (ClipPath)AssetDatabase.GUIDToAssetPath(p))
.ToArray();
DoUpgradeClipsMenuItem(clipPaths, allUpgraders, progressBarName, knownUpgradePaths, filterFlags);
}
static void DoUpgradeClipsMenuItem(
ClipPath[] clipPaths,
IEnumerable allUpgraders,
string progressBarName,
IReadOnlyDictionary upgradePathsUsedByMaterials,
SerializedShaderPropertyUsage filterFlags
)
{
// exit early if no clips
if (clipPaths?.Length == 0)
return;
// display dialog box
var dialogMessage = L10n.Tr(
"Upgrading Material curves in AnimationClips assumes you have already upgraded Materials and shaders as needed. " +
"It also requires loading assets that use clips to inspect their usage, which can be a slow process. " +
"Do you want to proceed?"
);
if (!EditorUtility.DisplayDialog(
L10n.Tr("Upgrade AnimationClips"),
dialogMessage,
DialogText.proceed,
DialogText.cancel))
return;
// only include scene paths if user requested it
var prefabPaths = AssetDatabase.FindAssets("t:Prefab")
.Select(p => (PrefabPath)AssetDatabase.GUIDToAssetPath(p))
.ToArray();
var scenePaths = AssetDatabase.FindAssets("t:Scene")
.Select(p => (ScenePath)AssetDatabase.GUIDToAssetPath(p))
.ToArray();
// retrieve clip assets with material animation
var clipData = GetAssetDataForClipsFiltered(clipPaths);
const float kGatherInPrefabsTotalProgress = 0.33f;
const float kGatherInScenesTotalProgress = 0.66f;
const float kUpgradeClipsTotalProgress = 1f;
// create table mapping all upgrade paths to new shaders
var allUpgradePathsToNewShaders = UpgradeUtility.GetAllUpgradePathsToShaders(allUpgraders);
// retrieve interdependencies with prefabs to figure out which clips can be safely upgraded
GetClipDependencyMappings(clipPaths, prefabPaths, out var clipPrefabDependents, out var prefabDependencies);
GatherClipsUsageInDependentPrefabs(
clipPrefabDependents, prefabDependencies, clipData, allUpgradePathsToNewShaders, upgradePathsUsedByMaterials,
(info, progress) => EditorUtility.DisplayCancelableProgressBar(progressBarName, $"Gathering from prefabs {info}", Mathf.Lerp(0f, kGatherInPrefabsTotalProgress, progress))
);
if (EditorUtility.DisplayCancelableProgressBar(progressBarName, "", kGatherInPrefabsTotalProgress))
{
EditorUtility.ClearProgressBar();
return;
}
// if any scenes should be considered, do the same for clips used by scenes
if (scenePaths.Any())
{
GetClipDependencyMappings(clipPaths, scenePaths, out var clipSceneDependents, out var sceneDependencies);
GatherClipsUsageInDependentScenes(
clipSceneDependents, sceneDependencies, clipData, allUpgradePathsToNewShaders, upgradePathsUsedByMaterials,
(info, progress) => EditorUtility.DisplayCancelableProgressBar(progressBarName, $"Gathering from scenes {info}", Mathf.Lerp(kGatherInPrefabsTotalProgress, kGatherInScenesTotalProgress, progress))
);
}
if (EditorUtility.DisplayCancelableProgressBar(progressBarName, "", kGatherInScenesTotalProgress))
{
EditorUtility.ClearProgressBar();
return;
}
// patch clips that should be upgraded
var upgraded = new HashSet<(IAnimationClip Clip, ClipPath Path, SerializedShaderPropertyUsage Usage)>();
var notUpgraded = new HashSet<(IAnimationClip Clip, ClipPath Path, SerializedShaderPropertyUsage Usage)>();
AssetDatabase.StartAssetEditing();
UpgradeClips(
clipData, filterFlags, upgraded, notUpgraded,
(info, progress) => EditorUtility.DisplayCancelableProgressBar(progressBarName, $"Upgrading clips {info}", Mathf.Lerp(kGatherInScenesTotalProgress, kUpgradeClipsTotalProgress, progress))
);
AssetDatabase.SaveAssets();
AssetDatabase.StopAssetEditing();
EditorUtility.ClearProgressBar();
// report results
if (upgraded.Count > 0)
{
var successes = upgraded.Select(data => $"- {data.Path}: ({data.Usage})");
Debug.Log(
"Upgraded the following clips:\n" +
$"{string.Join("\n", successes)}"
);
}
if (notUpgraded.Count == clipData.Count)
{
Debug.LogWarning("No clips were upgraded. Did you remember to upgrade materials first?");
}
else if (notUpgraded.Count > 0)
{
var errors = notUpgraded.Select(data => $"- {data.Path}: ({data.Usage})");
Debug.LogWarning(
$"Did not modify following clips because they they were used in ways other than {~filterFlags}:\n" +
$"{string.Join("\n", errors)}"
);
}
}
}
}