From cf1f19f615a4965ce2f023ec5b21d69d39b59522 Mon Sep 17 00:00:00 2001 From: Masusder <59669685+Masusder@users.noreply.github.com> Date: Wed, 14 May 2025 22:23:45 +0200 Subject: [PATCH] Semi-support for cross-soundbanks audio events Unfortunately in case some game splits audio events across multiple soundbanks and given game has thousands of them, custom implementation for that game would be required --- CUE4Parse | 2 +- FModel/ViewModels/CUE4ParseViewModel.cs | 162 ++++++++++++++++++------ 2 files changed, 125 insertions(+), 39 deletions(-) diff --git a/CUE4Parse b/CUE4Parse index 8d95611f..330f706c 160000 --- a/CUE4Parse +++ b/CUE4Parse @@ -1 +1 @@ -Subproject commit 8d95611f407181e07d849ebe594f0133e88c55f9 +Subproject commit 330f706c1f04389dc4da37382365d727b5fc09a0 diff --git a/FModel/ViewModels/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs index c32b5781..c5520992 100644 --- a/FModel/ViewModels/CUE4ParseViewModel.cs +++ b/FModel/ViewModels/CUE4ParseViewModel.cs @@ -61,6 +61,7 @@ using SkiaSharp; using UE4Config.Parsing; using Application = System.Windows.Application; using FGuid = CUE4Parse.UE4.Objects.Core.Misc.FGuid; +using CUE4Parse.UE4.Wwise.Objects; namespace FModel.ViewModels; @@ -759,7 +760,12 @@ public class CUE4ParseViewModel : ViewModel if (CheckExport(cancellationToken, result.Package, i)) break; } - } + } + + private readonly Dictionary _wwiseHierarchyTables = []; + private readonly Dictionary _wwiseEncodedMedia = []; + private readonly List _wwiseLoadedSoundBanks = []; + private bool _completedWwiseFullBnkInit = false; private bool CheckExport(CancellationToken cancellationToken, IPackage pkg, int index, EBulkType bulk = EBulkType.None) // return true once you wanna stop searching for exports { @@ -838,17 +844,20 @@ public class CUE4ParseViewModel : ViewModel } case UAkAudioEvent when isNone && pointer.Object.Value is UAkAudioEvent { EventCookedData: { } wwiseData }: { + var visitedWemIds = new HashSet(); // To prevent duplicates foreach (var kvp in wwiseData.EventLanguageMap) { if (!kvp.Value.HasValue) - continue; - + continue; + var projectName = string.IsNullOrEmpty(Provider.ProjectName) ? "Game" : Provider.ProjectName; var baseWwiseAudioPath = DetermineBaseWwiseAudioPath(projectName, kvp.Value.Value); var audioEventPath = pointer.Object.Value.GetPathName().StartsWith("/Game") ? string.Concat(projectName, pointer.Object.Value.GetPathName().AsSpan(5)) : pointer.Object.Value.GetPathName(); + BulkInitializeWwiseSoundBanks(baseWwiseAudioPath); + foreach (var soundBank in kvp.Value.Value.SoundBanks) { if (!soundBank.bContainsMedia) @@ -856,33 +865,19 @@ public class CUE4ParseViewModel : ViewModel var soundBankName = soundBank.SoundBankPathName.ToString(); var soundBankPath = Path.Combine(baseWwiseAudioPath, soundBankName); - var audioEventId = kvp.Value.Value.EventId.ToString(); - - if (!Provider.TrySaveAsset(soundBankPath, out byte[] data)) - continue; - - using var ar = new FByteArchive(soundBankName, data); - var wwiseReader = new WwiseReader(ar); - - var hierarchyTable = new Dictionary(); - foreach (var hierarchy in wwiseReader.Hierarchies) - { - uint id = hierarchy.Data.Id; - - if (!hierarchyTable.ContainsKey(id)) - { - hierarchyTable.Add(id, hierarchy); - } - } - + var audioEventId = kvp.Value.Value.EventId.ToString(); + + TryLoadAndCacheSoundBank(soundBankPath, soundBankName, out _); + + var visitedDecisionNodes = new HashSet<(uint parentHierarchyId, uint audioNodeId)>(); // To prevent infinite loops long parsedId = long.Parse(audioEventId); uint parsedAudioEventId = (uint) parsedId; - if (hierarchyTable.TryGetValue(parsedAudioEventId, out var eventHierarchy) && + if (_wwiseHierarchyTables.TryGetValue(parsedAudioEventId, out var eventHierarchy) && eventHierarchy.Data is HierarchyEvent hierarchyEvent) { foreach (var actionId in hierarchyEvent.EventActionIds) { - if (!hierarchyTable.TryGetValue(actionId, out var actionHierarchy) || + if (!_wwiseHierarchyTables.TryGetValue(actionId, out var actionHierarchy) || actionHierarchy.Data is not HierarchyEventAction eventAction) continue; @@ -891,10 +886,9 @@ public class CUE4ParseViewModel : ViewModel } // TODO: If EventActionPlay points to different soundbank ID than we're currently in, use `wwiseReader.IdToString` to convert to bank name, serialize it, and continue traversing from there - // TODO: It's possible for switch container to point to a different soundbank without referencing it in any way. I don't know how to handle that yet void TraverseAndSave(uint id) { - if (!hierarchyTable.TryGetValue(id, out var hierarchy)) + if (!_wwiseHierarchyTables.TryGetValue(id, out var hierarchy)) return; switch (hierarchy.Data) @@ -911,11 +905,28 @@ public class CUE4ParseViewModel : ViewModel case HierarchyMusicSwitchContainer musicSwitchContainer: foreach (var childId in musicSwitchContainer.ChildIds) TraverseAndSave(childId); - break; + + foreach (var node in musicSwitchContainer.DecisionTree.Nodes) + foreach (var nodeChild in node.Children) + TraverseDecisionTreeNode(nodeChild, musicSwitchContainer.Id); + + void TraverseDecisionTreeNode(AkDecisionTreeNode node, uint parentHierarchyId) + { + var key = (parentHierarchyId, node.AudioNodeId); + if (!visitedDecisionNodes.Add(key)) + return; + + foreach (var nodeChildTraverse in node.Children) + { + TraverseAndSave(nodeChildTraverse.AudioNodeId); + TraverseDecisionTreeNode(nodeChildTraverse, parentHierarchyId); + } + } + break; case HierarchyMusicTrack musicTrack: foreach (var playlist in musicTrack.Playlist) - SaveWemSound((uint) playlist.SourceID); + SaveWemSound(playlist.SourceId); break; case HierarchyMusicSegment musicSegment: @@ -941,8 +952,11 @@ public class CUE4ParseViewModel : ViewModel } void SaveWemSound(uint wemId) - { - if (wwiseReader.WwiseEncodedMedias.TryGetValue(wemId.ToString(), out var wemData)) + { + if (!visitedWemIds.Add(wemId)) + return; + + if (_wwiseEncodedMedia.TryGetValue(wemId.ToString(), out var wemData)) { var debugName = kvp.Value.Value.DebugName.ToString(); var fileName = $"{debugName.Replace('\\', '/')} ({wemId})"; @@ -973,10 +987,8 @@ public class CUE4ParseViewModel : ViewModel ? media.DebugName.Text.SubstringBeforeLast('.') : Path.GetFileNameWithoutExtension(mediaRelativePath); - var namedPath = Path.Combine( - projectName, - "Content", - "WwiseAudio", + var namedPath = Path.Combine( + baseWwiseAudioPath, $"{debugName.Replace('\\', '/')} ({kvp.Key.LanguageName.Text})" ); @@ -1166,15 +1178,14 @@ public class CUE4ParseViewModel : ViewModel { var files = Provider.Files.Values.ToList(); - // Most common directory - var baseWwiseAudioPath = Path.Combine(projectName, "Content", "WwiseAudio"); + var baseWwiseAudioPath = Path.Combine(projectName, "Content", "WwiseAudio"); // Most common directory var soundBankName = value.SoundBanks.FirstOrDefault().SoundBankPathName.ToString() ?? string.Empty; var mediaPathName = value.Media.FirstOrDefault().MediaPathName.Text ?? string.Empty; if (!string.IsNullOrEmpty(soundBankName)) { - var matchingFile = files.FirstOrDefault(f => f.Path.Contains(soundBankName)); + GameFile matchingFile = files.FirstOrDefault(f => f.Path.Contains(soundBankName)); if (matchingFile != null) { var matchingDirectory = matchingFile.Path[..matchingFile.Path.LastIndexOf(soundBankName)]; @@ -1185,7 +1196,7 @@ public class CUE4ParseViewModel : ViewModel if (!string.IsNullOrEmpty(mediaPathName)) { - var matchingFile = files.FirstOrDefault(f => f.Path.Contains(mediaPathName)); + GameFile matchingFile = files.FirstOrDefault(f => f.Path.Contains(mediaPathName)); if (matchingFile != null) { var matchingDirectory = matchingFile.Path[..matchingFile.Path.LastIndexOf(mediaPathName)]; @@ -1195,5 +1206,80 @@ public class CUE4ParseViewModel : ViewModel } return baseWwiseAudioPath; + } + + private void BulkInitializeWwiseSoundBanks(string baseWwiseAudioPath) + { + if (_completedWwiseFullBnkInit) + return; + + // Important note: If game splits audio event hierarchies across multiple soundbanks and either of these limits is reached, given game requires custom loading implementation! + const long MAX_TOTAL_WWISE_SIZE = 2L * 1024 * 1024 * 1024; // 2 GB + const int MAX_BANK_FILES = 500; + + long totalLoadedSize = 0; + int totalLoadedBanks = 0; + + IEnumerable soundBankFiles = Provider.Files.Values + .Where(file => string.Equals(file.Extension, "bnk", StringComparison.OrdinalIgnoreCase)) + .Where(file => file.Path.StartsWith(baseWwiseAudioPath.Replace("\\", "/"), StringComparison.OrdinalIgnoreCase)); + + foreach (var soundbank in soundBankFiles) + { + if (totalLoadedBanks >= MAX_BANK_FILES) + break; + + string fullPath = soundbank.Path; + string relPath = fullPath[baseWwiseAudioPath.Length..].TrimStart('/', '\\'); + + if (!TryLoadAndCacheSoundBank(fullPath, relPath, out var size)) + continue; + + if (totalLoadedSize + size > MAX_TOTAL_WWISE_SIZE) + break; + + totalLoadedSize += size; + totalLoadedBanks += 1; + } + + _completedWwiseFullBnkInit = true; + } + + private bool TryLoadAndCacheSoundBank(string fullAbsolutePath, string relativePath, out long fileSize) + { + fileSize = 0; + + if (_wwiseLoadedSoundBanks.Contains(relativePath)) + return false; + + if (!Provider.TrySaveAsset(fullAbsolutePath, out byte[] data)) + return false; + + fileSize = data.LongLength; + + using var archive = new FByteArchive(relativePath, data); + var wwiseReader = new WwiseReader(archive); + + if (wwiseReader.Hierarchies != null) + { + foreach (var h in wwiseReader.Hierarchies) + { + uint id = h.Data.Id; + if (!_wwiseHierarchyTables.ContainsKey(id)) + _wwiseHierarchyTables[id] = h; + } + } + + if (wwiseReader.WwiseEncodedMedias != null) + { + foreach (var kv in wwiseReader.WwiseEncodedMedias) + { + if (!_wwiseEncodedMedia.ContainsKey(kv.Key)) + _wwiseEncodedMedia[kv.Key] = kv.Value; + } + } + + _wwiseLoadedSoundBanks.Add(relativePath); + return true; } }