diff --git a/Sledge.Formats.Configuration.Tests/Resources/Worldcraft/CmdSeq-wc15.wc b/Sledge.Formats.Configuration.Tests/Resources/Worldcraft/CmdSeq-wc15.wc new file mode 100644 index 0000000..f16f4c9 Binary files /dev/null and b/Sledge.Formats.Configuration.Tests/Resources/Worldcraft/CmdSeq-wc15.wc differ diff --git a/Sledge.Formats.Configuration.Tests/Resources/Worldcraft/CmdSeq-wc33.wc b/Sledge.Formats.Configuration.Tests/Resources/Worldcraft/CmdSeq-wc33.wc new file mode 100644 index 0000000..9740c85 Binary files /dev/null and b/Sledge.Formats.Configuration.Tests/Resources/Worldcraft/CmdSeq-wc33.wc differ diff --git a/Sledge.Formats.Configuration.Tests/Sledge.Formats.Configuration.Tests.csproj b/Sledge.Formats.Configuration.Tests/Sledge.Formats.Configuration.Tests.csproj index beda79a..9d5a414 100644 --- a/Sledge.Formats.Configuration.Tests/Sledge.Formats.Configuration.Tests.csproj +++ b/Sledge.Formats.Configuration.Tests/Sledge.Formats.Configuration.Tests.csproj @@ -9,11 +9,15 @@ + + + + diff --git a/Sledge.Formats.Configuration.Tests/TestWorldcraftConfiguration.cs b/Sledge.Formats.Configuration.Tests/TestWorldcraftConfiguration.cs index 575aa71..0156ba0 100644 --- a/Sledge.Formats.Configuration.Tests/TestWorldcraftConfiguration.cs +++ b/Sledge.Formats.Configuration.Tests/TestWorldcraftConfiguration.cs @@ -11,14 +11,23 @@ namespace Sledge.Formats.Configuration.Tests; public sealed class TestWorldcraftConfiguration { /// - /// If you don't have worldcraft settings in your registry, this test will fail + /// This test is just for information from your local PC, it will not fail. /// [TestMethod] public void TestLoadSettingsFromLocalComputer() { - var config = WorldcraftConfiguration.LoadFromRegistry(WorldcraftConfigurationLoadSettings.Default); - Console.WriteLine("Undo levels: " + config.General.UndoLevels); - Console.WriteLine("Textures: " + string.Join("; ", config.TextureDirectories)); + try + { + var config = WorldcraftConfiguration.LoadFromRegistry(WorldcraftConfigurationLoadSettings.Default); + Console.WriteLine("Install directory: " + config.General.InstallDirectory); + Console.WriteLine("Undo levels: " + config.General.UndoLevels); + Console.WriteLine("Textures: " + string.Join("; ", config.TextureDirectories)); + } + catch (Exception ex) + { + Console.WriteLine("Unable to load config from local computer."); + Console.WriteLine(ex.Message); + } } [TestMethod] @@ -28,6 +37,7 @@ public void TestLoadSettings() var config = WorldcraftConfiguration.LoadFromRegistry(new WorldcraftConfigurationLoadSettings { LoadGameConfigurations = false, + LoadCommandSequences = false, RegistryLocation = reg.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Default).OpenSubKey(@"Software\Valve\Worldcraft") }); @@ -36,6 +46,7 @@ public void TestLoadSettings() Assert.IsNotNull(config.Views3D); Assert.IsNotNull(config.TextureDirectories); Assert.IsNull(config.GameConfigurations); + Assert.IsNull(config.CommandSequences); Assert.AreEqual(@"C:\Users\WDAGUtilityAccount\Desktop\Worldcraft 3.3", config.General.InstallDirectory, StringComparer.InvariantCultureIgnoreCase); Assert.AreEqual(true, config.General.UseIndependentWindowConfigurations); @@ -234,6 +245,180 @@ public void TestWriteGameConfigFile33() Assert.AreEqual(file.Configurations[0].BuildPrograms.BspDirectory, file2.Configurations[0].BuildPrograms.BspDirectory); } + [TestMethod] + public void TestReadCommandSequenceFile15() + { + using var file = typeof(TestWorldcraftConfiguration).Assembly.GetManifestResourceStream("Sledge.Formats.Configuration.Tests.Resources.Worldcraft.CmdSeq-wc15.wc"); + var sequences = new WorldcraftCommandSequenceFile(file).CommandSequences; + + Assert.AreEqual(2, sequences.Count); + var (seq1, seq2) = (sequences[0], sequences[1]); + + Assert.AreEqual("Default", seq1.Name); + + Assert.AreEqual(true, seq1.Steps[0].IsEnabled); + Assert.AreEqual("a", seq1.Steps[0].Command); + Assert.AreEqual("b", seq1.Steps[0].Arguments); + Assert.AreEqual(true, seq1.Steps[0].UseLongFileNames); + Assert.AreEqual(true, seq1.Steps[0].EnsureFileExists); + Assert.AreEqual("", seq1.Steps[0].FileExistsName); + Assert.AreEqual(false, seq1.Steps[0].UseProcessWindow); + + Assert.AreEqual(false, seq1.Steps[1].IsEnabled); + Assert.AreEqual("c", seq1.Steps[1].Command); + Assert.AreEqual("d", seq1.Steps[1].Arguments); + Assert.AreEqual(false, seq1.Steps[1].UseLongFileNames); + Assert.AreEqual(false, seq1.Steps[1].EnsureFileExists); + Assert.AreEqual("", seq1.Steps[1].FileExistsName); + Assert.AreEqual(true, seq1.Steps[1].UseProcessWindow); + + Assert.AreEqual("Test", seq2.Name); + + Assert.AreEqual(false, seq2.Steps[0].IsEnabled); + Assert.AreEqual("test1", seq2.Steps[0].Command); + Assert.AreEqual("test2", seq2.Steps[0].Arguments); + Assert.AreEqual(false, seq2.Steps[0].UseLongFileNames); + Assert.AreEqual(true, seq2.Steps[0].EnsureFileExists); + Assert.AreEqual("test3", seq2.Steps[0].FileExistsName); + Assert.AreEqual(true, seq2.Steps[0].UseProcessWindow); + } + + [TestMethod] + public void TestReadCommandSequenceFile33() + { + using var file = typeof(TestWorldcraftConfiguration).Assembly.GetManifestResourceStream("Sledge.Formats.Configuration.Tests.Resources.Worldcraft.CmdSeq-wc33.wc"); var sequences = new WorldcraftCommandSequenceFile(file).CommandSequences; + + Assert.AreEqual(4, sequences.Count); + var (seq1, seq2, seq3, seq4) = (sequences[0], sequences[1], sequences[2], sequences[3]); + + Assert.AreEqual("Half-Life (full)", seq1.Name); + Assert.AreEqual("Half-Life: Counterstrike (full)", seq2.Name); + Assert.AreEqual("Half-Life: Opposing Force (full)", seq3.Name); + Assert.AreEqual("Half-Life: Team Fortress (full)", seq4.Name); + + AssertSteps("valve", seq1); + AssertSteps("cstrike", seq2); + AssertSteps("gearbox", seq3); + AssertSteps("tfc", seq4); + + static void AssertSteps(string game, WorldcraftCommandSequence sequence) + { + Assert.AreEqual(8, sequence.Steps.Count); + + AssertStep(true, "Change Directory", "$exedir", true, false, "", true, sequence.Steps[0]); + AssertStep(true, "$csg_exe", @"$path\$file", true, false, "", true, sequence.Steps[1]); + AssertStep(true, "$bsp_exe", @"$path\$file", true, false, "", true, sequence.Steps[2]); + AssertStep(true, "$vis_exe", @"$path\$file", true, false, "", true, sequence.Steps[3]); + AssertStep(true, "$light_exe", @"$path\$file", true, false, "", true, sequence.Steps[4]); + AssertStep(true, "Copy File", @"$path\$file.bsp $bspdir\$file.bsp", true, false, "", true, sequence.Steps[5]); + AssertStep(true, "Copy File", @"$path\$file.pts $bspdir\$file.pts", true, false, "", true, sequence.Steps[6]); + switch (game) + { + case "valve": + AssertStep(true, "$game_exe", "+map $file -dev -console", true, false, "", false, sequence.Steps[7]); + break; + case "cstrike": + AssertStep(true, "$game_exe", "+map $file -game cstrike -dev -console +deathmatch 1", true, false, "", false, sequence.Steps[7]); + break; + case "gearbox": + AssertStep(true, "$game_exe", "+map $file -game gearbox -dev -console", true, false, "", false, sequence.Steps[7]); + break; + case "tfc": + AssertStep(true, "$game_exe", "+map $file -game tfc -dev -console -toconsole +sv_lan 1", true, false, "", false, sequence.Steps[7]); + break; + } + } + + static void AssertStep(bool isEnabled, string command, string args, bool useLongFileNames, bool ensureFileExists, string fileExistsName, bool useProcessWindow, WorldcraftCommandSequenceStep step) + { + Assert.AreEqual(isEnabled, step.IsEnabled); + Assert.AreEqual(command, step.Command); + Assert.AreEqual(args, step.Arguments); + Assert.AreEqual(useLongFileNames, step.UseLongFileNames); + Assert.AreEqual(ensureFileExists, step.EnsureFileExists); + Assert.AreEqual(fileExistsName, step.FileExistsName); + Assert.AreEqual(useProcessWindow, step.UseProcessWindow); + } + } + + [DataTestMethod] + [DataRow(0.1f)] + [DataRow(0.2f)] + public void TestWriteCommandSequencesFile(float version) + { + // The two known versions are identical aside from an unused field, so we can test them together + var file = new WorldcraftCommandSequenceFile(); + file.CommandSequences.Add(new WorldcraftCommandSequence + { + Name = "Test1", + Steps = + [ + new WorldcraftCommandSequenceStep + { + IsEnabled = true, + Command = "test1.step1.command", + Arguments = "test1.step1.args", + UseLongFileNames = true, + EnsureFileExists = false, + FileExistsName = "", + UseProcessWindow = true + }, + new WorldcraftCommandSequenceStep + { + IsEnabled = false, + Command = "test1.step2.command", + Arguments = "test1.step2.args", + UseLongFileNames = false, + EnsureFileExists = true, + FileExistsName = "test1.step2.filename", + UseProcessWindow = false + }, + ], + }); + file.CommandSequences.Add(new WorldcraftCommandSequence + { + Name = "Test2", + Steps = + [ + new WorldcraftCommandSequenceStep + { + IsEnabled = true, + Command = "test2.step1.command", + Arguments = "test2.step1.args", + UseLongFileNames = true, + EnsureFileExists = false, + FileExistsName = "", + UseProcessWindow = true + }, + ], + }); + + var ms = new MemoryStream(); + file.Write(ms, version); + ms.Position = 0; + + var file2 = new WorldcraftCommandSequenceFile(ms); + Assert.AreEqual(file.CommandSequences.Count, file2.CommandSequences.Count); + AssertSequence(file.CommandSequences[0], file2.CommandSequences[0]); + AssertSequence(file.CommandSequences[1], file2.CommandSequences[1]); + + static void AssertSequence(WorldcraftCommandSequence expected, WorldcraftCommandSequence actual) + { + Assert.AreEqual(expected.Name, actual.Name); + Assert.AreEqual(expected.Steps.Count, actual.Steps.Count); + for (var i = 0; i < expected.Steps.Count; i++) + { + Assert.AreEqual(expected.Steps[i].IsEnabled, actual.Steps[i].IsEnabled); + Assert.AreEqual(expected.Steps[i].Command, actual.Steps[i].Command); + Assert.AreEqual(expected.Steps[i].Arguments, actual.Steps[i].Arguments); + Assert.AreEqual(expected.Steps[i].UseLongFileNames, actual.Steps[i].UseLongFileNames); + Assert.AreEqual(expected.Steps[i].EnsureFileExists, actual.Steps[i].EnsureFileExists); + Assert.AreEqual(expected.Steps[i].FileExistsName, actual.Steps[i].FileExistsName); + Assert.AreEqual(expected.Steps[i].UseProcessWindow, actual.Steps[i].UseProcessWindow); + } + } + } + private const string Worldcraft33RegString = """ Windows Registry Editor Version 5.00 diff --git a/Sledge.Formats.Configuration/Worldcraft/CommandType.cs b/Sledge.Formats.Configuration/Worldcraft/CommandType.cs new file mode 100644 index 0000000..69a975d --- /dev/null +++ b/Sledge.Formats.Configuration/Worldcraft/CommandType.cs @@ -0,0 +1,9 @@ +namespace Sledge.Formats.Configuration.Worldcraft +{ + public enum CommandType + { + RunExecutable = 0, + ChangeDirectory = 256, + CopyFile = 257, + } +} \ No newline at end of file diff --git a/Sledge.Formats.Configuration/Worldcraft/WorldcraftCommandSequence.cs b/Sledge.Formats.Configuration/Worldcraft/WorldcraftCommandSequence.cs new file mode 100644 index 0000000..904662d --- /dev/null +++ b/Sledge.Formats.Configuration/Worldcraft/WorldcraftCommandSequence.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Sledge.Formats.Configuration.Worldcraft +{ + /// + /// A sequence of commands to run when compiling a map + /// + public class WorldcraftCommandSequence + { + /// + /// Command sequence name + /// + public string Name { get; set; } = ""; + + /// + /// Command sequence steps + /// + public List Steps { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/Sledge.Formats.Configuration/Worldcraft/WorldcraftCommandSequenceFile.cs b/Sledge.Formats.Configuration/Worldcraft/WorldcraftCommandSequenceFile.cs new file mode 100644 index 0000000..5e51d67 --- /dev/null +++ b/Sledge.Formats.Configuration/Worldcraft/WorldcraftCommandSequenceFile.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace Sledge.Formats.Configuration.Worldcraft +{ + public class WorldcraftCommandSequenceFile + { + private const int NameStringLength = 128; + private const int CommandStringLength = 260; + private static readonly string SequenceFileHeader = "Worldcraft Command Sequences\r\n" + (char)0x1A; + + /// + /// Version 0.1 used for Worldcraft 1.1-1.5b + /// + public const float MinVersion = 0.1f; + + /// + /// Version 0.2 used for Worldcraft 1.6a and up + /// + public const float MaxVersion = 0.2f; + + public List CommandSequences { get; set; } + + public WorldcraftCommandSequenceFile() + { + CommandSequences = new List(); + } + + public WorldcraftCommandSequenceFile(Stream stream) + { + CommandSequences = new List(); + ReadFromStream(stream); + } + + public static WorldcraftGameConfigurationFile FromFile(string file) + { + using (var stream = File.OpenRead(file)) + { + return new WorldcraftGameConfigurationFile(stream); + } + } + + private void ReadFromStream(Stream stream) + { + using (var br = new BinaryReader(stream, Encoding.ASCII, true)) + { + var header = br.ReadFixedLengthString(Encoding.ASCII, SequenceFileHeader.Length); + if (header != SequenceFileHeader.TrimEnd('\0')) throw new NotSupportedException($"Incorrect command sequence file header. Expected '{SequenceFileHeader}', got '{header}'."); + + var version = br.ReadSingle(); + if (version < MinVersion || version > MaxVersion) throw new NotSupportedException($"Unsupported command sequence file version. Expected {MinVersion} or {MaxVersion}, got {version}."); + + var numSequences = br.ReadInt32(); + for (var i = 0; i < numSequences; i++) + { + var seq = new WorldcraftCommandSequence + { + Name = br.ReadFixedLengthString(Encoding.ASCII, NameStringLength) + }; + + var numSteps = br.ReadInt32(); + for (var j = 0; j < numSteps; j++) + { + var step = new WorldcraftCommandSequenceStep + { + IsEnabled = br.ReadInt32() > 0, + Type = (CommandType) br.ReadInt32(), // unknown 1 + Command = br.ReadFixedLengthString(Encoding.ASCII, CommandStringLength), + Arguments = br.ReadFixedLengthString(Encoding.ASCII, CommandStringLength), +#pragma warning disable CS0612 // Type or member is obsolete + UseLongFileNames = br.ReadInt32() > 0, +#pragma warning restore CS0612 + EnsureFileExists = br.ReadInt32() > 0, + FileExistsName = br.ReadFixedLengthString(Encoding.ASCII, CommandStringLength), + UseProcessWindow = br.ReadInt32() > 0 + }; + if (Math.Abs(version - 0.2f) < float.Epsilon) + { + // this looks like a bool, its only set to 1 for $game_exe commands in the default sequences file. + // there's no way to control it in the UI, so we'll ignore it. + _ = br.ReadInt32(); + } + seq.Steps.Add(step); + } + CommandSequences.Add(seq); + } + } + } + + public void Write(Stream stream, float version = MaxVersion) + { + if (version < MinVersion || version > MaxVersion) throw new NotSupportedException($"Unsupported command sequence file version. Expected {MinVersion} or {MaxVersion}, got {version}."); + + using (var bw = new BinaryWriter(stream, Encoding.ASCII, true)) + { + bw.WriteFixedLengthString(Encoding.ASCII, SequenceFileHeader.Length, SequenceFileHeader); + bw.Write(version); + bw.Write(CommandSequences.Count); + foreach (var config in CommandSequences) + { + // Write sequence name, number of steps, and then each step: + bw.WriteFixedLengthString(Encoding.ASCII, NameStringLength, config.Name); + bw.Write(config.Steps.Count); + foreach (var step in config.Steps) + { + bw.Write(step.IsEnabled ? 1 : 0); + bw.Write(0); // unknown 1 + bw.WriteFixedLengthString(Encoding.ASCII, CommandStringLength, step.Command); + bw.WriteFixedLengthString(Encoding.ASCII, CommandStringLength, step.Arguments); +#pragma warning disable CS0612 // Type or member is obsolete + bw.Write(step.UseLongFileNames ? 1 : 0); +#pragma warning restore CS0612 + bw.Write(step.EnsureFileExists ? 1 : 0); + bw.WriteFixedLengthString(Encoding.ASCII, CommandStringLength, step.FileExistsName); + bw.Write(step.UseProcessWindow ? 1 : 0); + if (Math.Abs(version - 0.2f) < float.Epsilon) + { + bw.Write(0); // unknown 2 + } + } + } + } + } + } +} diff --git a/Sledge.Formats.Configuration/Worldcraft/WorldcraftCommandSequenceStep.cs b/Sledge.Formats.Configuration/Worldcraft/WorldcraftCommandSequenceStep.cs new file mode 100644 index 0000000..9ede8ec --- /dev/null +++ b/Sledge.Formats.Configuration/Worldcraft/WorldcraftCommandSequenceStep.cs @@ -0,0 +1,50 @@ +using System; + +namespace Sledge.Formats.Configuration.Worldcraft +{ + /// + /// A step in a command sequence representing a single command to run + /// + public class WorldcraftCommandSequenceStep + { + /// + /// Whether this step is enabled + /// + public bool IsEnabled { get; set; } = true; + + /// + /// The type of command to run + /// + public CommandType Type { get; set; } + + /// + /// The command to run + /// + public string Command { get; set; } = ""; + + /// + /// The command arguments + /// + public string Arguments { get; set; } = ""; + + /// + /// Doesn't seem to actually do anything, likely from older Windows where 8.3 filenames were common. Only kept since VHE has the option and it's stored in the configuration file. + /// + [Obsolete] public bool UseLongFileNames { get; set; } = true; + + /// + /// Check if a file exists after running the command + /// + public bool EnsureFileExists { get; set; } = false; + + /// + /// The file to check for after running the command + /// + public string FileExistsName { get; set; } = ""; + + /// + /// True to display a command window when running + /// + public bool UseProcessWindow { get; set; } = true; + } +} \ No newline at end of file diff --git a/Sledge.Formats.Configuration/Worldcraft/WorldcraftConfiguration.cs b/Sledge.Formats.Configuration/Worldcraft/WorldcraftConfiguration.cs index e070b5a..fcd0e65 100644 --- a/Sledge.Formats.Configuration/Worldcraft/WorldcraftConfiguration.cs +++ b/Sledge.Formats.Configuration/Worldcraft/WorldcraftConfiguration.cs @@ -13,6 +13,7 @@ public class WorldcraftConfiguration public Worldcraft3DViewsConfiguration Views3D { get; set; } public List TextureDirectories { get; set; } public List GameConfigurations { get; set; } + public List CommandSequences { get; set; } public static WorldcraftConfiguration LoadFromRegistry(WorldcraftConfigurationLoadSettings settings = null) { @@ -37,15 +38,31 @@ public static WorldcraftConfiguration LoadFromRegistry(WorldcraftConfigurationLo installDir = config.General.InstallDirectory; } - if (settings.LoadGameConfigurations && Directory.Exists(installDir)) + if (Directory.Exists(installDir)) { - var configFile = Path.Combine(installDir, "GameCfg.wc"); - if (File.Exists(configFile)) + if (settings.LoadGameConfigurations) { - using (var fs = File.OpenRead(configFile)) + var configFile = Path.Combine(installDir, "GameCfg.wc"); + if (File.Exists(configFile)) { - var cfg = new WorldcraftGameConfigurationFile(fs); - config.GameConfigurations = cfg.Configurations; + using (var fs = File.OpenRead(configFile)) + { + var cfg = new WorldcraftGameConfigurationFile(fs); + config.GameConfigurations = cfg.Configurations; + } + } + } + + if (settings.LoadCommandSequences) + { + var cmdSeqFile = Path.Combine(installDir, "CmdSeq.wc"); + if (File.Exists(cmdSeqFile)) + { + using (var fs = File.OpenRead(cmdSeqFile)) + { + var cfg = new WorldcraftCommandSequenceFile(fs); + config.CommandSequences = cfg.CommandSequences; + } } } } diff --git a/Sledge.Formats.Configuration/Worldcraft/WorldcraftConfigurationLoadSettings.cs b/Sledge.Formats.Configuration/Worldcraft/WorldcraftConfigurationLoadSettings.cs index e87c5c5..e145cca 100644 --- a/Sledge.Formats.Configuration/Worldcraft/WorldcraftConfigurationLoadSettings.cs +++ b/Sledge.Formats.Configuration/Worldcraft/WorldcraftConfigurationLoadSettings.cs @@ -32,6 +32,11 @@ public class WorldcraftConfigurationLoadSettings /// public bool LoadGameConfigurations { get; set; } = true; + /// + /// True to attempt to load command sequences from the install directory + /// + public bool LoadCommandSequences { get; set; } = true; + /// /// True to attempt to autodetect the install directory from the registry ([Worldcraft/General/Directory] registry key). /// All worldcraft versions store the install directory in the registry except for version 1.0. @@ -51,6 +56,7 @@ public class WorldcraftConfigurationLoadSettings { AutodetectRegistryLocation = true, LoadGameConfigurations = true, + LoadCommandSequences = true, AutodetectInstallDirectory = true, }; }