diff --git a/CUE4Parse b/CUE4Parse index 166d6707..75f3878b 160000 --- a/CUE4Parse +++ b/CUE4Parse @@ -1 +1 @@ -Subproject commit 166d67076273f6e717adcc15cd57b759abf8e987 +Subproject commit 75f3878b7a348cbda3927048b142a0a6923e79c0 diff --git a/FModel/ViewModels/AudioPlayerViewModel.cs b/FModel/ViewModels/AudioPlayerViewModel.cs index 26ed628c..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; @@ -664,23 +665,11 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable 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)) - { - vgmFilePath = Path.Combine(UserSettings.Default.OutputDirectory, ".data", "vgmstream-cli.exe"); - if (!File.Exists(vgmFilePath)) - { - Log.Error("Failed to convert {InputFilePath}, vgmstream is missing", inputFilePath); - 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 false; - } - } + var vgmStreamPath = TryGetVgmstreamPath(); + if (string.IsNullOrEmpty(vgmStreamPath)) + return false; - var success = TryConvertToWAV(inputFilePath, inputFileData, vgmFilePath, true, out wavFilePath); + var success = TryConvertToWav(inputFilePath, inputFileData, vgmStreamPath, true, out wavFilePath); if (!success) { @@ -713,10 +702,10 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable return false; } - return TryConvertToWAV(SelectedAudioFile.FilePath, SelectedAudioFile.Data, decoderPath, false, out rawFilePath); + return TryConvertToWav(SelectedAudioFile.FilePath, SelectedAudioFile.Data, decoderPath, false, out rawFilePath); } - private static bool TryConvertToWAV(string inputFilePath, byte[] inputFileData, string converterPath, bool usevgmstream, out string wavFilePath) + 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); @@ -746,4 +735,74 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable 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 26af56c6..9f1dfa3f 100644 --- a/FModel/ViewModels/CUE4ParseViewModel.cs +++ b/FModel/ViewModels/CUE4ParseViewModel.cs @@ -5,14 +5,11 @@ using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; -using System.Net.Http.Headers; 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 +19,12 @@ 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.KRD.Assets.Exports; +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; @@ -57,14 +55,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; @@ -73,21 +68,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; @@ -1171,20 +1159,20 @@ public class CUE4ParseViewModel : ViewModel 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; } @@ -1207,6 +1195,22 @@ 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: { @@ -1443,8 +1447,10 @@ public class CUE4ParseViewModel : ViewModel 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 (saveAudio) { @@ -1453,7 +1459,7 @@ public class CUE4ParseViewModel : ViewModel Directory.CreateDirectory(directory); bool conversionSuccess = true; - if (UserSettings.Default.ConvertAudioOnBulkExport) + if (UserSettings.Default.ConvertAudioOnBulkExport && extLower is not "wav") { if (AudioPlayerViewModel.TryConvert(savedAudioPath, data, out string wavFilePath)) savedAudioPath = wavFilePath; diff --git a/FModel/ViewModels/GameFileViewModel.cs b/FModel/ViewModels/GameFileViewModel.cs index b5e6cd47..1b57c5b7 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; @@ -245,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 @@ -259,8 +260,8 @@ 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; + 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),