diff --git a/TRLevelControl/IO/TRXInjector.cs b/TRLevelControl/IO/TRXInjector.cs new file mode 100644 index 000000000..ecc13f7b3 --- /dev/null +++ b/TRLevelControl/IO/TRXInjector.cs @@ -0,0 +1,199 @@ +using ICSharpCode.SharpZipLib.Zip.Compression.Streams; +using TRLevelControl.Model; +using TRLevelControl.Model.TRX; + +namespace TRLevelControl.IO; + +public static class TRXInjector +{ + private const uint _magic = 'T' | 'R' << 8 | 'X' << 16 | 'J' << 24; + private const uint _version = 4; + + public static TRXInjectionData Read(TRLevelReader reader) + { + try + { + if (reader.ReadUInt32() != _magic) + { + return null; + } + } + catch (EndOfStreamException) + { + return null; + } + + // Skip version and config option value + reader.BaseStream.Position += 2 * sizeof(uint); + + var zipReader = reader.Inflate(TRChunkType.LevelData); + + // Ignore tests + zipReader.BaseStream.Position += sizeof(int); + var testLength = zipReader.ReadInt32(); + zipReader.BaseStream.Position += testLength; + + var chunkCount = zipReader.ReadInt32(); + var data = new TRXInjectionData(); + for (int i = 0; i < chunkCount; i++) + { + var chunk = TRXChunk.Read(zipReader); + using var chunkMS = new MemoryStream(chunk.Data); + using var chunkReader = new TRLevelReader(chunkMS); + ReadChunk(data, chunk, chunkReader); + } + + return data; + } + + private static void ReadChunk(TRXInjectionData data, TRXChunk chunk, TRLevelReader reader) + { + for (int i = 0; i < chunk.BlockCount; i++) + { + var blockType = (TRXBlockType)reader.ReadInt32(); + var blockCount = reader.ReadInt32(); + reader.BaseStream.Position += sizeof(int); // Skip total length + + for (int j = 0; j < blockCount; j++) + { + switch (blockType) + { + case TRXBlockType.SampleInfos: + data.SFX.Add(TRSFXData.Read(reader)); + break; + } + } + } + } + + public static void Write(TRXInjectionData data, TRLevelWriter outWriter) + { + var chunks = Chunkify(data); + if (chunks == null || chunks.Count == 0) + { + return; + } + + using var injStream = new MemoryStream(); + using var injWriter = new TRLevelWriter(injStream); + + injWriter.Write(0); // Number of tests + injWriter.Write(0); // Total length of tests + injWriter.Write(chunks.Count); + chunks.ForEach(c => c.Serialize(injWriter)); + + var inflatedData = injStream.ToArray(); + using var outStream = new MemoryStream(); + using var deflater = new DeflaterOutputStream(outStream); + using var inStream = new MemoryStream(inflatedData); + + inStream.CopyTo(deflater); + deflater.Finish(); + + var deflatedData = outStream.ToArray(); + + outWriter.Write(_magic); + outWriter.Write(_version); + outWriter.Write(0); // Normally a value to link to a config option + + outWriter.Write(inflatedData.Length); + outWriter.Write(deflatedData.Length); + outWriter.Write(deflatedData); + } + + private static List Chunkify(TRXInjectionData data) + { + if (data == null) + { + return null; + } + + var chunks = new List + { + CreateChunk(TRXChunkType.SFXData, data, WriteSFXData), + }; + + chunks.RemoveAll(c => c.BlockCount == 0); + return chunks; + } + + private static TRXChunk CreateChunk(TRXChunkType type, + TRXInjectionData data, Func process) + { + using var stream = new MemoryStream(); + using var writer = new TRLevelWriter(stream); + int blockCount = process(data, writer); + + return new() + { + Type = type, + BlockCount = blockCount, + Data = stream.ToArray(), + }; + } + + private static int WriteSFXData(TRXInjectionData data, TRLevelWriter writer) + { + return WriteBlock(TRXBlockType.SampleInfos, data.SFX.Count, writer, + s => data.SFX.ForEach(f => f.Write(s))); + } + + private static int WriteBlock(TRXBlockType type, int elementCount, + TRLevelWriter writer, Action subCallback) + { + if (elementCount == 0) + { + return 0; + } + + using var stream = new MemoryStream(); + using var subWriter = new TRLevelWriter(stream); + subCallback(subWriter); + subWriter.Flush(); + + var data = stream.ToArray(); + writer.Write((int)type); + writer.Write(elementCount); + writer.Write(data.Length); + writer.Write(data); + + return 1; + } + + private class TRXChunk + { + public TRXChunkType Type { get; set; } + public int BlockCount { get; set; } + public byte[] Data { get; set; } + + public void Serialize(TRLevelWriter writer) + { + writer.Write((int)Type); + writer.Write(BlockCount); + writer.Write(Data.Length); + writer.Write(Data); + } + + public static TRXChunk Read(TRLevelReader reader) + { + var chunk = new TRXChunk + { + Type = (TRXChunkType)reader.ReadInt32(), + BlockCount = reader.ReadInt32(), + }; + int blockLength = reader.ReadInt32(); + chunk.Data = reader.ReadBytes(blockLength); + return chunk; + } + } + + private enum TRXChunkType + { + SFXData = 5, + } + + private enum TRXBlockType + { + SampleInfos = 14, + } +} diff --git a/TRLevelControl/Model/TRLevelBase.cs b/TRLevelControl/Model/TRLevelBase.cs index d1d7dcb64..c4f41e5fb 100644 --- a/TRLevelControl/Model/TRLevelBase.cs +++ b/TRLevelControl/Model/TRLevelBase.cs @@ -1,4 +1,6 @@ -namespace TRLevelControl.Model; +using TRLevelControl.Model.TRX; + +namespace TRLevelControl.Model; public abstract class TRLevelBase { @@ -9,4 +11,5 @@ public abstract class TRLevelBase public List ObjectTextures { get; set; } public List AnimatedTextures { get; set; } public abstract IEnumerable DistinctMeshes { get; } + public TRXInjectionData TRXData { get; set; } } diff --git a/TRLevelControl/Model/TRX/TRSFXData.cs b/TRLevelControl/Model/TRX/TRSFXData.cs new file mode 100644 index 000000000..7c84409ba --- /dev/null +++ b/TRLevelControl/Model/TRX/TRSFXData.cs @@ -0,0 +1,44 @@ +namespace TRLevelControl.Model.TRX; + +public class TRSFXData +{ + public short ID { get; set; } + public ushort Volume { get; set; } + public ushort Chance { get; set; } + public ushort Flags { get; set; } + public List Data { get; set; } + + public static TRSFXData Read(TRLevelReader reader) + { + var sfx = new TRSFXData + { + ID = reader.ReadInt16(), + Volume = reader.ReadUInt16(), + Chance = reader.ReadUInt16(), + Flags = reader.ReadUInt16(), + Data = [], + }; + + int sampleCount = (sfx.Flags & 0xFC) >> 2; + for (int i = 0; i < sampleCount; i++) + { + var length = reader.ReadInt32(); + sfx.Data.Add(reader.ReadBytes(length)); + } + + return sfx; + } + + public void Write(TRLevelWriter writer) + { + writer.Write(ID); + writer.Write(Volume); + writer.Write(Chance); + writer.Write(Flags); + Data.ForEach(wav => + { + writer.Write(wav.Length); + writer.Write(wav); + }); + } +} diff --git a/TRLevelControl/Model/TRX/TRXInjectionData.cs b/TRLevelControl/Model/TRX/TRXInjectionData.cs new file mode 100644 index 000000000..33a0943d6 --- /dev/null +++ b/TRLevelControl/Model/TRX/TRXInjectionData.cs @@ -0,0 +1,6 @@ +namespace TRLevelControl.Model.TRX; + +public class TRXInjectionData +{ + public List SFX { get; set; } = []; +} diff --git a/TRLevelControl/TRLevelControlBase.cs b/TRLevelControl/TRLevelControlBase.cs index 12c39be4d..9ca25103a 100644 --- a/TRLevelControl/TRLevelControlBase.cs +++ b/TRLevelControl/TRLevelControlBase.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using TRLevelControl.IO; using TRLevelControl.Model; namespace TRLevelControl; @@ -24,6 +25,7 @@ public L Read(Stream stream) _level = CreateLevel((TRFileVersion)reader.ReadUInt32()); Initialise(); Read(reader); + _level.TRXData = TRXInjector.Read(reader); Debug.Assert(reader.BaseStream.Position == reader.BaseStream.Length); return _level; @@ -41,6 +43,7 @@ public void Write(L level, Stream outputStream) _level = level; Initialise(); Write(writer); + TRXInjector.Write(level.TRXData, writer); } protected abstract L CreateLevel(TRFileVersion version); diff --git a/TRLevelControlTests/TRX/IOTests.cs b/TRLevelControlTests/TRX/IOTests.cs new file mode 100644 index 000000000..2a4c828d0 --- /dev/null +++ b/TRLevelControlTests/TRX/IOTests.cs @@ -0,0 +1,44 @@ +using TRLevelControl.Model; +using TRLevelControl.Model.TRX; + +namespace TRLevelControlTests.TRX; + +[TestClass] +[TestCategory("TRXInjection")] +public class IOTests : TestBase +{ + [TestMethod] + public void TestSFX() + { + var level = GetTR1TestLevel(); + Assert.IsNull(level.TRXData); + + var sfxA = new TRSFXData + { + ID = (short)TR1SFX.NatlaDeath, + Chance = 4, + Flags = 1 << 2, + Volume = 16384, + Data = [[.. Enumerable.Range(0, 256).Select(i => (byte)i)]], + }; + level.TRXData = new(); + level.TRXData.SFX.Add(sfxA); + level.TRXData.SFX.Add(sfxA); + + level = WriteReadTempLevel(level); + + Assert.IsNotNull(level.TRXData); + Assert.HasCount(2, level.TRXData.SFX); + Assert.AreNotEqual(level.TRXData.SFX[0], level.TRXData.SFX[1]); + + for (int i = 0; i < 2; i++) + { + var sfxB = level.TRXData.SFX[i]; + Assert.AreEqual(sfxA.ID, sfxB.ID); + Assert.AreEqual(sfxA.Chance, sfxB.Chance); + Assert.AreEqual(sfxA.Flags, sfxB.Flags); + Assert.AreEqual(sfxA.Volume, sfxB.Volume); + CollectionAssert.AreEqual(sfxA.Data, sfxB.Data); + } + } +} diff --git a/TRRandomizerCore/Editors/TR2ClassicEditor.cs b/TRRandomizerCore/Editors/TR2ClassicEditor.cs index 634230055..7f9b2aab2 100644 --- a/TRRandomizerCore/Editors/TR2ClassicEditor.cs +++ b/TRRandomizerCore/Editors/TR2ClassicEditor.cs @@ -276,18 +276,19 @@ protected override void SaveImpl(AbstractTRScriptEditor scriptEditor, TRSaveMoni environmentRandomizer.FinalizeEnvironment(); } + var audioRandomizer = new TR2AudioRandomizer + { + ScriptEditor = scriptEditor, + Levels = levels, + BasePath = wipDirectory, + BackupPath = backupDirectory, + SaveMonitor = monitor, + Settings = Settings + }; if (!monitor.IsCancelled && Settings.RandomizeAudio) { monitor.FireSaveStateBeginning(TRSaveCategory.Custom, "Randomizing audio tracks"); - new TR2AudioRandomizer - { - ScriptEditor = scriptEditor, - Levels = levels, - BasePath = wipDirectory, - BackupPath = backupDirectory, - SaveMonitor = monitor, - Settings = Settings - }.Randomize(Settings.AudioSeed); + audioRandomizer.Randomize(Settings.AudioSeed); } if (!monitor.IsCancelled && Settings.RandomizeOutfits) @@ -358,6 +359,12 @@ protected override void SaveImpl(AbstractTRScriptEditor scriptEditor, TRSaveMoni itemRandomizer.RandomizeSprites(); } + if (!monitor.IsCancelled && Settings.RandomizeAudio) + { + monitor.FireSaveStateBeginning(TRSaveCategory.Custom, "Embedding sound effects"); + audioRandomizer.EmbedSamples(); + } + AmendTitleAndCredits(scriptEditor, monitor); } diff --git a/TRRandomizerCore/Randomizers/TR2/Classic/TR2AudioRandomizer.cs b/TRRandomizerCore/Randomizers/TR2/Classic/TR2AudioRandomizer.cs index 23eaafd5f..0aec05f46 100644 --- a/TRRandomizerCore/Randomizers/TR2/Classic/TR2AudioRandomizer.cs +++ b/TRRandomizerCore/Randomizers/TR2/Classic/TR2AudioRandomizer.cs @@ -1,4 +1,6 @@ -namespace TRRandomizerCore.Randomizers; +using TRLevelControl; + +namespace TRRandomizerCore.Randomizers; public class TR2AudioRandomizer : BaseTR2Randomizer { @@ -29,4 +31,65 @@ public override void Randomize(int seed) } } } + + public void EmbedSamples() + { + var mainSFX = ReadMainSFX(); + if (mainSFX.Count == 0) + { + return; + } + + Parallel.ForEach(Levels, l => + { + var level = LoadCombinedLevel(l); + level.Data.TRXData ??= new(); + + foreach (var (sfxID, sfx) in level.Data.SoundEffects) + { + if (level.Data.TRXData.SFX.Any(s => s.ID == (short)sfxID)) + { + continue; + } + + level.Data.TRXData.SFX.Add(new() + { + ID = (short)sfxID, + Chance = sfx.Chance, + Flags = sfx.GetFlags(), + Volume = sfx.Volume, + Data = mainSFX.GetRange((int)sfx.SampleID, sfx.SampleCount), + }); + } + + SaveLevel(level); + }); + } + + private List ReadMainSFX() + { + var targetDir = Path.GetDirectoryName(ScriptEditor.OriginalFile.FullName); + var sfxFile = Path.GetFullPath(Path.Combine(targetDir, "../../data/main.sfx")); + var result = new List(_numSamples); + if (!File.Exists(sfxFile)) + { + return result; + } + + using var reader = new TRLevelReader(File.Open(sfxFile, FileMode.Open)); + while (reader.BaseStream.Position < reader.BaseStream.Length) + { + using var stream = new MemoryStream(); + using var writer = new TRLevelWriter(stream); + + var header = reader.ReadUInt32s(11); + var data = reader.ReadUInt8s(header[10]); + writer.Write(header); + writer.Write(data); + + result.Add(stream.ToArray()); + } + + return result; + } } diff --git a/TRRandomizerCore/Randomizers/TR2/Classic/TR2OutfitRandomizer.cs b/TRRandomizerCore/Randomizers/TR2/Classic/TR2OutfitRandomizer.cs index be47046b5..dfc05623d 100644 --- a/TRRandomizerCore/Randomizers/TR2/Classic/TR2OutfitRandomizer.cs +++ b/TRRandomizerCore/Randomizers/TR2/Classic/TR2OutfitRandomizer.cs @@ -389,6 +389,21 @@ private void AdjustOutfit(TR2CombinedLevel level, TR2Type lara) actorLara.MeshTrees = realLara.MeshTrees; actorLara.Meshes = realLara.Meshes; } + + if ((lara == TR2Type.LaraUnwater || lara == TR2Type.LaraHome) + && level.Data.SoundEffects.TryGetValue(TR2SFX.LaraFeet, out var feet)) + { + level.Data.TRXData ??= new(); + level.Data.TRXData.SFX.RemoveAll(s => s.ID == (short)TR2SFX.LaraFeet); + level.Data.TRXData.SFX.Add(new() + { + ID = (short)TR2SFX.LaraFeet, + Chance = feet.Chance, + Flags = feet.GetFlags(), + Volume = feet.Volume, + Data = [.. Enumerable.Range(0, 4).Select(i => File.ReadAllBytes($"Resources/TR2/Audio/Barefoot/{i}.wav"))], + }); + } } } } diff --git a/TRRandomizerCore/Resources/TR2/Audio/Barefoot/0.wav b/TRRandomizerCore/Resources/TR2/Audio/Barefoot/0.wav new file mode 100644 index 000000000..ca939a3d7 Binary files /dev/null and b/TRRandomizerCore/Resources/TR2/Audio/Barefoot/0.wav differ diff --git a/TRRandomizerCore/Resources/TR2/Audio/Barefoot/1.wav b/TRRandomizerCore/Resources/TR2/Audio/Barefoot/1.wav new file mode 100644 index 000000000..f135e19f5 Binary files /dev/null and b/TRRandomizerCore/Resources/TR2/Audio/Barefoot/1.wav differ diff --git a/TRRandomizerCore/Resources/TR2/Audio/Barefoot/2.wav b/TRRandomizerCore/Resources/TR2/Audio/Barefoot/2.wav new file mode 100644 index 000000000..0f7a9b52f Binary files /dev/null and b/TRRandomizerCore/Resources/TR2/Audio/Barefoot/2.wav differ diff --git a/TRRandomizerCore/Resources/TR2/Audio/Barefoot/3.wav b/TRRandomizerCore/Resources/TR2/Audio/Barefoot/3.wav new file mode 100644 index 000000000..8bb38db6c Binary files /dev/null and b/TRRandomizerCore/Resources/TR2/Audio/Barefoot/3.wav differ