diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 3898ed70..140f6d92 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -20,7 +20,7 @@ jobs: dotnet-version: '8.0.x' - name: .NET Restore - run: dotnet restore "./FModel/FModel.slnx" + run: dotnet restore "./FModel/FModel.slnx" -r win-x64 - name: .NET Publish run: dotnet publish "./FModel/FModel.csproj" -c Release --no-restore --no-self-contained -r win-x64 -f net8.0-windows -o "./FModel/bin/Publish/" -p:PublishReadyToRun=false -p:PublishSingleFile=true -p:DebugType=None -p:GenerateDocumentationFile=false -p:DebugSymbols=false diff --git a/CUE4Parse b/CUE4Parse index 4fb74359..3a243aa9 160000 --- a/CUE4Parse +++ b/CUE4Parse @@ -1 +1 @@ -Subproject commit 4fb7435973fc57bfb78577c971d776f7577440cf +Subproject commit 3a243aa9452658bbc6a74d538d1cf4d73da758f4 diff --git a/FModel/App.xaml.cs b/FModel/App.xaml.cs index c434def6..31da19b9 100644 --- a/FModel/App.xaml.cs +++ b/FModel/App.xaml.cs @@ -92,6 +92,12 @@ public partial class App UserSettings.Default.AudioDirectory = Path.Combine(UserSettings.Default.OutputDirectory, "Exports"); } + if (!Directory.Exists(UserSettings.Default.CodeDirectory)) + { + createMe = true; + UserSettings.Default.CodeDirectory = Path.Combine(UserSettings.Default.OutputDirectory, "Exports"); + } + if (!Directory.Exists(UserSettings.Default.ModelDirectory)) { createMe = true; diff --git a/FModel/Constants.cs b/FModel/Constants.cs index af4c4a51..45a79836 100644 --- a/FModel/Constants.cs +++ b/FModel/Constants.cs @@ -40,6 +40,12 @@ public static class Constants public const string _NO_PRESET_TRIGGER = "Hand Made"; + // Common issues + public const string MAPPING_ISSUE_LINK = "https://github.com/4sval/FModel/discussions/418"; + public const string AUDIO_ISSUE_LINK = "https://github.com/4sval/FModel/discussions/658"; + public const string RADA_ISSUE_LINK = "https://github.com/4sval/FModel/discussions/422"; + public const string VERSION_ISSUE_LINK = "https://github.com/4sval/FModel/discussions/425"; + public static int PALETTE_LENGTH => COLOR_PALETTE.Length; public static readonly Vector3[] COLOR_PALETTE = { diff --git a/FModel/Creator/Bases/FN/BaseAssembledMesh.cs b/FModel/Creator/Bases/FN/BaseAssembledMesh.cs new file mode 100644 index 00000000..830b90bf --- /dev/null +++ b/FModel/Creator/Bases/FN/BaseAssembledMesh.cs @@ -0,0 +1,47 @@ +using CUE4Parse.UE4.Assets.Exports; +using CUE4Parse.UE4.Assets.Objects; +using CUE4Parse.UE4.Objects.UObject; +using SkiaSharp; + +namespace FModel.Creator.Bases.FN; + +public class BaseAssembledMesh : UCreator +{ + public BaseAssembledMesh(UObject uObject, EIconStyle style) : base(uObject, style) + { + + } + + public override void ParseForInfo() + { + if (Object.TryGetValue(out FInstancedStruct[] additionalData, "AdditionalData")) + { + foreach (var data in additionalData) + { + if (data.NonConstStruct?.TryGetValue(out FSoftObjectPath largePreview, "LargePreviewImage", "SmallPreviewImage") == true) + { + Preview = Utils.GetBitmap(largePreview); + } + } + } + } + + public override SKBitmap[] Draw() + { + var ret = new SKBitmap(Width, Height, SKColorType.Rgba8888, SKAlphaType.Premul); + using var c = new SKCanvas(ret); + + switch (Style) + { + case EIconStyle.NoBackground: + DrawPreview(c); + break; + default: + DrawBackground(c); + DrawPreview(c); + break; + } + + return new[] { ret }; + } +} diff --git a/FModel/Creator/Bases/FN/BaseIcon.cs b/FModel/Creator/Bases/FN/BaseIcon.cs index 985b100a..edf73fce 100644 --- a/FModel/Creator/Bases/FN/BaseIcon.cs +++ b/FModel/Creator/Bases/FN/BaseIcon.cs @@ -36,6 +36,7 @@ public class BaseIcon : UCreator if (Object.TryGetValue(out FInstancedStruct[] dataList, "DataList")) { + GetRarity(dataList); GetSeries(dataList); Preview = Utils.GetBitmap(dataList); } @@ -139,6 +140,12 @@ public class BaseIcon : UCreator GetSeries(export); } + private void GetRarity(FInstancedStruct[] s) + { + if (s.FirstOrDefault(d => d.NonConstStruct?.TryGetValue(out EFortRarity _, "Rarity") == true) is { } dl) + GetRarity(dl.NonConstStruct.Get("Rarity")); + } + private void GetSeries(FInstancedStruct[] s) { if (s.FirstOrDefault(d => d.NonConstStruct?.TryGetValue(out FPackageIndex _, "Series") == true) is { } dl) diff --git a/FModel/Creator/Bases/FN/BaseIconStats.cs b/FModel/Creator/Bases/FN/BaseIconStats.cs index 13d2bfe1..d495d17d 100644 --- a/FModel/Creator/Bases/FN/BaseIconStats.cs +++ b/FModel/Creator/Bases/FN/BaseIconStats.cs @@ -87,6 +87,7 @@ public class BaseIconStats : BaseIcon weaponRowValue.TryGetValue(out float dmgPb, "DmgPB"); //Damage at point blank weaponRowValue.TryGetValue(out float mdpc, "MaxDamagePerCartridge"); //Max damage a weapon can do in a single hit, usually used for shotguns weaponRowValue.TryGetValue(out float dmgCritical, "DamageZone_Critical"); //Headshot multiplier + weaponRowValue.TryGetValue(out float envDmgPb, "EnvDmgPB"); //Structure damage at point blank weaponRowValue.TryGetValue(out int clipSize, "ClipSize"); //Item magazine size weaponRowValue.TryGetValue(out float firingRate, "FiringRate"); //Item firing rate, value is shots per second weaponRowValue.TryGetValue(out float swingTime, "SwingTime"); //Item swing rate, value is swing per second @@ -115,6 +116,15 @@ public class BaseIconStats : BaseIcon _statistics.Add(new IconStat(Utils.GetLocalizedResource("", "0DEF2455463B008C4499FEA03D149EDF", "Headshot Damage"), dmgPb * dmgCritical * multiplier, 160)); } } + { + var envdmgmultiplier = bpc != 0f ? bpc : 1; + if (envDmgPb != 0f) + + { + _statistics.Add(new IconStat(Utils.GetLocalizedResource("", "11AF67134E0F4E27E5E588806AB475BE", "Structure Damage"), envDmgPb * envdmgmultiplier, 160)); + } + } + if (clipSize > 999f || clipSize == 0f) { _statistics.Add(new IconStat(Utils.GetLocalizedResource("", "068239DD4327B36124498C9C5F61C038", "Magazine Size"), Utils.GetLocalizedResource("", "0FAE8E5445029F2AA209ADB0FE49B23C", "Infinite"), -1)); diff --git a/FModel/Creator/CreatorPackage.cs b/FModel/Creator/CreatorPackage.cs index 0fe8c317..b2b12284 100644 --- a/FModel/Creator/CreatorPackage.cs +++ b/FModel/Creator/CreatorPackage.cs @@ -100,12 +100,14 @@ public class CreatorPackage : IDisposable case "FortCodeTokenItemDefinition": case "FortSchematicItemDefinition": case "FortAlterableItemDefinition": + case "SproutHousingItemDefinition": case "SparksKeyboardItemDefinition": case "FortWorldMultiItemDefinition": case "FortAlterationItemDefinition": case "FortExpeditionItemDefinition": case "FortIngredientItemDefinition": case "FortConsumableItemDefinition": + case "SproutBuildingItemDefinition": case "StWFortAccoladeItemDefinition": case "FortAccountBuffItemDefinition": case "FortFOBCoreDecoItemDefinition": @@ -163,6 +165,9 @@ public class CreatorPackage : IDisposable case "JunoAthenaDanceItemOverrideDefinition": creator = new BaseJuno(_object.Value, _style); return true; + case "AssembledMeshSchema": + creator = new BaseAssembledMesh(_object.Value, _style); + return true; case "FortTandemCharacterData": creator = new BaseTandem(_object.Value, _style); return true; diff --git a/FModel/Enums.cs b/FModel/Enums.cs index 5e06621d..59c16b75 100644 --- a/FModel/Enums.cs +++ b/FModel/Enums.cs @@ -27,6 +27,7 @@ public enum SettingsOut public enum EStatusKind { Ready, // ready + Configuring, // waiting for user input Loading, // doing stuff Stopping, // trying to stop Stopped, // stopped @@ -107,6 +108,7 @@ public enum EBulkType Animations = 1 << 4, Audio = 1 << 5, Code = 1 << 6, + Raw = 1 << 7, } public enum EAssetCategory : uint @@ -158,4 +160,7 @@ public enum EAssetCategory : uint Particle = AssetCategoryExtensions.CategoryBase + (9 << 16), GameSpecific = AssetCategoryExtensions.CategoryBase + (10 << 16), Borderlands = GameSpecific + 1, + Aion2 = GameSpecific + 2, + RocoKingdomWorld = GameSpecific + 3, + DeltaForce = GameSpecific + 4, } diff --git a/FModel/Extensions/AvalonExtensions.cs b/FModel/Extensions/AvalonExtensions.cs index b356e106..3021e178 100644 --- a/FModel/Extensions/AvalonExtensions.cs +++ b/FModel/Extensions/AvalonExtensions.cs @@ -14,6 +14,7 @@ public static class AvalonExtensions private static readonly IHighlightingDefinition _cppHighlighter = LoadHighlighter("Cpp.xshd"); private static readonly IHighlightingDefinition _changelogHighlighter = LoadHighlighter("Changelog.xshd"); private static readonly IHighlightingDefinition _verseHighlighter = LoadHighlighter("Verse.xshd"); + private static readonly IHighlightingDefinition _luaHighlighter = LoadHighlighter("Lua.xshd"); [MethodImpl(MethodImplOptions.AggressiveInlining)] private static IHighlightingDefinition LoadHighlighter(string resourceName) @@ -29,6 +30,9 @@ public static class AvalonExtensions { switch (ext) { + case "lua": + case "luac": + return _luaHighlighter; case "ini": case "csv": return _iniHighlighter; diff --git a/FModel/FModel.csproj b/FModel/FModel.csproj index 4ca717cf..90c2d391 100644 --- a/FModel/FModel.csproj +++ b/FModel/FModel.csproj @@ -81,6 +81,7 @@ + @@ -129,6 +130,7 @@ + diff --git a/FModel/Helper.cs b/FModel/Helper.cs index 58d51545..05f1530b 100644 --- a/FModel/Helper.cs +++ b/FModel/Helper.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Windows; @@ -62,7 +63,7 @@ public static class Helper GetOpenedWindow(windowName).Close(); } - private static bool IsWindowOpen(string name = "") where T : Window + public static bool IsWindowOpen(string name = "") where T : Window { return string.IsNullOrEmpty(name) ? Application.Current.Windows.OfType().Any() @@ -111,4 +112,24 @@ public static class Helper const float ratio = 180f / MathF.PI; return radians * ratio; } + + public static string GetGameName(string path) + { + // install_folder/ + // ├─ Engine/ + // ├─ GameName/ + // │ ├─ Binaries/ + // │ ├─ Content/ + // │ │ ├─ Paks/ + // our goal is to get the GameName folder + var dir = new DirectoryInfo(path); + if (dir.Name.Equals("Paks", StringComparison.InvariantCulture) && dir.Parent is { Parent: not null } && + dir.Parent.Name.Equals("Content", StringComparison.InvariantCulture) && + dir.Parent.Parent.GetDirectories().Any(x => x.Name == "Binaries")) + { + return dir.Parent.Parent.Name; + } + + return dir.Name; + } } diff --git a/FModel/MainWindow.xaml b/FModel/MainWindow.xaml index 8dc4c0b0..46d2ddb5 100644 --- a/FModel/MainWindow.xaml +++ b/FModel/MainWindow.xaml @@ -12,7 +12,8 @@ xmlns:adonisExtensions="clr-namespace:AdonisUI.Extensions;assembly=AdonisUI" WindowStartupLocation="CenterScreen" Closing="OnClosing" Loaded="OnLoaded" PreviewKeyDown="OnWindowKeyDown" Height="{Binding Source={x:Static SystemParameters.MaximizedPrimaryScreenHeight}, Converter={converters:RatioConverter}, ConverterParameter='0.95'}" - Width="{Binding Source={x:Static SystemParameters.MaximizedPrimaryScreenWidth}, Converter={converters:RatioConverter}, ConverterParameter='0.90'}"> + Width="{Binding Source={x:Static SystemParameters.MaximizedPrimaryScreenWidth}, Converter={converters:RatioConverter}, ConverterParameter='0.90'}" + AllowDrop="True"> @@ -628,6 +629,10 @@ + + + + @@ -709,5 +714,7 @@ + diff --git a/FModel/MainWindow.xaml.cs b/FModel/MainWindow.xaml.cs index 2c532723..b36da622 100644 --- a/FModel/MainWindow.xaml.cs +++ b/FModel/MainWindow.xaml.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using System.IO; using System.Linq; using System.Threading.Tasks; using System.Windows; diff --git a/FModel/Resources/Cpp.xshd b/FModel/Resources/Cpp.xshd index f61b1264..89c82780 100644 --- a/FModel/Resources/Cpp.xshd +++ b/FModel/Resources/Cpp.xshd @@ -18,6 +18,7 @@ + (\/\/.*|\/\*[\s\S]*?\*\/) @@ -44,10 +45,19 @@ Int16 Int32 Int64 + int8 + int16 + int32 + int64 uint + UInt8 UInt16 UInt32 UInt64 + uint8 + uint16 + uint32 + uint64 float double bool @@ -83,6 +93,7 @@ inline constexpr default + && @@ -120,8 +131,6 @@ [\[\]\{\}] - (\/\/.*|\/\*[\s\S]*?\*\/) - \b[A-Za-z_][A-Za-z0-9_]*\b(?=<) diff --git a/FModel/Resources/Lua.xshd b/FModel/Resources/Lua.xshd new file mode 100644 index 00000000..148f5b97 --- /dev/null +++ b/FModel/Resources/Lua.xshd @@ -0,0 +1,230 @@ + + + + + + + + + + + + + --.*$ + + "([^"\\]|\\.)*" + '([^'\\]|\\.)*' + + \b\d+\.\d+([eE][+-]?\d+)?\b + \b\d+[eE][+-]?\d+\b + \b\d+\b + + + return + function + goto + end + if + else + elseif + then + for + in + until + while + break + or + and + repeat + do + + + + local + nil + not + true + false + + + + + assert + collectgarbage + error + ipairs + next + pairs + pcall + print + rawequal + rawget + rawlen + rawset + select + setmetatable + tonumber + tostring + type + xpcall + getmetatable + require + module + + + math + string + table + coroutine + os + io + utf8 + bit32 + package + debug + + + arshift + band + bnot + bor + bxor + btest + extract + lrotate + lshift + replace + rrotate + rshift + + + create + resume + running + status + wrap + yield + isyieldable + + + getuservalue + gethook + getinfo + getlocal + getregistry + getupvalue + upvaluejoin + upvalueid + setuservalue + sethook + setlocal + setupvalue + traceback + + + close + flush + input + lines + open + output + popen + read + tmpfile + seek + setvbuf + write + + + byte + char + dump + find + format + gmatch + gsub + len + lower + match + rep + reverse + sub + upper + pack + packsize + unpack + concat + maxn + insert + move + offset + codepoint + codes + charpattern + + + clock + date + difftime + execute + exit + getenv + remove + rename + setlocale + time + loadlib + searchpath + seeall + preload + cpath + path + searchers + loaded + + + abs + acos + asin + atan + atan2 + ceil + cos + cosh + deg + exp + floor + fmod + ult + log + log10 + max + min + modf + pi + rad + random + randomseed + sin + sqrt + tan + sinh + tanh + pow + frexp + ldexp + huge + maxinteger + mininteger + + + (\|)|(<<)|(>>)|(\/\/)|(==)|(~=)|(<=)|(>=)|(<)|(>)|(=)|(\()|(\))|(\{)|(\})|(\[)|(\])|(::)|(:)|(;)|(,)|(\.\.\.)|(\.\.)|(\.)|[+\-*%\^#&~] + + (?<=function\s)[A-Za-z0-9_]+(?=\.) + + (?<=\.)[A-Za-z0-9_]+(?=\() + (?<=function\s)[A-Za-z0-9_]+(?=\s*\() + + \b[A-Z_][A-Z0-9_]*\b + + diff --git a/FModel/Settings/UserSettings.cs b/FModel/Settings/UserSettings.cs index ebf9e534..bca1427d 100644 --- a/FModel/Settings/UserSettings.cs +++ b/FModel/Settings/UserSettings.cs @@ -119,6 +119,13 @@ namespace FModel.Settings set => SetProperty(ref _audioDirectory, value); } + private string _codeDirectory; + public string CodeDirectory + { + get => _codeDirectory; + set => SetProperty(ref _codeDirectory, value); + } + private string _modelDirectory; public string ModelDirectory { @@ -252,13 +259,6 @@ namespace FModel.Settings set => SetProperty(ref _imageMergerMargin, value); } - private bool _canExportRawData; - public bool CanExportRawData - { - get => _canExportRawData; - set => SetProperty(ref _canExportRawData, value); - } - private bool _readScriptData; public bool ReadScriptData { @@ -461,13 +461,6 @@ namespace FModel.Settings set => SetProperty(ref _cameraMode, value); } - private int _wwiseMaxBnkPrefetch; - public int WwiseMaxBnkPrefetch - { - get => _wwiseMaxBnkPrefetch; - set => SetProperty(ref _wwiseMaxBnkPrefetch, value); - } - private int _previewMaxTextureSize = 1024; public int PreviewMaxTextureSize { diff --git a/FModel/ViewModels/ApiEndpoints/DillyApiEndpoints.cs b/FModel/ViewModels/ApiEndpoints/DillyApiEndpoints.cs index 926061bc..630c0816 100644 --- a/FModel/ViewModels/ApiEndpoints/DillyApiEndpoints.cs +++ b/FModel/ViewModels/ApiEndpoints/DillyApiEndpoints.cs @@ -11,6 +11,7 @@ namespace FModel.ViewModels.ApiEndpoints; public class DillyApiEndpoint : AbstractApiProvider { private Backup[] _backups; + private ManifestInfoDilly[] _manifests; public DillyApiEndpoint(RestClient client) : base(client) { } @@ -27,6 +28,19 @@ public class DillyApiEndpoint : AbstractApiProvider return _backups ??= GetBackupsAsync(token).GetAwaiter().GetResult(); } + public async Task GetManifestsAsync(CancellationToken token) + { + var request = new FRestRequest($"https://export-service-new.dillyapis.com/v1/manifests"); + var response = await _client.ExecuteAsync(request, token).ConfigureAwait(false); + Log.Information("[{Method}] [{Status}({StatusCode})] '{Resource}'", request.Method, response.StatusDescription, (int) response.StatusCode, response.ResponseUri?.OriginalString); + return response.Data; + } + + public ManifestInfoDilly[] GetManifests(CancellationToken token) + { + return _manifests ??= GetManifestsAsync(token).GetAwaiter().GetResult(); + } + public async Task>> GetHotfixesAsync(CancellationToken token, string language = "en") { var request = new FRestRequest("https://api.fortniteapi.com/v1/cloudstorage/hotfixes") diff --git a/FModel/ViewModels/ApiEndpoints/Models/FModelResponse.cs b/FModel/ViewModels/ApiEndpoints/Models/FModelResponse.cs index 67c6f6a4..598f070c 100644 --- a/FModel/ViewModels/ApiEndpoints/Models/FModelResponse.cs +++ b/FModel/ViewModels/ApiEndpoints/Models/FModelResponse.cs @@ -23,6 +23,13 @@ public class Backup [J] public string Url { get; private set; } } +[DebuggerDisplay("{" + nameof(AppName) + "}")] +public class ManifestInfoDilly +{ + [J] public string AppName { get; private set; } + [J] public string DownloadUrl { get; private set; } +} + public class Donator { [J] public string Username { get; private set; } diff --git a/FModel/ViewModels/ApplicationViewModel.cs b/FModel/ViewModels/ApplicationViewModel.cs index 7822aa70..42b24d90 100644 --- a/FModel/ViewModels/ApplicationViewModel.cs +++ b/FModel/ViewModels/ApplicationViewModel.cs @@ -104,7 +104,7 @@ public class ApplicationViewModel : ViewModel if (UserSettings.Default.CurrentDir is null) { //If no game is selected, many things will break before a shutdown request is processed in the normal way. - //A hard exit is preferable to an unhandled expection in this case + //A hard exit is preferable to an unhandled exception in this case Environment.Exit(0); } @@ -126,7 +126,6 @@ public class ApplicationViewModel : ViewModel if (sender is not IAesVfsReader reader) return; CUE4Parse.GameDirectory.Disable(reader); }; - CustomDirectories = new CustomDirectoriesViewModel(); SettingsView = new SettingsViewModel(); AesManager = new AesManagerViewModel(CUE4Parse); @@ -141,8 +140,10 @@ public class ApplicationViewModel : ViewModel if (!bAlreadyLaunched && UserSettings.Default.PerDirectory.TryGetValue(gameDirectory, out var currentDir)) return currentDir; + Status.SetStatus(EStatusKind.Configuring); var gameLauncherViewModel = new GameSelectorViewModel(gameDirectory); var result = new DirectorySelector(gameLauncherViewModel).ShowDialog(); + Status.SetStatus(EStatusKind.Ready); if (!result.HasValue || !result.Value) return null; UserSettings.Default.GameDirectory = gameLauncherViewModel.SelectedDirectory.GameDirectory; @@ -155,6 +156,35 @@ public class ApplicationViewModel : ViewModel return null; } + public DirectorySettings AddGameDirectory(string directory) + { + if (Status.Kind is EStatusKind.Configuring) + { + var directorySelector = Helper.GetWindow("Directory Selector", null); + directorySelector.AddManualGame(directory); + return null; + } + else + { + Status.SetStatus(EStatusKind.Configuring); + var gameLauncherViewModel = new GameSelectorViewModel(UserSettings.Default.GameDirectory); + var directorySelector = new DirectorySelector(gameLauncherViewModel); + directorySelector.AddManualGame(directory); + var result = directorySelector.ShowDialog(); + Status.SetStatus(EStatusKind.Ready); + if (!result.HasValue || !result.Value) + return null; + + UserSettings.Default.GameDirectory = gameLauncherViewModel.SelectedDirectory.GameDirectory; + if (UserSettings.Default.CurrentDir.Equals(gameLauncherViewModel.SelectedDirectory)) + return gameLauncherViewModel.SelectedDirectory; + + UserSettings.Default.CurrentDir = gameLauncherViewModel.SelectedDirectory; + RestartWithWarning(); + return null; + } + } + public void RestartWithWarning() { MessageBox.Show("It looks like you just changed something.\nFModel will restart to apply your changes.", "Uh oh, a restart is needed", MessageBoxButton.OK, MessageBoxImage.Warning); @@ -246,7 +276,7 @@ public class ApplicationViewModel : ViewModel } else { - FLogger.Append(ELog.Error, () => FLogger.Text("Could not download VgmStream", Constants.WHITE, true)); + FLogger.Append(ELog.Error, () => FLogger.Text("Could not download vgmstream", Constants.WHITE, true)); } } } diff --git a/FModel/ViewModels/AudioPlayerViewModel.cs b/FModel/ViewModels/AudioPlayerViewModel.cs index d12b2e7b..9821e915 100644 --- a/FModel/ViewModels/AudioPlayerViewModel.cs +++ b/FModel/ViewModels/AudioPlayerViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; @@ -298,13 +299,23 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable Save(a, true); } - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully saved audio from ", Constants.WHITE); - FLogger.Link(_audioFiles.First().FileName, _audioFiles.First().FilePath, true); - }); if (_audioFiles.Count > 1) - FLogger.Append(ELog.Information, () => FLogger.Text($"Successfully saved {_audioFiles.Count} audio files", Constants.WHITE, true)); + { + var dir = new DirectoryInfo(Path.GetDirectoryName(_audioFiles.First().FilePath)); + FLogger.Append(ELog.Information, () => + { + FLogger.Text($"Successfully saved {_audioFiles.Count} audio files to ", Constants.WHITE); + FLogger.Link(dir.Name, dir.FullName, true); + }); + } + else + { + FLogger.Append(ELog.Information, () => + { + FLogger.Text("Successfully saved ", Constants.WHITE); + FLogger.Link(_audioFiles.First().FileName, _audioFiles.First().FilePath, true); + }); + } }); } @@ -330,12 +341,8 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable Directory.CreateDirectory(path.SubstringBeforeLast('/')); } - using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write)) - using (var writer = new BinaryWriter(stream)) - { - writer.Write(fileToSave.Data); - writer.Flush(); - } + using var stream = new FileStream(path, FileMode.Create, FileAccess.Write); + stream.Write(fileToSave.Data); if (File.Exists(path)) { @@ -654,32 +661,30 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable } } - private bool TryConvert(out string wavFilePath) => TryConvert(SelectedAudioFile.FilePath, SelectedAudioFile.Data, out wavFilePath); - public static bool TryConvert(string inputFilePath, byte[] inputFileData, out string wavFilePath) + private bool TryConvert(out string wavFilePath) => TryConvert(SelectedAudioFile.FilePath, SelectedAudioFile.Data, out wavFilePath, true); + public static bool TryConvert(string inputFilePath, byte[] inputFileData, out string wavFilePath, bool updateUi = false) { wavFilePath = string.Empty; - var vgmFilePath = Path.Combine(UserSettings.Default.OutputDirectory, ".data", "test.exe"); - if (!File.Exists(vgmFilePath)) + var vgmStreamPath = TryGetVgmstreamPath(); + if (string.IsNullOrEmpty(vgmStreamPath)) + return false; + + var success = TryConvertToWav(inputFilePath, inputFileData, vgmStreamPath, true, out wavFilePath); + + if (!success) { - vgmFilePath = Path.Combine(UserSettings.Default.OutputDirectory, ".data", "vgmstream-cli.exe"); - if (!File.Exists(vgmFilePath)) return false; + Log.Error("Failed to convert {InputFilePath} to .wav format", Path.GetFileName(inputFilePath)); + if (updateUi) + { + FLogger.Append(ELog.Error, () => + { + FLogger.Text("Failed to convert audio to .wav format. See: ", Constants.WHITE); + FLogger.Link("→ link ←", Constants.AUDIO_ISSUE_LINK, true); + }); + } } - Directory.CreateDirectory(inputFilePath.SubstringBeforeLast("/")); - File.WriteAllBytes(inputFilePath, inputFileData); - - wavFilePath = Path.ChangeExtension(inputFilePath, ".wav"); - var vgmProcess = Process.Start(new ProcessStartInfo - { - FileName = vgmFilePath, - Arguments = $"-o \"{wavFilePath}\" \"{inputFilePath}\"", - UseShellExecute = false, - CreateNoWindow = true - }); - vgmProcess?.WaitForExit(5000); - - File.Delete(inputFilePath); - return vgmProcess?.ExitCode == 0 && File.Exists(wavFilePath); + return success; } private bool TryDecode(string extension, out string rawFilePath) @@ -688,23 +693,116 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable var decoderPath = Path.Combine(UserSettings.Default.OutputDirectory, ".data", $"{extension}dec.exe"); if (!File.Exists(decoderPath)) { + Log.Error("Failed to convert {FilePath}, rada decoder is missing", SelectedAudioFile.FilePath); + FLogger.Append(ELog.Error, () => + { + FLogger.Text("Failed to convert audio because rada decoder is missing. See: ", Constants.WHITE); + FLogger.Link("→ link ←", Constants.RADA_ISSUE_LINK, true); + }); return false; } - Directory.CreateDirectory(SelectedAudioFile.FilePath.SubstringBeforeLast("/")); - File.WriteAllBytes(SelectedAudioFile.FilePath, SelectedAudioFile.Data); + return TryConvertToWav(SelectedAudioFile.FilePath, SelectedAudioFile.Data, decoderPath, false, out rawFilePath); + } - rawFilePath = Path.ChangeExtension(SelectedAudioFile.FilePath, ".wav"); - var decoderProcess = Process.Start(new ProcessStartInfo + private static bool TryConvertToWav(string inputFilePath, byte[] inputFileData, string converterPath, bool usevgmstream, out string wavFilePath) + { + wavFilePath = Path.ChangeExtension(inputFilePath, ".wav"); + var directory = Path.GetDirectoryName(inputFilePath); + Directory.CreateDirectory(directory); + + var tempfile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + Path.GetExtension(inputFilePath)); + File.WriteAllBytes(tempfile, inputFileData); + + var tempWavFilePath = Path.ChangeExtension(tempfile, ".wav"); + + var process = Process.Start(new ProcessStartInfo { - FileName = decoderPath, - Arguments = $"-i \"{SelectedAudioFile.FilePath}\" -o \"{rawFilePath}\"", + FileName = converterPath, + Arguments = usevgmstream ? $"-o \"{tempWavFilePath}\" \"{tempfile}\"" : $"-i \"{tempfile}\" -o \"{tempWavFilePath}\"", UseShellExecute = false, CreateNoWindow = true }); - decoderProcess?.WaitForExit(5000); + process?.WaitForExit(5000); - File.Delete(SelectedAudioFile.FilePath); - return decoderProcess?.ExitCode == 0 && File.Exists(rawFilePath); + File.Delete(tempfile); + + var success = process?.ExitCode == 0 && File.Exists(tempWavFilePath); + if (success) + { + File.Move(tempWavFilePath, wavFilePath, true); + } + + return success; + } + + private static string TryGetVgmstreamPath() + { + var vgmFilePath = Path.Combine(UserSettings.Default.OutputDirectory, ".data", "test.exe"); + if (!File.Exists(vgmFilePath)) + { + vgmFilePath = Path.Combine(UserSettings.Default.OutputDirectory, ".data", "vgmstream-cli.exe"); + if (!File.Exists(vgmFilePath)) + { + Log.Error("Failed to convert audio, vgmstream is missing"); + FLogger.Append(ELog.Error, () => + { + FLogger.Text("Failed to convert audio because vgmstream is missing. See: ", Constants.WHITE); + FLogger.Link("→ link ←", Constants.AUDIO_ISSUE_LINK, true); + }); + + return string.Empty; + } + } + + return vgmFilePath; + } + + // Since Square Enix soundbanks are pretty niche, let's just use vgmstream to extract them + public static List ExtractSquareEnixAudio(string sabPath, byte[] sqexData) + { + var vgmStreamPath = TryGetVgmstreamPath(); + if (string.IsNullOrEmpty(vgmStreamPath)) + return []; + if (sqexData.Length == 0) + return []; + + var extractionDir = Path.GetDirectoryName(sabPath); + Directory.CreateDirectory(extractionDir); + + // There's no clean way to know what was extracted with vgmstream (it's a soundbank, might contain multiple sounds) so we're monitoring extraction directory + var capturedFiles = new ConcurrentBag(); + using var watcher = new FileSystemWatcher(extractionDir) + { + Filter = "*.wav", + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime + }; + + void handler(object s, FileSystemEventArgs e) => capturedFiles.Add(e.FullPath); + + watcher.Created += handler; + watcher.Changed += handler; + watcher.EnableRaisingEvents = true; + + var tempSab = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sab"); + File.WriteAllBytes(tempSab, sqexData); + + var startInfo = new ProcessStartInfo + { + FileName = vgmStreamPath, + Arguments = $"-S 0 -o \"{extractionDir}\\?n_?s.wav\" \"{tempSab}\"", + UseShellExecute = false, + CreateNoWindow = true + }; + + using (var process = Process.Start(startInfo)) + { + process?.WaitForExit(15000); + } + + File.Delete(tempSab); + watcher.EnableRaisingEvents = false; + + return [.. capturedFiles.Distinct()]; } } diff --git a/FModel/ViewModels/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs index 46d2433a..a47cf91d 100644 --- a/FModel/ViewModels/CUE4ParseViewModel.cs +++ b/FModel/ViewModels/CUE4ParseViewModel.cs @@ -5,14 +5,12 @@ using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; -using System.Net.Http.Headers; +using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Windows; - using AdonisUI.Controls; - using CUE4Parse; using CUE4Parse.Compression; using CUE4Parse.Encryption.Aes; @@ -22,11 +20,15 @@ using CUE4Parse.FileProvider.Vfs; using CUE4Parse.GameTypes.Aion2.Objects; using CUE4Parse.GameTypes.AoC.Objects; using CUE4Parse.GameTypes.AshEchoes.FileProvider; -using CUE4Parse.GameTypes.SMG.UE4.Assets.Exports.Wwise; -using CUE4Parse.GameTypes.KRD.Assets.Exports; +using CUE4Parse.GameTypes.Borderlands3.Assets.Exports; using CUE4Parse.GameTypes.Borderlands4.Assets.Exports; using CUE4Parse.GameTypes.Borderlands4.Wwise; -using CUE4Parse.GameTypes.Borderlands3.Assets.Exports; +using CUE4Parse.GameTypes.DFHO.Assets.Objects; +using CUE4Parse.GameTypes.HonorOfKings.FileProvider; +using CUE4Parse.GameTypes.KRD.Assets.Exports; +using CUE4Parse.GameTypes.RocoKingdomWorld.Assets.Objects; +using CUE4Parse.GameTypes.SMG.UE4.Assets.Exports.Wwise; +using CUE4Parse.GameTypes.SquareEnix.UE4.Assets.Exports; using CUE4Parse.MappingsProvider; using CUE4Parse.UE4.AssetRegistry; using CUE4Parse.UE4.Assets; @@ -58,14 +60,11 @@ using CUE4Parse.UE4.Shaders; using CUE4Parse.UE4.Versions; using CUE4Parse.UE4.Wwise; using CUE4Parse.Utils; - using CUE4Parse_Conversion; using CUE4Parse_Conversion.Sounds; - using EpicManifestParser; using EpicManifestParser.UE; using EpicManifestParser.ZlibngDotNetDecompressor; - using FModel.Creator; using FModel.Extensions; using FModel.Framework; @@ -74,21 +73,14 @@ using FModel.Settings; using FModel.Views; using FModel.Views.Resources.Controls; using FModel.Views.Snooper; - using Newtonsoft.Json; using Newtonsoft.Json.Converters; - using OpenTK.Windowing.Common; using OpenTK.Windowing.Desktop; - using Serilog; - using SkiaSharp; - using Svg.Skia; - using UE4Config.Parsing; - using Application = System.Windows.Application; using FGuid = CUE4Parse.UE4.Objects.Core.Misc.FGuid; @@ -163,6 +155,9 @@ public class CUE4ParseViewModel : ViewModel public CriWareProvider CriWareProvider => _criWareProviderLazy?.Value; public ConcurrentBag UnknownExtensions = []; + public int ExportedCount; + public int FailedExportCount; + public CUE4ParseViewModel() { var currentDir = UserSettings.Default.CurrentDir; @@ -202,6 +197,7 @@ public class CUE4ParseViewModel : ViewModel ], SearchOption.AllDirectories, versionContainer, pathComparer), _ when versionContainer.Game is EGame.GAME_AshEchoes => new AEDefaultFileProvider(gameDirectory, SearchOption.AllDirectories, versionContainer, pathComparer), _ when versionContainer.Game is EGame.GAME_BlackStigma => new DefaultFileProvider(gameDirectory, SearchOption.AllDirectories, versionContainer, StringComparer.Ordinal), + _ when versionContainer.Game is EGame.GAME_HonorofKingsWorld => new HoKWDefaultFileProvider(gameDirectory, SearchOption.AllDirectories, versionContainer, pathComparer), _ => new DefaultFileProvider(gameDirectory, SearchOption.AllDirectories, versionContainer, pathComparer) }; @@ -223,14 +219,12 @@ public class CUE4ParseViewModel : ViewModel public async Task Initialize() { - await _apiEndpointView.EpicApi.VerifyAuth(CancellationToken.None); await _threadWorkerView.Begin(cancellationToken => { Provider.OnDemandOptions = new IoStoreOnDemandOptions { ChunkHostUri = new Uri("https://download.epicgames.com/", UriKind.Absolute), ChunkCacheDirectory = Directory.CreateDirectory(Path.Combine(UserSettings.Default.OutputDirectory, ".data")), - Authorization = new AuthenticationHeaderValue("Bearer", UserSettings.Default.LastAuthResponse.AccessToken), Timeout = TimeSpan.FromSeconds(30) }; @@ -285,6 +279,20 @@ public class CUE4ParseViewModel : ViewModel it => new FRandomAccessStreamArchive(it, manifest.FindFile(it)!.GetStream(), p.Versions)); }); + var manifests = _apiEndpointView.DillyApi.GetManifests(cancellationToken); + var downloadUrl = manifests.First(x => x.AppName == "Fortnite_Studio").DownloadUrl; + + using var client = new HttpClient(); + var manifestBytes = client.GetByteArrayAsync(downloadUrl).GetAwaiter().GetResult(); + + var uefnManifest = FBuildPatchAppManifest.Deserialize(manifestBytes, manifestOptions); + + Parallel.ForEach(uefnManifest.Files.Where(x => _fnLiveRegex.IsMatch(x.FileName)), fileManifest => + { + p.RegisterVfs(fileManifest.FileName, [fileManifest.GetStream()], + it => new FRandomAccessStreamArchive(it, uefnManifest.FindFile(it)!.GetStream(), p.Versions)); + }); + var elapsedTime = Stopwatch.GetElapsedTime(startTs); FLogger.Append(ELog.Information, () => FLogger.Text($"Fortnite [LIVE] has been loaded successfully in {elapsedTime.TotalMilliseconds:F1}ms", Constants.WHITE, true)); @@ -323,7 +331,7 @@ public class CUE4ParseViewModel : ViewModel } Provider.Initialize(); - _wwiseProviderLazy = new Lazy(() => new WwiseProvider(Provider, UserSettings.Default.GameDirectory, UserSettings.Default.WwiseMaxBnkPrefetch)); + _wwiseProviderLazy = new Lazy(() => new WwiseProvider(Provider, UserSettings.Default.GameDirectory)); _fmodProviderLazy = new Lazy(() => new FModProvider(Provider, UserSettings.Default.GameDirectory)); _criWareProviderLazy = new Lazy(() => new CriWareProvider(Provider, UserSettings.Default.GameDirectory)); Log.Information($"{Provider.Versions.Game} ({Provider.Versions.Platform}) | Archives: x{Provider.UnloadedVfs.Count} | AES: x{Provider.RequiredKeys.Count} | Loose Files: x{Provider.Files.Count}"); @@ -492,7 +500,7 @@ public class CUE4ParseViewModel : ViewModel var ioStoreOnDemandPath = Path.Combine(UserSettings.Default.GameDirectory, "..\\..\\..\\Cloud", inst[0].Value.SubstringAfterLast("/").SubstringBefore("\"")); if (!File.Exists(ioStoreOnDemandPath)) return; - await Provider.RegisterVfsAsync(new IoChunkToc(ioStoreOnDemandPath)); + await Provider.RegisterVfsAsync(new IoChunkToc(ioStoreOnDemandPath, Provider.Versions)); var onDemandCount = await Provider.MountAsync(); FLogger.Append(ELog.Information, () => FLogger.Text($"{onDemandCount} on-demand archive{(onDemandCount > 1 ? "s" : "")} streamed via epicgames.com", Constants.WHITE, true)); @@ -599,6 +607,9 @@ public class CUE4ParseViewModel : ViewModel foreach (var f in folder.Folders) ExportFolder(cancellationToken, f); } + public void ExtractFolder(CancellationToken cancellationToken, TreeItem folder, EBulkType bulk) + => BulkFolder(cancellationToken, folder, asset => Extract(cancellationToken, asset, TabControl.HasNoTabs, bulk)); + public void ExtractFolder(CancellationToken cancellationToken, TreeItem folder) => BulkFolder(cancellationToken, folder, asset => Extract(cancellationToken, asset, TabControl.HasNoTabs)); @@ -617,6 +628,9 @@ public class CUE4ParseViewModel : ViewModel public void AudioFolder(CancellationToken cancellationToken, TreeItem folder) => BulkFolder(cancellationToken, folder, asset => Extract(cancellationToken, asset, TabControl.HasNoTabs, EBulkType.Audio | EBulkType.Auto)); + public void CodeFolder(CancellationToken cancellationToken, TreeItem folder) + => BulkFolder(cancellationToken, folder, asset => Extract(cancellationToken, asset, TabControl.HasNoTabs, EBulkType.Code | EBulkType.Auto)); + public void Extract(CancellationToken cancellationToken, GameFile entry, bool addNewTab = false, EBulkType bulk = EBulkType.None) { ApplicationService.ApplicationView.IsAssetsExplorerVisible = false; @@ -630,6 +644,7 @@ public class CUE4ParseViewModel : ViewModel var saveProperties = HasFlag(bulk, EBulkType.Properties); var saveTextures = HasFlag(bulk, EBulkType.Textures); var saveAudio = HasFlag(bulk, EBulkType.Audio); + var saveDecompiled = HasFlag(bulk, EBulkType.Code); switch (entry.Extension) { case "uasset": @@ -644,6 +659,13 @@ public class CUE4ParseViewModel : ViewModel if (saveProperties) break; // do not search for viewable exports if we are dealing with jsons } + if (saveDecompiled) + { + if (Decompile(entry, false)) + TabControl.SelectedTab.SaveDecompiled(updateUi); + break; + } + for (var i = result.InclusiveStart; i < result.ExclusiveEnd; i++) { if (CheckExport(cancellationToken, result.Package, i, bulk)) @@ -667,6 +689,11 @@ public class CUE4ParseViewModel : ViewModel ProcessAion2DatFile(entry, updateUi, saveProperties); break; } + case "bytes" when Provider.Versions.Game is EGame.GAME_RocoKingdomWorld: + { + ProcessRocoBinFile(entry, updateUi, saveProperties); + break; + } case "dbc" when Provider.Versions.Game is EGame.GAME_AshesOfCreation: { ProcessCacheDBFile(entry, updateUi, saveProperties); @@ -694,7 +721,6 @@ public class CUE4ParseViewModel : ViewModel case "verse": case "html": case "json5": - case "json": case "uref": case "cube": case "usda": @@ -730,6 +756,8 @@ public class CUE4ParseViewModel : ViewModel case "po": case "md": case "h": + case "non" when Provider.Versions.Game is EGame.GAME_RocoKingdomWorld: + case "cam" when Provider.Versions.Game is EGame.GAME_RocoKingdomWorld: // Uncharted Waters Origin case "crn": case "uwt": @@ -747,6 +775,17 @@ public class CUE4ParseViewModel : ViewModel break; } + case "json": + { + var data = Provider.SaveAsset(entry); + using var stream = new MemoryStream(data) { Position = 0 }; + using var reader = new StreamReader(stream); + + var parsedJson = JsonConvert.DeserializeObject(reader.ReadToEnd()); + TabControl.SelectedTab.SetDocumentText(JsonConvert.SerializeObject(parsedJson, Formatting.Indented), saveProperties, updateUi); + + break; + } case "locmeta": { var archive = entry.CreateReader(); @@ -803,13 +842,13 @@ public class CUE4ParseViewModel : ViewModel case "pck": { var archive = entry.CreateReader(); - var wwise = new WwiseReader(archive); + var wwise = new WwiseReader(archive, new WwiseGameFileSource(entry)); TabControl.SelectedTab.SetDocumentText(JsonConvert.SerializeObject(wwise, Formatting.Indented), saveProperties, updateUi); var medias = WwiseProvider.ExtractBankSounds(wwise); foreach (var media in medias) { - SaveAndPlaySound(cancellationToken, media.OutputPath, media.Extension, media.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, media.OutputPath, media.Extension, media.Data?.GetData() ?? [], saveAudio, updateUi); } break; @@ -868,6 +907,13 @@ public class CUE4ParseViewModel : ViewModel break; } + case "ustbin" when Provider.Versions.Game is EGame.GAME_DeltaForce: + { + var archive = entry.CreateReader(); + var ustbin = new FDeltaStringTable(archive); + TabControl.SelectedTab.SetDocumentText(JsonConvert.SerializeObject(ustbin, Formatting.Indented), saveProperties, updateUi); + break; + } case "png": case "jpg": case "bmp": @@ -951,6 +997,40 @@ public class CUE4ParseViewModel : ViewModel } } + // Roco Kingdom: World + void ProcessRocoBinFile(GameFile entry, bool updateUi, bool saveProperties) + { + TabControl.SelectedTab.Highlighter = AvalonExtensions.HighlighterSelector("json"); + var nonFileName = "/" + entry.NameWithoutExtension + ".non"; + var nonPath = Provider.Files.Keys.FirstOrDefault(k => k.EndsWith(nonFileName, StringComparison.OrdinalIgnoreCase)); + + // I will only get one localization file because they did not translate any languages, lol + var locPathKey = entry.Path.Replace("/BinData/", "/BinLocalize/en_US/").Replace("/BinDataCompressed/", "/BinLocalize/en_US/"); + var locFileFound = Provider.Files.TryGetValue(locPathKey, out var locEntry); + + if (!string.IsNullOrEmpty(nonPath) && Provider.Files.TryGetValue(nonPath, out var nonEntry)) + { + string json = Encoding.UTF8.GetString(nonEntry.Read()); + var schema = JsonConvert.DeserializeObject(json); + var archive = entry.CreateReader(); + var locArchive = locFileFound ? new FRocoBinData(locEntry.CreateReader(), null, ERocoBinDataType.BinLocalize) : null; + + var data = entry.PathWithoutExtension switch + { + var p when p.Contains("BinDataCompressed") => new FRocoBinData(archive, schema, ERocoBinDataType.BinDataCompressed, locArchive), + var p when p.Contains("BinData") => new FRocoBinData(archive, schema, ERocoBinDataType.BinData, locArchive), + var p when p.Contains("BinLocalize") => new FRocoBinData(archive, null, ERocoBinDataType.BinLocalize), + _ => null + }; + + TabControl.SelectedTab.SetDocumentText(JsonConvert.SerializeObject(data, Formatting.Indented), saveProperties, updateUi); + } + else if (entry.PathWithoutExtension.Contains("/Bin/")) + { + throw new Exception($"Could not find associated .non file for {entry.Name}"); + } + } + void ProcessAion2DatFile(GameFile entry, bool updateUi, bool saveProperties) { TabControl.SelectedTab.Highlighter = AvalonExtensions.HighlighterSelector("json"); @@ -965,7 +1045,7 @@ public class CUE4ParseViewModel : ViewModel } else if (entry.NameWithoutExtension.Equals("L10NString")) { - var l10nData = new FAion2L10NFile(entry); + var l10nData = new FAion2L10NFile(entry, Provider); TabControl.SelectedTab.SetDocumentText(JsonConvert.SerializeObject(l10nData, Formatting.Indented), saveProperties, updateUi); } else @@ -1116,7 +1196,8 @@ public class CUE4ParseViewModel : ViewModel case UExternalSource when (isNone || saveAudio) && pointer.Object.Value is UExternalSource externalSource: { var audioName = Path.GetFileNameWithoutExtension(externalSource.ExternalSourcePath); - SaveAndPlaySound(cancellationToken, audioName, "wem", externalSource.Data?.WemFile ?? [], saveAudio, updateUi); + var outputPath = Path.Combine(TabControl.SelectedTab.Entry.PathWithoutExtension.Replace('\\', '/').SubstringBeforeLast('/'), audioName); + SaveAndPlaySound(cancellationToken, outputPath, "wem", externalSource.Data?.WemFile?.GetData() ?? [], saveAudio, updateUi); return false; } case UAkAudioBank when (isNone || saveAudio) && pointer.Object.Value is UAkAudioBank soundBank: @@ -1124,7 +1205,7 @@ public class CUE4ParseViewModel : ViewModel var extractedSounds = WwiseProvider.ExtractBankSounds(soundBank); foreach (var sound in extractedSounds) { - SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data?.GetData() ?? [], saveAudio, updateUi); } return false; } @@ -1133,27 +1214,27 @@ public class CUE4ParseViewModel : ViewModel var extractedSounds = WwiseProvider.ExtractAudioEventSounds(audioEvent); foreach (var sound in extractedSounds) { - SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data?.GetData() ?? [], saveAudio, updateUi); } return false; } case UFMODEvent when (isNone || saveAudio) && pointer.Object.Value is UFMODEvent fmodEvent: { var extractedSounds = FmodProvider.ExtractEventSounds(fmodEvent); - var directory = Path.GetDirectoryName(fmodEvent.Owner?.Name) ?? "/FMOD/Desktop/"; + var directory = Path.GetDirectoryName(Provider.FixPath(fmodEvent.Owner?.Name ?? "/FMOD/Desktop/")); foreach (var sound in extractedSounds) { - SaveAndPlaySound(cancellationToken, Path.Combine(directory, sound.Name), sound.Extension, sound.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, Path.Combine(directory, sound.Name).Replace("\\", "/"), sound.Extension, sound.Data, saveAudio, updateUi); } return false; } case UFMODBank when (isNone || saveAudio) && pointer.Object.Value is UFMODBank fmodBank: { var extractedSounds = FmodProvider.ExtractBankSounds(fmodBank); - var directory = Path.GetDirectoryName(fmodBank.Owner?.Name) ?? "/FMOD/Desktop/"; + var directory = Path.GetDirectoryName(Provider.FixPath(fmodBank.Owner?.Name ?? "/FMOD/Desktop/")); foreach (var sound in extractedSounds) { - SaveAndPlaySound(cancellationToken, Path.Combine(directory, sound.Name), sound.Extension, sound.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, Path.Combine(directory, sound.Name).Replace("\\", "/"), sound.Extension, sound.Data, saveAudio, updateUi); } return false; } @@ -1176,11 +1257,27 @@ public class CUE4ParseViewModel : ViewModel } return false; } + case USQEXSEADSoundBank or USQEXSEADSound when (isNone || saveAudio) && pointer.Object.Value is UObject squareEnixObject: + { + var data = squareEnixObject switch + { + USQEXSEADSoundBank sqexSoundBank => sqexSoundBank.SQEXSoundBankData?.Data ?? [], + USQEXSEADSound sqexSound => sqexSound.SQEXSoundData?.Data ?? [], + _ => [], + }; + var sabPath = Path.Combine(TabControl.SelectedTab.Entry.PathWithoutExtension.Replace('\\', '/').SubstringBeforeLast('/'), squareEnixObject.Name); + var extractedSounds = AudioPlayerViewModel.ExtractSquareEnixAudio(sabPath, data); + foreach (var soundPath in extractedSounds) + { + SaveAndPlaySound(cancellationToken, soundPath, "wav", File.ReadAllBytes(soundPath), saveAudio, updateUi); + } + return false; + } case UAkMediaAssetData when isNone || saveAudio: case USoundWave when isNone || saveAudio: { // If UAkMediaAsset exists in the same package it should be used to handle the audio instead (because it contains actual audio name) - if (pointer.Object.Value is UAkMediaAssetData dataObj && dataObj.Outer is UAkMediaAsset) + if (pointer.Object.Value is UAkMediaAssetData dataObj && dataObj.Outer.Object.Value is UAkMediaAsset) return false; var shouldDecompress = UserSettings.Default.CompressedAudioMode == ECompressedAudio.PlayDecompressed; @@ -1197,13 +1294,14 @@ public class CUE4ParseViewModel : ViewModel } case UAkMediaAsset when (isNone || saveAudio) && pointer.Object.Value is UAkMediaAsset akMediaAsset: { - var audioName = akMediaAsset.MediaName; - if (akMediaAsset.CurrentMediaAssetData?.TryLoad(out var akMediaAssetData) is true) + var audioName = akMediaAsset.MediaName ?? akMediaAsset.Name; + var outputPath = Path.Combine(TabControl.SelectedTab.Entry.PathWithoutExtension.Replace('\\', '/').SubstringBeforeLast('/'), audioName); + if (akMediaAsset.CurrentMediaAssetData?.ResolvedObject?.Object?.Value is UAkMediaAssetData akMediaAssetData) { var shouldDecompress = UserSettings.Default.CompressedAudioMode is ECompressedAudio.PlayDecompressed; akMediaAssetData.Decode(shouldDecompress, out var audioFormat, out var data); - SaveAndPlaySound(cancellationToken, audioName, audioFormat, data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, outputPath, audioFormat, data, saveAudio, updateUi); } return false; } @@ -1212,14 +1310,15 @@ public class CUE4ParseViewModel : ViewModel var shouldDecompress = UserSettings.Default.CompressedAudioMode is ECompressedAudio.PlayDecompressed; foreach (var mediaIndex in akAudioEventData.MediaList) { - if (mediaIndex.TryLoad(out var akMediaAsset)) + if (mediaIndex?.Object?.Value is UAkMediaAsset akMediaAsset) { - if (akMediaAsset.CurrentMediaAssetData?.TryLoad(out var akMediaAssetData) is true) + if (akMediaAsset.CurrentMediaAssetData?.ResolvedObject?.Object?.Value is UAkMediaAssetData akMediaAssetData) { var audioName = akMediaAsset.MediaName ?? $"{akAudioEventData.Outer.Name} ({akMediaAsset.ID})"; + var outputPath = Path.Combine(TabControl.SelectedTab.Entry.PathWithoutExtension.Replace('\\', '/').SubstringBeforeLast('/'), audioName); akMediaAssetData.Decode(shouldDecompress, out var audioFormat, out var data); - SaveAndPlaySound(cancellationToken, audioName, audioFormat, data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, outputPath, audioFormat, data, saveAudio, updateUi); } } } @@ -1231,7 +1330,7 @@ public class CUE4ParseViewModel : ViewModel var extractedSounds = WwiseProvider.ExtractDialogBorderlands3(dialogPerformanceData); foreach (var sound in extractedSounds) { - SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data?.GetData() ?? [], saveAudio, updateUi); } return false; } @@ -1241,12 +1340,13 @@ public class CUE4ParseViewModel : ViewModel if (Provider.Versions.Game is not EGame.GAME_Borderlands4) return false; + var ownerDirectory = WwiseProvider.GetOwnerDirectory(faceFXAnimSet); foreach (var faceFXAnimData in faceFXAnimSet.FaceFXAnimDataList) { - var extractedSounds = WwiseProvider.ExtractAudioEventBorderlands4(faceFXAnimData.ID.Name, false); + var extractedSounds = WwiseProvider.ExtractAudioEventBorderlands4(ownerDirectory, faceFXAnimData.ID.Name, false); foreach (var sound in extractedSounds) { - SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data?.GetData() ?? [], saveAudio, updateUi); } } @@ -1255,12 +1355,13 @@ public class CUE4ParseViewModel : ViewModel // Borderlands 4 case UGbxGraphAsset when (isNone || saveAudio) && pointer.Object.Value is UGbxGraphAsset gbxGraphAsset: { + var ownerDirectory = WwiseProvider.GetOwnerDirectory(gbxGraphAsset); foreach (var (eventName, useSoundTag) in GbxAudioUtil.GetAndClearEvents()) { - var extractedSounds = WwiseProvider.ExtractAudioEventBorderlands4(eventName, useSoundTag); + var extractedSounds = WwiseProvider.ExtractAudioEventBorderlands4(ownerDirectory, eventName, useSoundTag); foreach (var sound in extractedSounds) { - SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data?.GetData() ?? [], saveAudio, updateUi); } } @@ -1368,11 +1469,13 @@ public class CUE4ParseViewModel : ViewModel } - public void Decompile(GameFile entry) + public bool Decompile(GameFile entry, bool AddTab = true) { - ApplicationService.ApplicationView.IsAssetsExplorerVisible = false; - - if (TabControl.CanAddTabs) TabControl.AddTab(entry); + if (TabControl.CanAddTabs && AddTab) + { + ApplicationService.ApplicationView.IsAssetsExplorerVisible = false; + TabControl.AddTab(entry); + } else TabControl.SelectedTab.SoftReset(entry); TabControl.SelectedTab.TitleExtra = "Decompiled"; @@ -1401,56 +1504,57 @@ public class CUE4ParseViewModel : ViewModel if (dummy is not UClass || pointer.Object.Value is not UClass blueprint) continue; - cppList.Add(blueprint.DecompileBlueprintToPseudo(cookedMetaData)); + cppList.Add(blueprint.DecompileBlueprintToPseudo(pkg.Mappings, cookedMetaData)); } + if (cppList.Count == 0) return false; var cpp = cppList.Count > 1 ? string.Join("\n\n", cppList) : cppList.FirstOrDefault() ?? string.Empty; if (entry.Path.Contains("_Verse.uasset")) { cpp = Regex.Replace(cpp, "__verse_0x[a-fA-F0-9]{8}_", ""); // UnmangleCasedName } cpp = Regex.Replace(cpp, @"CallFunc_([A-Za-z0-9_]+)_ReturnValue", "$1"); - + cpp = Regex.Replace(cpp, @"K2Node_DynamicCast_([A-Za-z0-9_]+)", "$1"); + cpp = Regex.Replace(cpp, @"K2Node_([A-Za-z0-9_]+)", "$1"); TabControl.SelectedTab.SetDocumentText(cpp, false, false); + return true; } - private void SaveAndPlaySound(CancellationToken cancellationToken, string fullPath, string ext, byte[] data, bool isBulk, bool updateUi) + private void SaveAndPlaySound(CancellationToken cancellationToken, string fullPath, string ext, byte[] data, bool saveAudio, bool updateUi) { if (fullPath.StartsWith('/')) fullPath = fullPath[1..]; - var savedAudioPath = Path.Combine(UserSettings.Default.AudioDirectory, - UserSettings.Default.KeepDirectoryStructure ? fullPath : fullPath.SubstringAfterLast('/')).Replace('\\', '/') + $".{ext.ToLowerInvariant()}"; + var extLower = ext.ToLowerInvariant(); + var baseFilePath = UserSettings.Default.KeepDirectoryStructure ? fullPath : fullPath.SubstringAfterLast('/'); + var combinedPath = Path.Combine(UserSettings.Default.AudioDirectory, baseFilePath); + var savedAudioPath = Path.ChangeExtension(combinedPath, extLower).Replace('\\', '/'); - if (isBulk) + if (saveAudio) { cancellationToken.ThrowIfCancellationRequested(); - Directory.CreateDirectory(savedAudioPath.SubstringBeforeLast('/')); - using var stream = new FileStream(savedAudioPath, FileMode.Create, FileAccess.Write); - using (var writer = new BinaryWriter(stream)) - { - writer.Write(data); - writer.Flush(); - } + var directory = Path.GetDirectoryName(savedAudioPath); + Directory.CreateDirectory(directory); - if (UserSettings.Default.ConvertAudioOnBulkExport) + bool conversionSuccess = true; + if (UserSettings.Default.ConvertAudioOnBulkExport && extLower is not "wav") { - AudioPlayerViewModel.TryConvert(savedAudioPath, data, out string wavFilePath); - if (!string.IsNullOrEmpty(wavFilePath)) - { + if (AudioPlayerViewModel.TryConvert(savedAudioPath, data, out string wavFilePath)) savedAudioPath = wavFilePath; - } - else if (updateUi) + else { - FLogger.Append(ELog.Error, () => - { - FLogger.Text("Failed to convert audio to WAV format, aborting extraction.", Constants.WHITE, true); - }); + Interlocked.Increment(ref FailedExportCount); return; } } + else + { + using var stream = new FileStream(savedAudioPath, FileMode.Create, FileAccess.Write); + stream.Write(data); + } + Interlocked.Increment(ref ExportedCount); Log.Information("Successfully saved {FilePath}", savedAudioPath); - if (updateUi) + if (updateUi && conversionSuccess) { FLogger.Append(ELog.Information, () => { @@ -1462,6 +1566,9 @@ public class CUE4ParseViewModel : ViewModel return; } + if (!updateUi) + return; + // TODO // since we are currently in a thread, the audio player's lifetime (memory-wise) will keep the current thread up and running until fmodel itself closes // the solution would be to kill the current thread at this line and then open the audio player without "Application.Current.Dispatcher.Invoke" @@ -1479,6 +1586,7 @@ public class CUE4ParseViewModel : ViewModel var toSaveDirectory = new DirectoryInfo(UserSettings.Default.ModelDirectory); if (toSave.TryWriteToDir(toSaveDirectory, out var label, out var savedFilePath)) { + Interlocked.Increment(ref ExportedCount); Log.Information("Successfully saved {FilePath}", savedFilePath); if (updateUi) { @@ -1491,6 +1599,7 @@ public class CUE4ParseViewModel : ViewModel } else { + Interlocked.Increment(ref FailedExportCount); Log.Error("{FileName} could not be saved", export.Name); FLogger.Append(ELog.Error, () => FLogger.Text($"Could not save '{export.Name}'", Constants.WHITE, true)); } @@ -1512,6 +1621,7 @@ public class CUE4ParseViewModel : ViewModel } }); + Interlocked.Increment(ref ExportedCount); Log.Information("{FileName} successfully exported", entry.Name); if (updateUi) { @@ -1524,6 +1634,7 @@ public class CUE4ParseViewModel : ViewModel } else { + Interlocked.Increment(ref FailedExportCount); Log.Error("{FileName} could not be exported", entry.Name); if (updateUi) FLogger.Append(ELog.Error, () => FLogger.Text($"Could not export '{entry.Name}'", Constants.WHITE, true)); diff --git a/FModel/ViewModels/Commands/RightClickMenuCommand.cs b/FModel/ViewModels/Commands/RightClickMenuCommand.cs index f4456ed9..b490957c 100644 --- a/FModel/ViewModels/Commands/RightClickMenuCommand.cs +++ b/FModel/ViewModels/Commands/RightClickMenuCommand.cs @@ -1,7 +1,11 @@ +using System; using System.Collections; +using System.Data; +using System.IO; using System.Linq; using System.Threading; using CUE4Parse.FileProvider.Objects; +using CUE4Parse.Utils; using FModel.Framework; using FModel.Services; using FModel.Settings; @@ -13,8 +17,21 @@ public class RightClickMenuCommand : ViewModelCommand { private ThreadWorkerViewModel _threadWorkerView => ApplicationService.ThreadWorkerView; - public RightClickMenuCommand(ApplicationViewModel contextViewModel) : base(contextViewModel) + public RightClickMenuCommand(ApplicationViewModel contextViewModel) : base(contextViewModel) { } + + private enum EAction { + Show, + Export, + } + + private enum EShowAssetType + { + None, + JSON, + Metadata, + References, + Decompile, } public override async void Execute(ApplicationViewModel contextViewModel, object parameter) @@ -26,189 +43,151 @@ public class RightClickMenuCommand : ViewModelCommand if (param.Length == 0) return; var folders = param.OfType().ToArray(); - var assets = param.SelectMany(item => item switch - { - GameFile gf => new[] { gf }, // search view passes GameFile directly - GameFileViewModel gvm => new[] { gvm.Asset }, - _ => [] - }).ToArray(); + var assets = param + .Select(static item => item switch + { + GameFile gf => gf, // Search view passes GameFile directly + GameFileViewModel gvm => gvm.Asset, + _ => null + }) + .Where(static gf => gf is not null).ToArray(); if (folders.Length == 0 && assets.Length == 0) return; - var updateUi = assets.Length > 1 ? EBulkType.Auto : EBulkType.None; + var assetsGroups = assets.GroupBy(static gf => gf.Directory); + var (action, showtype, bulktype) = trigger switch + { + "Assets_Extract_New_Tab" => (EAction.Show, EShowAssetType.JSON, EBulkType.None), + "Assets_Show_Metadata" => (EAction.Show, EShowAssetType.Metadata, EBulkType.None), + "Assets_Show_References" => (EAction.Show, EShowAssetType.References, EBulkType.None), + "Assets_Decompile" => (EAction.Show, EShowAssetType.Decompile, EBulkType.Code), + + "Save_Data" => (EAction.Export, EShowAssetType.None, EBulkType.Raw), + "Save_Properties" => (EAction.Export, EShowAssetType.None, EBulkType.Properties), + "Save_Textures" => (EAction.Export, EShowAssetType.None, EBulkType.Textures), + "Save_Models" => (EAction.Export, EShowAssetType.None, EBulkType.Meshes), + "Save_Animations" => (EAction.Export, EShowAssetType.None, EBulkType.Animations), + "Save_Audio" => (EAction.Export, EShowAssetType.None, EBulkType.Audio), + "Save_Code" => (EAction.Export, EShowAssetType.None, EBulkType.Code), + + _ => throw new ArgumentOutOfRangeException("Unsupported asset action."), + }; + + Interlocked.Exchange(ref contextViewModel.CUE4Parse.ExportedCount, 0); + Interlocked.Exchange(ref contextViewModel.CUE4Parse.FailedExportCount, 0); await _threadWorkerView.Begin(cancellationToken => { - switch (trigger) + if (action is EAction.Show) { - #region Asset Commands - case "Assets_Extract_New_Tab": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, entry, true); - } - break; - case "Assets_Show_Metadata": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.ShowMetadata(entry); - } - break; - case "Assets_Show_References": - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.FindReferences(assets.FirstOrDefault()); - } - break; - case "Assets_Decompile": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Decompile(entry); - } - break; - case "Assets_Export_Data": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.ExportData(entry); - } - break; - case "Assets_Save_Properties": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, EBulkType.Properties | updateUi); - } - break; - case "Assets_Save_Textures": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, EBulkType.Textures | updateUi); - } - break; - case "Assets_Save_Models": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, EBulkType.Meshes | updateUi); - } - break; - case "Assets_Save_Animations": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, EBulkType.Animations | updateUi); - } - break; - case "Assets_Save_Audio": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, EBulkType.Audio | updateUi); - } - break; - #endregion + if (showtype is EShowAssetType.References) + assets = [assets.FirstOrDefault()]; - #region Folder Commands - case "Folders_Export_Data": - foreach (var folder in folders) - { - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.ExportFolder(cancellationToken, folder); + Action entryAction = showtype switch + { + EShowAssetType.JSON => entry => contextViewModel.CUE4Parse.Extract(cancellationToken, entry, true), + EShowAssetType.Metadata => entry => contextViewModel.CUE4Parse.ShowMetadata(entry), + EShowAssetType.Decompile => entry => contextViewModel.CUE4Parse.Decompile(entry), + EShowAssetType.References => entry => contextViewModel.CUE4Parse.FindReferences(entry), + _ => throw new ArgumentOutOfRangeException("Unsupported asset action type."), + }; - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully exported ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, UserSettings.Default.RawDataDirectory, true); - }); - } - break; - case "Folders_Save_Properties": - foreach (var folder in folders) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.SaveFolder(cancellationToken, folder); + foreach (var entry in assets) + { + Thread.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + entryAction(entry); + } - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully saved ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, UserSettings.Default.PropertiesDirectory, true); - }); - } - break; - case "Folders_Save_Textures": - foreach (var folder in folders) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.TextureFolder(cancellationToken, folder); + return; + } - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully saved textures from ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, UserSettings.Default.TextureDirectory, true); - }); - } - break; - case "Folders_Save_Models": - foreach (var folder in folders) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.ModelFolder(cancellationToken, folder); + var (dirType, filetype) = bulktype switch + { + EBulkType.Raw => (UserSettings.Default.RawDataDirectory, "files"), + EBulkType.Properties => (UserSettings.Default.PropertiesDirectory, "json files"), + EBulkType.Textures => (UserSettings.Default.TextureDirectory, "textures"), + EBulkType.Meshes => (UserSettings.Default.ModelDirectory, "models"), + EBulkType.Animations => (UserSettings.Default.ModelDirectory, "animations"), + EBulkType.Audio => (UserSettings.Default.AudioDirectory, "audio files"), + EBulkType.Code => (UserSettings.Default.CodeDirectory, "code files"), + _ => (null, null), + }; - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully saved models from ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, UserSettings.Default.ModelDirectory, true); - }); - } - break; - case "Folders_Save_Animations": - foreach (var folder in folders) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.AnimationFolder(cancellationToken, folder); + if (string.IsNullOrEmpty(dirType)) + return; - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully saved animations from ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, UserSettings.Default.ModelDirectory, true); - }); - } - break; - case "Folders_Save_Audio": - foreach (var folder in folders) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.AudioFolder(cancellationToken, folder); + Action folderAction = bulktype switch + { + EBulkType.Raw => folder => contextViewModel.CUE4Parse.ExportFolder(cancellationToken, folder), + _ => folder => contextViewModel.CUE4Parse.ExtractFolder(cancellationToken, folder, bulktype | EBulkType.Auto), + }; - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully saved audio from ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, UserSettings.Default.AudioDirectory, true); - }); - } - break; - #endregion + foreach (var folder in folders) + { + cancellationToken.ThrowIfCancellationRequested(); + folderAction(folder); + + var path = Path.Combine(dirType, UserSettings.Default.KeepDirectoryStructure ? folder.PathAtThisPoint : folder.PathAtThisPoint.SubstringAfterLast('/')).Replace('\\', '/'); + LogExport(contextViewModel, folder.PathAtThisPoint, path, dirType, filetype); + } + + Action fileAction = bulktype switch + { + EBulkType.Raw => (entry, _, update) => contextViewModel.CUE4Parse.ExportData(entry, !update), + _ => (entry, bulk, update) => contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, bulk), + }; + + foreach (var group in assetsGroups) + { + var directory = group.Key; + var list = group.ToArray(); + var update = list.Length > 1; + var bulk = bulktype | (update ? EBulkType.Auto : EBulkType.None); + foreach (var entry in list) + { + Thread.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + fileAction(entry, bulk, update); + } + + if (update) + { + var path = Path.Combine(dirType, UserSettings.Default.KeepDirectoryStructure ? directory : directory.SubstringAfterLast('/')).Replace('\\', '/'); + LogExport(contextViewModel, directory, path, dirType, filetype); + } } }); } + + private void LogExport(ApplicationViewModel contextViewModel, string directory, string path, string basePath, string fileType) + { + if (contextViewModel.CUE4Parse.ExportedCount > 0) + { + FLogger.Append(ELog.Information, () => + { + FLogger.Text($"Successfully exported {contextViewModel.CUE4Parse.ExportedCount} {fileType} from ", Constants.WHITE); + FLogger.Link(directory, Path.Exists(path) ? path : basePath, true); + }); + } + else if (contextViewModel.CUE4Parse.FailedExportCount == 0) + { + // Not an error because folder simply might not contain type of asset user is trying to save + FLogger.Append(ELog.Warning, () => + { + FLogger.Text($"Failed to find any {fileType} in {directory}", Constants.WHITE, true); + }); + } + + if (contextViewModel.CUE4Parse.FailedExportCount > 0) + { + FLogger.Append(ELog.Error, () => + { + FLogger.Text($"Failed to export {contextViewModel.CUE4Parse.FailedExportCount} {fileType} from {directory}", Constants.WHITE, true); + }); + } + + Interlocked.Exchange(ref contextViewModel.CUE4Parse.ExportedCount, 0); + Interlocked.Exchange(ref contextViewModel.CUE4Parse.FailedExportCount, 0); + } } diff --git a/FModel/ViewModels/Commands/TabCommand.cs b/FModel/ViewModels/Commands/TabCommand.cs index 07d1f6c0..a622d5e4 100644 --- a/FModel/ViewModels/Commands/TabCommand.cs +++ b/FModel/ViewModels/Commands/TabCommand.cs @@ -34,34 +34,34 @@ public class TabCommand : ViewModelCommand case "Find_References": _applicationView.CUE4Parse.FindReferences(tabViewModel.Entry); break; - case "Asset_Export_Data": + case "Save_Data": await _threadWorkerView.Begin(_ => _applicationView.CUE4Parse.ExportData(tabViewModel.Entry)); break; - case "Asset_Save_Properties": + case "Save_Properties": await _threadWorkerView.Begin(cancellationToken => { _applicationView.CUE4Parse.Extract(cancellationToken, tabViewModel.Entry, false, EBulkType.Properties); }); break; - case "Asset_Save_Textures": + case "Save_Textures": await _threadWorkerView.Begin(cancellationToken => { _applicationView.CUE4Parse.Extract(cancellationToken, tabViewModel.Entry, false, EBulkType.Textures); }); break; - case "Asset_Save_Models": + case "Save_Models": await _threadWorkerView.Begin(cancellationToken => { _applicationView.CUE4Parse.Extract(cancellationToken, tabViewModel.Entry, false, EBulkType.Meshes); }); break; - case "Asset_Save_Animations": + case "Save_Animations": await _threadWorkerView.Begin(cancellationToken => { _applicationView.CUE4Parse.Extract(cancellationToken, tabViewModel.Entry, false, EBulkType.Animations); }); break; - case "Asset_Save_Audio": + case "Save_Audio": await _threadWorkerView.Begin(cancellationToken => { _applicationView.CUE4Parse.Extract(cancellationToken, tabViewModel.Entry, false, EBulkType.Audio); diff --git a/FModel/ViewModels/GameFileViewModel.cs b/FModel/ViewModels/GameFileViewModel.cs index adefb5f5..8f336ece 100644 --- a/FModel/ViewModels/GameFileViewModel.cs +++ b/FModel/ViewModels/GameFileViewModel.cs @@ -12,6 +12,7 @@ using CUE4Parse.GameTypes.Borderlands4.Assets.Exports; using CUE4Parse.GameTypes.FN.Assets.Exports.DataAssets; using CUE4Parse.GameTypes.SMG.UE4.Assets.Exports.Wwise; using CUE4Parse.GameTypes.SMG.UE4.Assets.Objects; +using CUE4Parse.GameTypes.SquareEnix.UE4.Assets.Exports; using CUE4Parse.UE4.Assets; using CUE4Parse.UE4.Assets.Exports; using CUE4Parse.UE4.Assets.Exports.Animation; @@ -67,6 +68,7 @@ public class GameFileViewModel(GameFile asset) : ViewModel private const int MaxPreviewSize = 128; private ApplicationViewModel _applicationView => ApplicationService.ApplicationView; + private EGame? GameVersion => _applicationView.CUE4Parse?.Provider.Versions.Game; public EResolveCompute Resolved { get; private set; } = EResolveCompute.None; public GameFile Asset { get; } = asset; @@ -244,9 +246,9 @@ public class GameFileViewModel(GameFile asset) : ViewModel UFMODBankLookup => (EAssetCategory.Data, EBulkType.None), - UFMODBus or UFMODSnapshot or UFMODSnapshotReverb or UFMODVCA => (EAssetCategory.Audio, EBulkType.None), + UFMODBus or UFMODSnapshot or UFMODSnapshotReverb or UFMODVCA or USQEXSEADSoundAttenuation => (EAssetCategory.Audio, EBulkType.None), - UFMODBank or UAkAudioBank or UAtomWaveBank or UAkInitBank => (EAssetCategory.SoundBank, EBulkType.Audio), + UFMODBank or UAkAudioBank or UAtomWaveBank or UAkInitBank or USQEXSEADSoundBank => (EAssetCategory.SoundBank, EBulkType.Audio), UWwiseAssetLibrary or USoundBase or UAkMediaAssetData or UAtomCueSheet or USoundAtomCueSheet or UAkAudioType or UExternalSource or UExternalSourceBank @@ -258,9 +260,9 @@ public class GameFileViewModel(GameFile asset) : ViewModel UNiagaraSystem or UNiagaraScriptBase or UParticleSystem => (EAssetCategory.Particle, EBulkType.None), // Game specific assets below - UBorderlandsDialogObject => (EAssetCategory.Borderlands, EBulkType.None), // Borderlands 3; - UGbxGraphAsset or UDialogScriptData or UDialogPerformanceData => (EAssetCategory.Borderlands, EBulkType.Audio), // Borderlands 4; Borderlands 3; - UFaceFXAnimSet when _applicationView.CUE4Parse?.Provider.Versions.Game is EGame.GAME_Borderlands4 => (EAssetCategory.Borderlands, EBulkType.Audio), // Borderlands 4; + UBorderlandsDialogObject when GameVersion is EGame.GAME_Borderlands3 => (EAssetCategory.Borderlands, EBulkType.None), // Borderlands 3; + UGbxGraphAsset or UDialogScriptData or UDialogPerformanceData when GameVersion is EGame.GAME_Borderlands4 or EGame.GAME_Borderlands3 => (EAssetCategory.Borderlands, EBulkType.Audio), // Borderlands 4; Borderlands 3; + UFaceFXAnimSet when GameVersion is EGame.GAME_Borderlands4 => (EAssetCategory.Borderlands, EBulkType.Audio), // Borderlands 4; _ => (EAssetCategory.All, EBulkType.None), }; @@ -355,7 +357,9 @@ public class GameFileViewModel(GameFile asset) : ViewModel case "csv": AssetCategory = EAssetCategory.Data; break; + case "stinfo": case "ushaderbytecode": + case "upipelinecache": AssetCategory = EAssetCategory.ByteCode; break; case "wav": @@ -430,10 +434,21 @@ public class GameFileViewModel(GameFile asset) : ViewModel }); } // Game specific extensions below - case "ace": // Borderlands 3 - case "ncs": // Borderlands 4 + case "ace" when GameVersion is EGame.GAME_Borderlands3: + case "ncs" when GameVersion is EGame.GAME_Borderlands4: AssetCategory = EAssetCategory.Borderlands; break; + case "dat" when GameVersion is EGame.GAME_Aion2: + AssetCategory = EAssetCategory.Aion2; + break; + case "bytes" when GameVersion is EGame.GAME_RocoKingdomWorld: + case "non" when GameVersion is EGame.GAME_RocoKingdomWorld: + case "cam" when GameVersion is EGame.GAME_RocoKingdomWorld: + AssetCategory = EAssetCategory.RocoKingdomWorld; + break; + case "ustbin" when GameVersion is EGame.GAME_DeltaForce: + AssetCategory = EAssetCategory.DeltaForce; + break; default: AssetCategory = EAssetCategory.All; // just so it sets resolved break; diff --git a/FModel/ViewModels/GameSelectorViewModel.cs b/FModel/ViewModels/GameSelectorViewModel.cs index 9cd43d67..369945b2 100644 --- a/FModel/ViewModels/GameSelectorViewModel.cs +++ b/FModel/ViewModels/GameSelectorViewModel.cs @@ -4,6 +4,8 @@ using Serilog; using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -67,12 +69,103 @@ public class GameSelectorViewModel : ViewModel public void AddUndetectedDir(string gameDirectory) => AddUndetectedDir(gameDirectory.SubstringAfterLast('\\'), gameDirectory); public void AddUndetectedDir(string gameName, string gameDirectory) { - var setting = DirectorySettings.Default(gameName, gameDirectory, true); + if (TryDetectUeVersion(gameDirectory, out var ueVersion, out var newGameDirectory)) + { + // gameDirectory = newGameDirectory; // directory was changed to point to the correct paks folder + } + + var setting = DirectorySettings.Default(gameName, gameDirectory, true, ueVersion); UserSettings.Default.PerDirectory[gameDirectory] = setting; _detectedDirectories.Add(setting); SelectedDirectory = DetectedDirectories.Last(); } + private bool TryDetectUeVersion(string gameDirectory, out EGame ueVersion, [MaybeNullWhen(false)] out string newGameDirectory) + { + var targetGameDir = gameDirectory; + if (!targetGameDir.EndsWith("Paks", StringComparison.OrdinalIgnoreCase)) + { + var dirs = Directory.GetDirectories(targetGameDir, "Paks", SearchOption.AllDirectories); + var paksDir = dirs.Length == 1 ? dirs[0] : dirs.FirstOrDefault(x => !x.EndsWith("Engine\\Programs\\CrashReportClient\\Content\\Paks")); + if (!string.IsNullOrEmpty(paksDir)) + { + Log.Warning("Selected directory \"{GameDirectory}\" does not end with \"Paks\". Looking in \"{PaksDir}\" instead.", targetGameDir, paksDir); + targetGameDir = paksDir; + } + + if (Directory.GetFiles(gameDirectory, "*.exe") is { Length: 1 } exe && TryGetUeVersionFromExe(exe[0], out ueVersion)) + { + // we checked the exe in the original directory, the BootstrapPackagedGame one + // but we still want c4p to use the paks folder as the game directory (if any), not the original one + newGameDirectory = targetGameDir; + Log.Information("Detected UE version {UeVersion} from \"{Exe}\"", ueVersion, exe[0]); + return true; + } + } + + // past this point, we assume targetGameDir is the correct Paks folder + newGameDirectory = targetGameDir; + var projectDir = Path.Combine(targetGameDir, "..", ".."); + + var projectBinariesDir = Path.Combine(projectDir, "Binaries", "Win64"); + if (Directory.Exists(projectBinariesDir)) + { + if (Directory.GetFiles(projectBinariesDir, "*-Win64-Shipping.exe") is { Length: > 0 } shipping) + { + foreach (var exe in shipping) + { + if (TryGetUeVersionFromExe(exe, out ueVersion)) + { + Log.Information("Detected UE version {UeVersion} from \"{Exe}\"", ueVersion, exe); + return true; + } + } + } + else if (Directory.GetFiles(projectBinariesDir, "*.exe") is { Length: < 3 } exes) + { + foreach (var exe in exes) + { + if (TryGetUeVersionFromExe(exe, out ueVersion)) + { + Log.Information("Detected UE version {UeVersion} from \"{Exe}\"", ueVersion, exe); + return true; + } + } + } + } + + var crashReportClientExe = Path.Combine(projectDir, "..", "Engine", "Binaries", "Win64", "CrashReportClient.exe"); + if (File.Exists(crashReportClientExe) && TryGetUeVersionFromExe(crashReportClientExe, out ueVersion)) + { + Log.Information("Detected UE version {UeVersion} from \"{Exe}\"", ueVersion, crashReportClientExe); + return true; + } + + ueVersion = EGame.GAME_UE4_LATEST; + Log.Warning("Failed to detect UE version for \"{GameDirectory}\".", gameDirectory); + return false; + } + + private bool TryGetUeVersionFromExe(string exePath, out EGame ueVersion) + { + ueVersion = EGame.GAME_UE4_LATEST; + try + { + var info = FileVersionInfo.GetVersionInfo(exePath); + ueVersion = info.FileMajorPart switch + { + 4 => (EGame) Math.Min((uint)(GameUtils.GameUe4Base + (info.FileMinorPart << 16)), (uint) EGame.GAME_UE4_LATEST), + 5 => (EGame) Math.Min((uint)(GameUtils.GameUe5Base + (info.FileMinorPart << 16)), (uint) EGame.GAME_UE5_LATEST), + _ => throw new InvalidOperationException($"Unsupported UE major version {info.FileMajorPart} detected from {exePath}") + }; + return true; + } + catch + { + return false; + } + } + public void DeleteSelectedGame() { UserSettings.Default.PerDirectory.Remove(SelectedDirectory.GameDirectory); // should not be a problem diff --git a/FModel/ViewModels/SettingsViewModel.cs b/FModel/ViewModels/SettingsViewModel.cs index becbf3a2..626f4227 100644 --- a/FModel/ViewModels/SettingsViewModel.cs +++ b/FModel/ViewModels/SettingsViewModel.cs @@ -195,6 +195,7 @@ public class SettingsViewModel : ViewModel private string _propertiesSnapshot; private string _textureSnapshot; private string _audioSnapshot; + private string _codeSnapshot; private string _modelSnapshot; private string _gameSnapshot; private ETexturePlatform _uePlatformSnapshot; @@ -227,6 +228,7 @@ public class SettingsViewModel : ViewModel _propertiesSnapshot = UserSettings.Default.PropertiesDirectory; _textureSnapshot = UserSettings.Default.TextureDirectory; _audioSnapshot = UserSettings.Default.AudioDirectory; + _codeSnapshot = UserSettings.Default.CodeDirectory; _modelSnapshot = UserSettings.Default.ModelDirectory; _gameSnapshot = UserSettings.Default.GameDirectory; _uePlatformSnapshot = UserSettings.Default.CurrentDir.TexturePlatform; @@ -303,12 +305,6 @@ public class SettingsViewModel : ViewModel if (_ueGameSnapshot != SelectedUeGame || _customVersionsSnapshot != SelectedCustomVersions || _uePlatformSnapshot != SelectedUePlatform || _optionsSnapshot != SelectedOptions || // combobox _mapStructTypesSnapshot != SelectedMapStructTypes || - _outputSnapshot != UserSettings.Default.OutputDirectory || // textbox - _rawDataSnapshot != UserSettings.Default.RawDataDirectory || // textbox - _propertiesSnapshot != UserSettings.Default.PropertiesDirectory || // textbox - _textureSnapshot != UserSettings.Default.TextureDirectory || // textbox - _audioSnapshot != UserSettings.Default.AudioDirectory || // textbox - _modelSnapshot != UserSettings.Default.ModelDirectory || // textbox _gameSnapshot != UserSettings.Default.GameDirectory) // textbox restart = true; diff --git a/FModel/ViewModels/TabControlViewModel.cs b/FModel/ViewModels/TabControlViewModel.cs index 9131169a..dbeb4423 100644 --- a/FModel/ViewModels/TabControlViewModel.cs +++ b/FModel/ViewModels/TabControlViewModel.cs @@ -1,6 +1,17 @@ using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading; +using System.Windows; +using System.Windows.Media.Imaging; +using CUE4Parse.FileProvider.Objects; +using CUE4Parse.UE4.Assets.Exports.Texture; +using CUE4Parse.Utils; +using CUE4Parse_Conversion.Textures; using FModel.Extensions; using FModel.Framework; +using FModel.Services; using FModel.Settings; using FModel.ViewModels.Commands; using FModel.Views.Resources.Controls; @@ -8,15 +19,6 @@ using ICSharpCode.AvalonEdit.Document; using ICSharpCode.AvalonEdit.Highlighting; using Serilog; using SkiaSharp; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Windows; -using System.Windows.Media.Imaging; -using CUE4Parse.UE4.Assets.Exports.Texture; -using CUE4Parse_Conversion.Textures; -using CUE4Parse.FileProvider.Objects; -using CUE4Parse.Utils; namespace FModel.ViewModels; @@ -374,8 +376,7 @@ public class TabItem : ViewModel public void SaveImage() => SaveImage(SelectedImage, true); private void SaveImage(TabImage image, bool updateUi) { - if (image == null) - return; + if (image is null) return; var path = Path.Combine(UserSettings.Default.TextureDirectory, UserSettings.Default.KeepDirectoryStructure ? Entry.Directory : "", image.ExportName).Replace('\\', '/'); @@ -392,6 +393,7 @@ public class TabItem : ViewModel private void SaveImage(TabImage image, string path) { + if (image.ImageBuffer is null) return; using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read); fs.Write(image.ImageBuffer, 0, image.ImageBuffer.Length); } @@ -407,11 +409,22 @@ public class TabItem : ViewModel Application.Current.Dispatcher.Invoke(() => File.WriteAllText(directory, Document.Text)); SaveCheck(directory, fileName, updateUi); } + public void SaveDecompiled(bool updateUi) + { + var fileName = Path.ChangeExtension(Entry.Name, ".cpp"); + var directory = Path.Combine(UserSettings.Default.PropertiesDirectory, + UserSettings.Default.KeepDirectoryStructure ? Entry.Directory : "", fileName).Replace('\\', '/'); + Directory.CreateDirectory(directory.SubstringBeforeLast('/')); + + Application.Current.Dispatcher.Invoke(() => File.WriteAllText(directory, Document.Text)); + SaveCheck(directory, fileName, updateUi); + } private void SaveCheck(string path, string fileName, bool updateUi) { if (File.Exists(path)) { + Interlocked.Increment(ref ApplicationService.ApplicationView.CUE4Parse.ExportedCount); Log.Information("{FileName} successfully saved", fileName); if (updateUi) { @@ -424,6 +437,7 @@ public class TabItem : ViewModel } else { + Interlocked.Increment(ref ApplicationService.ApplicationView.CUE4Parse.FailedExportCount); Log.Error("{FileName} could not be saved", fileName); if (updateUi) FLogger.Append(ELog.Error, () => FLogger.Text($"Could not save '{fileName}'", Constants.WHITE, true)); diff --git a/FModel/ViewModels/ThreadWorkerViewModel.cs b/FModel/ViewModels/ThreadWorkerViewModel.cs index c1fc7b44..59c49f19 100644 --- a/FModel/ViewModels/ThreadWorkerViewModel.cs +++ b/FModel/ViewModels/ThreadWorkerViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using CUE4Parse.UE4.Exceptions; using FModel.Framework; using FModel.Services; using FModel.Views.Resources.Controls; @@ -100,7 +101,26 @@ public class ThreadWorkerViewModel : ViewModel CurrentCancellationTokenSource = null; // kill token Log.Error("{Exception}", e); - FLogger.Append(e); + switch (e) + { + case MappingException: + FLogger.Append(ELog.Error, () => + { + FLogger.Text("Package has unversioned properties but mapping file (.usmap) is missing, can't serialize. See: ", Constants.WHITE); + FLogger.Link("→ link ←", Constants.MAPPING_ISSUE_LINK, true); + }); + break; + case VersionException v: // Error might be unrelated to version, but it's usually the case + FLogger.Append(ELog.Error, () => + { + FLogger.Text(v.Message[..^1] + ", can't serialize. Make sure the correct UE version is configured. See: ", Constants.WHITE); + FLogger.Link("→ link ←", Constants.VERSION_ISSUE_LINK, true); + }); + break; + default: + FLogger.Append(e); + break; + } return; } } diff --git a/FModel/ViewModels/UpdateViewModel.cs b/FModel/ViewModels/UpdateViewModel.cs index adbc79bd..26acdb88 100644 --- a/FModel/ViewModels/UpdateViewModel.cs +++ b/FModel/ViewModels/UpdateViewModel.cs @@ -81,6 +81,9 @@ public partial class UpdateViewModel : ViewModel if (username.Equals("Asval", StringComparison.OrdinalIgnoreCase)) { username = "4sval"; // found out the hard way co-authored usernames can't be trusted + } else if (username.Equals("Krowe Moh", StringComparison.OrdinalIgnoreCase)) + { + username = "Krowe-moh"; } coAuthorMap[commit].Add(username); @@ -101,7 +104,7 @@ public partial class UpdateViewModel : ViewModel } catch { - // + // Ignore } } diff --git a/FModel/Views/DirectorySelector.xaml b/FModel/Views/DirectorySelector.xaml index 22f27a2a..3bc8b1de 100644 --- a/FModel/Views/DirectorySelector.xaml +++ b/FModel/Views/DirectorySelector.xaml @@ -1,4 +1,4 @@ - + Width="{Binding Source={x:Static SystemParameters.MaximizedPrimaryScreenWidth}, Converter={converters:RatioConverter}, ConverterParameter='0.25'}" + AllowDrop="True">