diff --git a/.gitignore b/.gitignore
index 7c07a502a..f82b03485 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,6 +47,7 @@ dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
+perf-results/
# .NET Core
project.lock.json
@@ -221,7 +222,7 @@ ClientBin/
*.publishsettings
orleans.codegen.cs
-# Including strong name files can present a security risk
+# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
@@ -317,7 +318,7 @@ __pycache__/
# OpenCover UI analysis results
OpenCover/
-# Azure Stream Analytics local run output
+# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
@@ -326,7 +327,7 @@ ASALocalRun/
# NVidia Nsight GPU debugger configuration file
*.nvuser
-# MFractors (Xamarin productivity tool) working folder
+# MFractors (Xamarin productivity tool) working folder
.mfractor/
*.bak
Test/Test - Backup.csproj
diff --git a/Directory.Build.targets b/Directory.Build.targets
new file mode 100644
index 000000000..a95f23ac0
--- /dev/null
+++ b/Directory.Build.targets
@@ -0,0 +1,5 @@
+
+
+ 13.0
+
+
diff --git a/UndertaleModCli/CommandOptions/DumpOptions.cs b/UndertaleModCli/CommandOptions/DumpOptions.cs
index e4f9578dd..ae058a8de 100644
--- a/UndertaleModCli/CommandOptions/DumpOptions.cs
+++ b/UndertaleModCli/CommandOptions/DumpOptions.cs
@@ -41,4 +41,19 @@ public class DumpOptions
/// Determines if all sprites should get dumped
///
public bool Sprites { get; set; }
+
+ ///
+ /// Determines if all sounds should get dumped
+ ///
+ public bool Sounds { get; set; }
+
+ ///
+ /// Determines if external audio files should be copied when dumping sounds
+ ///
+ public bool CopyExternalAudio { get; set; }
+
+ ///
+ /// Determines if sounds should be grouped by audio group
+ ///
+ public bool GroupSoundsByAudioGroup { get; set; }
}
diff --git a/UndertaleModCli/Program.cs b/UndertaleModCli/Program.cs
index b5d34ca3f..18bcf71bc 100644
--- a/UndertaleModCli/Program.cs
+++ b/UndertaleModCli/Program.cs
@@ -1,4 +1,4 @@
-using Microsoft.CodeAnalysis.CSharp.Scripting;
+using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Newtonsoft.Json;
using System;
@@ -109,7 +109,7 @@ public partial class Program : IScriptInterface
public static int Main(string[] args)
{
// Give a friendly message when not running from an actual console, on Windows... (and when no arguments are passed in)
- if (OperatingSystem.IsWindows() && args.Length == 0 && Console.GetCursorPosition() == (0, 0))
+ if (OperatingSystem.IsWindows() && args.Length == 0 && IsAtInitialConsoleCursorPosition())
{
Console.WriteLine("UndertaleModCli is meant to be executed from a command line terminal/console.\nDid you mean to download the GUI version of UndertaleModTool instead?\n\n(Press any key to dismiss this message.)");
Console.ReadKey();
@@ -205,6 +205,9 @@ public static int Main(string[] args)
Option dumpStringsOption = new("-s", "--strings") { Description = "Whether to dump all strings" };
Option dumpTexturesOption = new("-t", "--textures") { Description = "Whether to dump all embedded textures" };
Option dumpSpritesOption = new("--sprites") { Description = "Whether to dump all sprites" };
+ Option dumpSoundsOption = new("--sounds") { Description = "Whether to dump all sounds" };
+ Option dumpCopyExternalAudioOption = new("--copy-external-audio") { Description = "Copy external audio files when dumping sounds" };
+ Option dumpGroupSoundsOption = new("--group-sounds-by-audio-group") { Description = "Group dumped sounds by audio group" };
Command dumpCommand = new("dump", "Dump certain properties about the game data file")
{
dataFileArgument,
@@ -213,7 +216,10 @@ public static int Main(string[] args)
dumpCodeOption,
dumpStringsOption,
dumpTexturesOption,
- dumpSpritesOption
+ dumpSpritesOption,
+ dumpSoundsOption,
+ dumpCopyExternalAudioOption,
+ dumpGroupSoundsOption
};
dumpCommand.SetAction(parseResult =>
{
@@ -225,7 +231,10 @@ public static int Main(string[] args)
Code = parseResult.GetValue(dumpCodeOption),
Strings = parseResult.GetValue(dumpStringsOption),
Textures = parseResult.GetValue(dumpTexturesOption),
- Sprites = parseResult.GetValue(dumpSpritesOption)
+ Sprites = parseResult.GetValue(dumpSpritesOption),
+ Sounds = parseResult.GetValue(dumpSoundsOption),
+ CopyExternalAudio = parseResult.GetValue(dumpCopyExternalAudioOption),
+ GroupSoundsByAudioGroup = parseResult.GetValue(dumpGroupSoundsOption)
});
});
@@ -306,6 +315,36 @@ public static int Main(string[] args)
return parseResult.Invoke();
}
+ private static bool IsAtInitialConsoleCursorPosition()
+ {
+ try
+ {
+ return Console.GetCursorPosition() == (0, 0);
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ }
+
+ internal static string ReserveUniqueSoundDestinationPath(
+ string directory,
+ string soundName,
+ string extension,
+ ISet reservedDestinationPaths)
+ {
+ string destinationPath = Paths.JoinVerifyWithinDirectory(directory, soundName + extension);
+ if (reservedDestinationPaths.Add(destinationPath))
+ return destinationPath;
+
+ for (int suffix = 1; ; suffix++)
+ {
+ destinationPath = Paths.JoinVerifyWithinDirectory(directory, $"{soundName}_{suffix}{extension}");
+ if (reservedDestinationPaths.Add(destinationPath))
+ return destinationPath;
+ }
+ }
+
public Program(FileInfo datafile, FileInfo[] scripts, FileInfo output, bool verbose = false, bool interactive = false)
{
this.Verbose = verbose;
@@ -334,6 +373,8 @@ public Program(FileInfo datafile, bool verbose, DirectoryInfo output = null)
Console.WriteLine($"Trying to load file: '{datafile.FullName}'");
this.Verbose = verbose;
+ this.FilePath = datafile.FullName;
+ this.ExePath = Environment.CurrentDirectory;
this.Data = ReadDataFile(datafile, verbose ? WarningHandler : null, verbose ? MessageHandler : null);
this.Output = output ?? new DirectoryInfo(datafile.DirectoryName);
@@ -489,15 +530,15 @@ private static int Dump(DumpOptions options)
return EXIT_FAILURE;
}
- if (program.Data.IsYYC())
+ bool requestedCodeDump = options.Code?.Length > 0;
+ if (program.Data.IsYYC() && requestedCodeDump)
{
Console.WriteLine("The game was made with YYC (YoYo Compiler), which means that the code was compiled into the executable. " +
- "There is thus no code to dump. Exiting.");
- return EXIT_SUCCESS;
+ "There is thus no code to dump.");
}
// If user provided code to dump, dump code
- if ((options.Code?.Length > 0) && (program.Data.Code?.Count > 0))
+ if (requestedCodeDump && !program.Data.IsYYC() && (program.Data.Code?.Count > 0))
{
// If user wanted to dump everything, do that, otherwise only dump what user provided
string[] codeArray;
@@ -522,6 +563,10 @@ private static int Dump(DumpOptions options)
if (options.Sprites)
program.DumpAllSprites().Wait();
+ // If user wanted to dump sounds, dump all of them
+ if (options.Sounds)
+ program.DumpAllSounds(options.CopyExternalAudio, options.GroupSoundsByAudioGroup);
+
return EXIT_SUCCESS;
}
@@ -973,6 +1018,173 @@ void ExportTextures()
}
}
+ ///
+ /// Dumps all sounds in a data file.
+ ///
+ private void DumpAllSounds(bool copyExternalAudio, bool groupByAudioGroup)
+ {
+ const string DefaultAudioGroupName = "audiogroup_default";
+ byte[] emptyWavFileBytes = Convert.FromBase64String("UklGRiQAAABXQVZFZm10IBAAAAABAAIAQB8AAAB9AAAEABAAZGF0YQAAAAA=");
+ string baseDirectory = Path.Join(Output.FullName, "Sounds");
+ string dataDirectory = Path.GetDirectoryName(FilePath);
+ int builtinSoundGroupId = Data.GetBuiltinSoundGroupID();
+ Dictionary> loadedAudioGroups = new();
+ List<(string SoundName, string DestinationPath, byte[] Data, string SourcePath)> dumpJobs = new();
+ Dictionary audioGroupDirectories = new(StringComparer.OrdinalIgnoreCase);
+ Dictionary externalDirectories = new(StringComparer.OrdinalIgnoreCase);
+ HashSet createdDirectories = new(StringComparer.OrdinalIgnoreCase);
+ HashSet reservedDestinationPaths = new(StringComparer.OrdinalIgnoreCase);
+
+ EnsureDirectory(baseDirectory);
+
+ foreach (UndertaleSound sound in Data.Sounds)
+ {
+ if (sound is null)
+ continue;
+
+ DumpSound(sound);
+ }
+
+ int maxParallelism = Math.Clamp(Environment.ProcessorCount, 1, 8);
+ Parallel.ForEach(dumpJobs, new ParallelOptions { MaxDegreeOfParallelism = maxParallelism }, job =>
+ {
+ if (Verbose)
+ Console.WriteLine($"Dumping {job.SoundName}");
+
+ if (job.Data is not null)
+ File.WriteAllBytes(job.DestinationPath, job.Data);
+ else
+ File.Copy(job.SourcePath, job.DestinationPath, true);
+ });
+
+ void DumpSound(UndertaleSound sound)
+ {
+ string soundName = sound.Name.Content;
+ string audioGroupName = GetAudioGroupName(sound);
+ string soundDirectory = groupByAudioGroup
+ ? GetAudioGroupDirectory(audioGroupName)
+ : baseDirectory;
+
+ bool flagCompressed = sound.Flags.HasFlag(UndertaleSound.AudioEntryFlags.IsCompressed);
+ bool flagEmbedded = sound.Flags.HasFlag(UndertaleSound.AudioEntryFlags.IsEmbedded);
+ bool isEmbedded = true;
+ string audioExtension = ".ogg";
+
+ if (flagEmbedded && !flagCompressed)
+ {
+ audioExtension = ".wav";
+ }
+ else if (!flagCompressed && !flagEmbedded)
+ {
+ isEmbedded = false;
+ }
+
+ if (!isEmbedded)
+ {
+ if (copyExternalAudio)
+ CopyExternalSound(sound, soundName, audioExtension, audioGroupName);
+ return;
+ }
+
+ byte[] soundData = GetSoundData(sound);
+ string destinationPath = ReserveUniqueSoundDestinationPath(soundDirectory, soundName, audioExtension, reservedDestinationPaths);
+ dumpJobs.Add((soundName, destinationPath, soundData, null));
+ }
+
+ string GetAudioGroupName(UndertaleSound sound) =>
+ sound.AudioGroup?.Name?.Content ?? DefaultAudioGroupName;
+
+ void CopyExternalSound(UndertaleSound sound, string soundName, string audioExtension, string audioGroupName)
+ {
+ string externalFilename = sound.File.Content;
+ if (!externalFilename.Contains('.'))
+ externalFilename += ".ogg";
+
+ string sourcePath = Paths.JoinVerifyWithinDirectory(dataDirectory, externalFilename);
+ string externalDirectory = GetExternalDirectory(audioGroupName);
+
+ string destinationPath = ReserveUniqueSoundDestinationPath(externalDirectory, soundName, audioExtension, reservedDestinationPaths);
+ dumpJobs.Add((soundName, destinationPath, null, sourcePath));
+ }
+
+ byte[] GetSoundData(UndertaleSound sound)
+ {
+ if (sound.AudioFile is not null)
+ return sound.AudioFile.Data;
+
+ if (sound.GroupID > builtinSoundGroupId)
+ {
+ IList audioGroup = GetAudioGroupData(sound);
+ if (audioGroup is not null)
+ return audioGroup[sound.AudioID].Data;
+ }
+
+ return emptyWavFileBytes;
+ }
+
+ string GetAudioGroupDirectory(string audioGroupName)
+ {
+ if (audioGroupDirectories.TryGetValue(audioGroupName, out string directory))
+ return directory;
+
+ directory = Paths.JoinVerifyWithinDirectory(baseDirectory, audioGroupName);
+ EnsureDirectory(directory);
+ audioGroupDirectories[audioGroupName] = directory;
+ return directory;
+ }
+
+ string GetExternalDirectory(string audioGroupName)
+ {
+ string key = groupByAudioGroup ? audioGroupName : string.Empty;
+ if (externalDirectories.TryGetValue(key, out string directory))
+ return directory;
+
+ directory = groupByAudioGroup
+ ? Paths.JoinVerifyWithinDirectory(GetAudioGroupDirectory(audioGroupName), "external")
+ : Paths.JoinVerifyWithinDirectory(baseDirectory, "external");
+ EnsureDirectory(directory);
+ externalDirectories[key] = directory;
+ return directory;
+ }
+
+ IList GetAudioGroupData(UndertaleSound sound)
+ {
+ string audioGroupName = GetAudioGroupName(sound);
+ if (loadedAudioGroups.TryGetValue(audioGroupName, out IList cachedAudioGroup))
+ return cachedAudioGroup;
+
+ string relativeAudioGroupPath = sound.AudioGroup is UndertaleAudioGroup { Path.Content: string customRelativePath }
+ ? customRelativePath
+ : $"audiogroup{sound.GroupID}.dat";
+ string groupFilePath = Paths.JoinVerifyWithinDirectory(dataDirectory, relativeAudioGroupPath);
+ if (!File.Exists(groupFilePath))
+ return null;
+
+ try
+ {
+ UndertaleData data;
+ using (FileStream stream = new(groupFilePath, FileMode.Open, FileAccess.Read))
+ {
+ data = UndertaleIO.Read(stream, (warning, _) => Console.Error.WriteLine($"WARNING: {warning}"));
+ }
+
+ loadedAudioGroups[audioGroupName] = data.EmbeddedAudio;
+ return data.EmbeddedAudio;
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"An error occurred while trying to load {audioGroupName}: {e.Message}");
+ return null;
+ }
+ }
+
+ void EnsureDirectory(string directory)
+ {
+ if (createdDirectories.Add(directory))
+ Directory.CreateDirectory(directory);
+ }
+ }
+
///
/// Replaces a code entry with text from another file.
///
diff --git a/UndertaleModCli/UndertaleModCli.csproj b/UndertaleModCli/UndertaleModCli.csproj
index 0b744f570..18f3ae671 100644
--- a/UndertaleModCli/UndertaleModCli.csproj
+++ b/UndertaleModCli/UndertaleModCli.csproj
@@ -14,6 +14,12 @@
1591
+
+
+ <_Parameter1>UndertaleModTests
+
+
+
diff --git a/UndertaleModLib/UndertaleModLib.csproj b/UndertaleModLib/UndertaleModLib.csproj
index 602a1f653..246bc0339 100644
--- a/UndertaleModLib/UndertaleModLib.csproj
+++ b/UndertaleModLib/UndertaleModLib.csproj
@@ -31,7 +31,9 @@
-
+
+ all
+
diff --git a/UndertaleModTests/CliDumpTests.cs b/UndertaleModTests/CliDumpTests.cs
new file mode 100644
index 000000000..6c4ee3a10
--- /dev/null
+++ b/UndertaleModTests/CliDumpTests.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace UndertaleModTests
+{
+ [TestClass]
+ public class CliDumpTests
+ {
+ [TestMethod]
+ public void ReserveUniqueSoundDestinationPathAddsSuffixForDuplicateNames()
+ {
+ string directory = Path.Combine(Path.GetTempPath(), $"umt-cli-dump-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(directory);
+ try
+ {
+ HashSet reservedPaths = new(StringComparer.OrdinalIgnoreCase);
+
+ string firstPath = UndertaleModCli.Program.ReserveUniqueSoundDestinationPath(
+ directory,
+ "snd_duplicate",
+ ".ogg",
+ reservedPaths);
+ string secondPath = UndertaleModCli.Program.ReserveUniqueSoundDestinationPath(
+ directory,
+ "snd_duplicate",
+ ".ogg",
+ reservedPaths);
+
+ Assert.AreEqual(Path.Join(directory, "snd_duplicate.ogg"), firstPath);
+ Assert.AreEqual(Path.Join(directory, "snd_duplicate_1.ogg"), secondPath);
+ Assert.AreEqual(2, reservedPaths.Count);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+ }
+}
diff --git a/UndertaleModTests/ScriptCompileTests.cs b/UndertaleModTests/ScriptCompileTests.cs
new file mode 100644
index 000000000..68804d531
--- /dev/null
+++ b/UndertaleModTests/ScriptCompileTests.cs
@@ -0,0 +1,56 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Scripting;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using UndertaleModLib.Scripting;
+using UndertaleModLib.Util;
+
+namespace UndertaleModTests
+{
+ [TestClass]
+ public class ScriptCompileTests
+ {
+ [TestMethod]
+ public void ExportAllSoundsScriptCompiles()
+ {
+ string scriptPath = FindRepositoryFile(
+ Path.Combine("UndertaleModTool", "Scripts", "Resource Exporters", "ExportAllSounds.csx"));
+ string scriptText = $"#line 1 \"{scriptPath}\"{Environment.NewLine}" +
+ File.ReadAllText(scriptPath, Encoding.UTF8);
+
+ var script = CSharpScript.Create