Use Refactored blueprint decompilation (unfinished code)

ignore the json var and GetLoadPackageResult, will be removed once the method is found

Removed the KismetExtensions.cs file and refactored blueprint decompilation logic in CUE4ParseViewModel.cs to use a new DecompileBlueprintToPseudo method. Updated CUE4ParseExtensions.cs with GetLoadPackageResult helper. Improved C++ syntax highlighting in Cpp.xshd by adding 'default' keyword and enhanced comment rule. Minor formatting and code organization improvements across affected files.
This commit is contained in:
Krowe Moh 2025-07-24 14:36:38 +10:00
parent 046677b875
commit 51541e5ef9
8 changed files with 114 additions and 1308 deletions

View File

@ -3,6 +3,7 @@ using CUE4Parse.FileProvider;
using CUE4Parse.FileProvider.Objects;
using CUE4Parse.UE4.Assets;
using CUE4Parse.UE4.Objects.UObject;
using CUE4Parse.Utils;
using FModel.Settings;
namespace FModel.Extensions;
@ -67,4 +68,19 @@ public static class CUE4ParseExtensions
return result;
}
public static LoadPackageResult GetLoadPackageResult(this IFileProvider provider, string file, string objectName = null)
{
var result = new LoadPackageResult { Package = provider.LoadPackage(file) };
if (result.IsPaginated || (result.Package.HasFlags(EPackageFlags.PKG_ContainsMap) && UserSettings.Default.PreviewWorlds)) // focus on UWorld if it's a map we want to preview
{
result.RequestedIndex = result.Package.GetExportIndex(file.SubstringBeforeLast('.'));
if (objectName != null)
{
result.RequestedIndex = int.TryParse(objectName, out var index) ? index : result.Package.GetExportIndex(objectName);
}
}
return result;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -82,6 +82,7 @@
<Word>friend</Word>
<Word>inline</Word>
<Word>constexpr</Word>
<Word>default</Word>
</Keywords>
<Keywords color="Pointer">
@ -119,6 +120,8 @@
<Rule color="Brace">[\[\]\{\}]</Rule>
<Rule color="Comment">(\/\/.*|\/\*[\s\S]*?\*\/)</Rule>
<!-- Template Functions -->
<Rule color="Function">\b[A-Za-z_][A-Za-z0-9_]*\b(?=&lt;)</Rule>

View File

@ -74,10 +74,12 @@ public class CUE4ParseViewModel : ViewModel
{
private ThreadWorkerViewModel _threadWorkerView => ApplicationService.ThreadWorkerView;
private ApiEndpointViewModel _apiEndpointView => ApplicationService.ApiEndpointView;
private readonly Regex _fnLiveRegex = new(@"^FortniteGame[/\\]Content[/\\]Paks[/\\]",
RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private bool _modelIsOverwritingMaterial;
public bool ModelIsOverwritingMaterial
{
get => _modelIsOverwritingMaterial;
@ -86,6 +88,7 @@ public class CUE4ParseViewModel : ViewModel
public bool IsSnooperOpen => _snooper is { Exists: true, IsVisible: true };
private Snooper _snooper;
public Snooper SnooperViewer
{
get
@ -445,6 +448,7 @@ public class CUE4ParseViewModel : ViewModel
public int LocalizedResourcesCount { get; set; }
public bool LocalResourcesDone { get; set; }
public bool HotfixedResourcesDone { get; set; }
public async Task LoadLocalizedResources()
{
var snapshot = LocalizedResourcesCount;
@ -458,6 +462,7 @@ public class CUE4ParseViewModel : ViewModel
Utils.Typefaces = new Typefaces(this);
}
}
private Task LoadGameLocalizedResources()
{
if (LocalResourcesDone) return Task.CompletedTask;
@ -466,6 +471,7 @@ public class CUE4ParseViewModel : ViewModel
LocalResourcesDone = Provider.TryChangeCulture(Provider.GetLanguageCode(UserSettings.Default.AssetLanguage));
});
}
private Task LoadHotfixedLocalizedResources()
{
if (!Provider.ProjectName.Equals("fortnitegame", StringComparison.OrdinalIgnoreCase) || HotfixedResourcesDone) return Task.CompletedTask;
@ -480,6 +486,7 @@ public class CUE4ParseViewModel : ViewModel
}
private int _virtualPathCount { get; set; }
public Task LoadVirtualPaths()
{
if (_virtualPathCount > 0) return Task.CompletedTask;
@ -698,7 +705,7 @@ public class CUE4ParseViewModel : ViewModel
case "wav":
case "WAV":
case "ogg":
// todo: CSCore.MediaFoundation.MediaFoundationException The byte stream type of the given URL is unsupported. case "aif":
// todo: CSCore.MediaFoundation.MediaFoundationException The byte stream type of the given URL is unsupported. case "aif":
{
var data = Provider.SaveAsset(entry);
SaveAndPlaySound(entry.PathWithoutExtension, entry.Extension, data);
@ -988,271 +995,34 @@ public class CUE4ParseViewModel : ViewModel
var pkg = Provider.LoadPackage(entry);
var outputBuilder = new StringBuilder();
string mypathisapathrealwhathowPath = Path.Combine(
Path.GetDirectoryName(entry.Path)!,
$"{Path.GetFileNameWithoutExtension(entry.Path)}.o.uasset"
);
string idkhowtogetitwithoutthis = string.Empty;
try
{
var whatisthisforealreal = Provider.GetLoadPackageResult(mypathisapathrealwhathowPath);
idkhowtogetitwithoutthis = JsonConvert.SerializeObject(whatisthisforealreal.GetDisplayData(false), Formatting.Indented);
}
catch (Exception e) {}
var cpp = string.Empty;
for (var i = 0; i < pkg.ExportMapLength; i++)
{
var pointer = new FPackageIndex(pkg, i + 1).ResolvedObject;
if (pointer?.Object is null)
if (pointer?.Object is null && pointer.Class?.Object?.Value is null)
continue;
var dummy = ((AbstractUePackage) pkg).ConstructObject(pointer.Class?.Object?.Value as UStruct, pkg);
if (dummy is not UClass || pointer.Object.Value is not UClass blueprint)
continue;
var typePrefix = blueprint?.SuperStruct.Load<UStruct>().GetPrefix();
var modifierStr = blueprint.Flags.HasAnyFlags(EObjectFlags.RF_Public) ? "public" : "private";
outputBuilder.AppendLine($"class {typePrefix}{blueprint.Name} : {modifierStr} {typePrefix}{blueprint?.SuperStruct?.Name ?? string.Empty}\n{{\n{modifierStr}:");
if (!blueprint.ClassDefaultObject.TryLoad(out var bpObject))
continue;
var strings = new List<string>();
foreach (var property in bpObject.Properties)
{
var propertyName = property.Name.ToString();
var propertyValue = property.Tag?.GenericValue;
strings.Add(propertyName);
string placeholder = $"{propertyName}fmodelholder"; // spelling mistake is intended
void ShouldAppend(string value)
{
if (outputBuilder.ToString().Contains(placeholder))
{
outputBuilder.Replace(placeholder, value);
}
else
{
outputBuilder.AppendLine($"\t{KismetExtensions.GetPropertyType(propertyValue)} {propertyName.Replace(" ", "")} = {value};");
}
}
string GetLineOfText(object value)
{
string text = null;
switch (value)
{
case FScriptStruct structTag:
switch (structTag.StructType)
{
case FVector vector:
text = $"FVector({vector.X}, {vector.Y}, {vector.Z})";
break;
case FGuid guid:
text = $"FGuid({guid.A}, {guid.B}, {guid.C}, {guid.D})";
break;
case TIntVector3<int> vector3:
text = $"FVector({vector3.X}, {vector3.Y}, {vector3.Z})";
break;
case TIntVector3<float> floatVector3:
text = $"FVector({floatVector3.X}, {floatVector3.Y}, {floatVector3.Z})";
break;
case TIntVector2<float> floatVector2:
text = $"FVector2D({floatVector2.X}, {floatVector2.Y})";
break;
case FVector2D vector2d:
text = $"FVector2D({vector2d.X}, {vector2d.Y})";
break;
case FRotator rotator:
text = $"FRotator({rotator.Pitch}, {rotator.Yaw}, {rotator.Roll})";
break;
case FLinearColor linearColor:
text = $"FLinearColor({linearColor.R}, {linearColor.G}, {linearColor.B}, {linearColor.A})";
break;
case FGameplayTagContainer gTag:
text = gTag.GameplayTags.Length switch
{
> 1 => "[\n" + string.Join(",\n", gTag.GameplayTags.Select(tag => $"\t\t\"{tag.TagName}\"")) + "\n\t]",
> 0 => $"\"{gTag.GameplayTags[0].TagName}\"",
_ => "[]"
};
break;
case FStructFallback fallback:
if (fallback.Properties.Count > 0)
{
text = "[\n" + string.Join(",\n", fallback.Properties.Select(p => $"\t\"{GetLineOfText(p)}\"")) + "\n\t]";
}
else
{
text = "[]";
}
break;
}
break;
case UScriptSet:
case UScriptMap:
case UScriptArray:
IEnumerable<string> inner = value switch
{
UScriptSet set => set.Properties.Select(p => $"\t\"{p.GenericValue}\""),
UScriptMap map => map.Properties.Select(kvp => $"\t{{\n\t\t\"{kvp.Key}\": \"{kvp.Value}\"\n\t}}"),
UScriptArray array => array.Properties.Select(p => $"\t\"{GetLineOfText(p)}\""),
_ => throw new ArgumentOutOfRangeException(nameof(value), value, null)
};
text = "[\n" + string.Join(",\n", inner) + "\n\t]";
break;
case FMulticastScriptDelegate multicast:
text = multicast.InvocationList.Length == 0 ? "[]" : $"[{string.Join(", ", multicast.InvocationList.Select(x => $"\"{x.FunctionName}\""))}]";
break;
case bool:
text = value.ToString()?.ToLowerInvariant();
break;
}
return text ?? value.ToString();
}
ShouldAppend(GetLineOfText(propertyValue));
}
foreach (var field in blueprint.ChildProperties)
{
if (field is not FProperty property || strings.Contains(property.Name.Text)) continue;
var propertyName = property.Name.ToString().Replace(" ", "");
var type = KismetExtensions.GetPropertyType(property);
var prefix = "";
switch (property)
{
case FFieldPathProperty pathProp:
prefix = pathProp.PropertyClass.ToString().GetPrefix();
break;
case FObjectProperty objectProp:
prefix = objectProp.PropertyClass.ToString().GetPrefix();
break;
}
outputBuilder.AppendLine($"\t{prefix}{type}{(KismetExtensions.isPointer(property) ? '*' : "")} {propertyName} = {propertyName}fmodelholder;");
}
{
var funcMapOrder = blueprint?.FuncMap?.Keys.Select(fname => fname.ToString()).ToList();
var functions = pkg.ExportsLazy
.Where(e => e.Value is UFunction)
.Select(e => (UFunction) e.Value)
.OrderBy(f =>
{
if (funcMapOrder != null)
{
var functionName = f.Name.ToString();
int index = funcMapOrder.IndexOf(functionName);
return index >= 0 ? index : int.MaxValue;
}
return int.MaxValue;
})
.ThenBy(f => f.Name.ToString())
.ToList();
var jumpCodeOffsetsMap = new Dictionary<string, List<int>>();
foreach (var function in functions.AsEnumerable().Reverse())
{
if (function?.ScriptBytecode == null)
continue;
foreach (var property in function.ScriptBytecode)
{
string label = string.Empty;
int offset = 0;
switch (property.Token)
{
case EExprToken.EX_JumpIfNot:
label = ((EX_JumpIfNot) property).ObjectPath?.ToString()?.Split('.').Last().Split('[')[0];
offset = (int) ((EX_JumpIfNot) property).CodeOffset;
break;
case EExprToken.EX_Jump:
label = ((EX_Jump) property).ObjectPath?.ToString()?.Split('.').Last().Split('[')[0];
offset = (int) ((EX_Jump) property).CodeOffset;
break;
case EExprToken.EX_LocalFinalFunction:
{
EX_FinalFunction op = (EX_FinalFunction) property;
label = op.StackNode?.Name?.Split('.').Last().Split('[')[0];
if (op is { Parameters: [EX_IntConst intConst] })
offset = intConst.Value;
break;
}
}
if (!string.IsNullOrEmpty(label))
{
if (!jumpCodeOffsetsMap.TryGetValue(label, out var list))
jumpCodeOffsetsMap[label] = list = new List<int>();
list.Add(offset);
}
}
}
foreach (var function in functions)
{
string argsList = "";
string returnFunc = "void";
if (function?.ChildProperties != null)
{
foreach (FProperty property in function.ChildProperties)
{
var name = property.Name.ToString();
var plainName = property.Name.PlainText;
var prefix = "";
switch (property)
{
case FFieldPathProperty pathProp:
prefix = pathProp.PropertyClass.ToString().GetPrefix();
break;
case FObjectProperty objectProp:
prefix = objectProp.PropertyClass.ToString().GetPrefix();
break;
}
var type = KismetExtensions.GetPropertyType(property);
var isConst = property.PropertyFlags.HasFlag(EPropertyFlags.ConstParm);
var isOut = property.PropertyFlags.HasFlag(EPropertyFlags.OutParm);
var isEdit = property.PropertyFlags.HasFlag(EPropertyFlags.Edit);
if (plainName == "ReturnValue")
{
returnFunc = $"{(isConst ? "const " : "")}{prefix}{type}{(KismetExtensions.isPointer(property) ? '*' : "")}";
continue;
}
bool uselessIgnore = name.EndsWith("_ReturnValue") || name.StartsWith("CallFunc_") || name.StartsWith("K2Node_") || name.StartsWith("Temp_"); // read variable name
if (uselessIgnore && !isEdit)
continue;
var strippedVerseName = Regex.Replace(name, @"^__verse_0x[0-9A-Fa-f]+_", "");
argsList += $"{(isConst ? "const " : "")}{prefix}{type}{(KismetExtensions.isPointer(property) ? '*' : "")}{(isOut ? '&' : "")} {strippedVerseName}, ";
}
}
argsList = argsList.TrimEnd(',', ' ');
outputBuilder.AppendLine($"\n\t{returnFunc} {function.Name.Replace(" ", "")}({argsList})\n\t{{");
if (function?.ScriptBytecode != null)
{
var jumpCodeOffsets = jumpCodeOffsetsMap.TryGetValue(function.Name, out var list) ? list : new List<int>();
foreach (KismetExpression property in function.ScriptBytecode)
{
KismetExtensions.ProcessExpression(property.Token, property, outputBuilder, jumpCodeOffsets);
}
}
else
{
outputBuilder.Append("\n\t // No Bytecode (Make sure \"Serialize Script Bytecode\" is enabled \n\n");
outputBuilder.Append("\t}\n");
}
}
outputBuilder.Append("\n\n}");
}
cpp += blueprint.DecompileBlueprintToPseudo(idkhowtogetitwithoutthis);
}
var cpp = Regex.Replace(outputBuilder.ToString(), @"\w+fmodelholder", "nullptr");
TabControl.SelectedTab.SetDocumentText(cpp, false, false);
}

View File

@ -4,13 +4,38 @@ using System.ComponentModel;
using System.Linq;
using System.Text.RegularExpressions;
using System.Windows.Data;
using System.Windows.Input;
using CUE4Parse.FileProvider.Objects;
using FModel.Framework;
namespace FModel.ViewModels;
public class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public event EventHandler? CanExecuteChanged;
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
public void Execute(object? parameter) => _execute();
}
public class SearchViewModel : ViewModel
{
public enum ESortSizeMode
{
None,
Ascending,
Descending
}
private string _filterText;
public string FilterText
{
@ -32,6 +57,29 @@ public class SearchViewModel : ViewModel
set => SetProperty(ref _hasMatchCaseEnabled, value);
}
private ESortSizeMode _currentSortSizeMode = ESortSizeMode.None;
public ESortSizeMode CurrentSortSizeMode
{
get => _currentSortSizeMode;
set => SetProperty(ref _currentSortSizeMode, value);
}
public void CycleSortSizeMode()
{
CurrentSortSizeMode = CurrentSortSizeMode switch
{
ESortSizeMode.None => ESortSizeMode.Ascending,
ESortSizeMode.Ascending => ESortSizeMode.Descending,
ESortSizeMode.Descending => ESortSizeMode.None,
_ => ESortSizeMode.None
};
RefreshFilter();
}
private RelayCommand? _sortSizeModeCommand;
public ICommand SortSizeModeCommand => _sortSizeModeCommand ??= new RelayCommand(CycleSortSizeMode);
public int ResultsCount => SearchResults?.Count ?? 0;
public RangeObservableCollection<GameFile> SearchResults { get; }
public ICollectionView SearchResultsView { get; }
@ -48,6 +96,14 @@ public class SearchViewModel : ViewModel
SearchResultsView.Filter = e => ItemFilter(e, FilterText.Trim().Split(' '));
else
SearchResultsView.Refresh();
SearchResultsView.SortDescriptions.Clear();
if (CurrentSortSizeMode != ESortSizeMode.None)
SearchResultsView.SortDescriptions.Add(new SortDescription(nameof(GameFile.Size),
CurrentSortSizeMode == ESortSizeMode.Ascending
? ListSortDirection.Ascending
: ListSortDirection.Descending));
}
private bool ItemFilter(object item, IEnumerable<string> filters)
@ -62,4 +118,4 @@ public class SearchViewModel : ViewModel
if (!HasMatchCaseEnabled) o |= RegexOptions.IgnoreCase;
return new Regex(FilterText, o).Match(entry.Path).Success;
}
}
}

View File

@ -70,6 +70,7 @@
<Geometry x:Key="MeshIcon">M1.8 6q-.525 0-.887-.35Q.55 5.3.55 4.8V4q0-1.425 1.012-2.438Q2.575.55 4 .55h.8q.5 0 .85.362.35.363.35.888 0 .5-.35.85T4.8 3H4q-.425 0-.712.287Q3 3.575 3 4v.8q0 .5-.35.85T1.8 6ZM4 23.45q-1.425 0-2.438-1.012Q.55 21.425.55 20v-.8q0-.5.363-.85.362-.35.887-.35.5 0 .85.35t.35.85v.8q0 .425.288.712Q3.575 21 4 21h.8q.5 0 .85.35t.35.85q0 .525-.35.887-.35.363-.85.363Zm15.2 0q-.5 0-.85-.363-.35-.362-.35-.887 0-.5.35-.85t.85-.35h.8q.425 0 .712-.288Q21 20.425 21 20v-.8q0-.5.35-.85t.85-.35q.525 0 .888.35.362.35.362.85v.8q0 1.425-1.012 2.438Q21.425 23.45 20 23.45ZM22.2 6q-.5 0-.85-.35T21 4.8V4q0-.425-.288-.713Q20.425 3 20 3h-.8q-.5 0-.85-.35T18 1.8q0-.525.35-.888.35-.362.85-.362h.8q1.425 0 2.438 1.012Q23.45 2.575 23.45 4v.8q0 .5-.362.85-.363.35-.888.35ZM12 17.35l1-.575v-4.1l3.55-2.075V9.425l-1-.575L12 10.925 8.45 8.85l-1 .575V10.6L11 12.675v4.1Zm-1.325 2.325-4.55-2.65q-.625-.35-.975-.963-.35-.612-.35-1.337V9.45q0-.725.35-1.337.35-.613.975-.963l4.55-2.65Q11.3 4.15 12 4.15t1.325.35l4.55 2.65q.625.35.975.963.35.612.35 1.337v5.275q0 .725-.35 1.337-.35.613-.975.963l-4.55 2.65q-.625.35-1.325.35t-1.325-.35Z</Geometry>
<Geometry x:Key="ArchiveIcon">M3.5 1.75v11.5c0 .09.048.173.126.217a.75.75 0 0 1-.752 1.298A1.748 1.748 0 0 1 2 13.25V1.75C2 .784 2.784 0 3.75 0h5.586c.464 0 .909.185 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0 1 12.25 15h-.5a.75.75 0 0 1 0-1.5h.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177L9.513 1.573a.25.25 0 0 0-.177-.073H7.25a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5h-3a.25.25 0 0 0-.25.25Zm3.75 8.75h.5c.966 0 1.75.784 1.75 1.75v3a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1-.75-.75v-3c0-.966.784-1.75 1.75-1.75ZM6 5.25a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 6 5.25Zm.75 2.25h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM8 6.75A.75.75 0 0 1 8.75 6h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 8 6.75ZM8.75 3h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM8 9.75A.75.75 0 0 1 8.75 9h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 8 9.75Zm-1 2.5v2.25h1v-2.25a.25.25 0 0 0-.25-.25h-.5a.25.25 0 0 0-.25.25Z</Geometry>
<Geometry x:Key="GitHubIcon">M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z</Geometry>
<Geometry x:Key="SortIcon">M8 16H4l6 6V2H8zm6-11v17h2V8h4l-6-6z</Geometry>
<Style x:Key="TabItemFillSpace" TargetType="TabItem" BasedOn="{StaticResource {x:Type TabItem}}">
<Setter Property="Width">

View File

@ -58,6 +58,14 @@
</TextBox.Style>
</TextBox>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button ToolTip="Sort File Sizes" Padding="5" Command="{Binding CUE4Parse.SearchVm.SortSizeModeCommand}" Style="{DynamicResource {x:Static adonisUi:Styles.ToolbarButton}}">
<Viewbox Width="16" Height="16" HorizontalAlignment="Center">
<Canvas Width="24" Height="24">
<Path Fill="{DynamicResource {x:Static adonisUi:Brushes.ForegroundBrush}}" Data="{StaticResource SortIcon}" />
</Canvas>
</Viewbox>
</Button>
<ToggleButton ToolTip="Match Case" Padding="5" IsChecked="{Binding CUE4Parse.SearchVm.HasMatchCaseEnabled}" Style="{DynamicResource {x:Static adonisUi:Styles.ToolbarToggleButton}}">
<Viewbox Width="16" Height="16" HorizontalAlignment="Center">
<Canvas Width="24" Height="24">

View File

@ -38,11 +38,14 @@ public partial class SearchView
MainWindow.YesWeCats.AssetsListName.ItemsSource = null;
var folder = _applicationView.CustomDirectories.GoToCommand.JumpTo(entry.Directory);
if (folder == null) return;
MainWindow.YesWeCats.Activate();
do { await Task.Delay(100); } while (MainWindow.YesWeCats.AssetsListName.Items.Count < folder.AssetsList.Assets.Count);
while (!folder.IsSelected || MainWindow.YesWeCats.AssetsFolderName.SelectedItem != folder)
await Task.Delay(50); // stops assets tab from opening too early
MainWindow.YesWeCats.LeftTabControl.SelectedIndex = 2; // assets tab
do
{