diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f478713 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,18 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '6.0.x' + - run: dotnet test BotwSaveManager.Core.Tests/BotwSaveManager.Core.Tests.csproj --configuration Release diff --git a/BotWSaveManager.sln b/BotWSaveManager.sln index bacb3f9..8fa2a85 100644 --- a/BotWSaveManager.sln +++ b/BotWSaveManager.sln @@ -9,6 +9,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BotwSaveManager.Core", "Bot EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotwSaveManager.Console", "BotwSaveManager.Console\BotwSaveManager.Console.csproj", "{5FD1DD50-4944-4614-A67D-84598C84DD16}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotwSaveManager.Core.Tests", "BotwSaveManager.Core.Tests\BotwSaveManager.Core.Tests.csproj", "{16E414BF-49E8-4ACC-BF4D-9C3A47925EB1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +29,10 @@ Global {5FD1DD50-4944-4614-A67D-84598C84DD16}.Debug|Any CPU.Build.0 = Debug|Any CPU {5FD1DD50-4944-4614-A67D-84598C84DD16}.Release|Any CPU.ActiveCfg = Release|Any CPU {5FD1DD50-4944-4614-A67D-84598C84DD16}.Release|Any CPU.Build.0 = Release|Any CPU + {16E414BF-49E8-4ACC-BF4D-9C3A47925EB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {16E414BF-49E8-4ACC-BF4D-9C3A47925EB1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {16E414BF-49E8-4ACC-BF4D-9C3A47925EB1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {16E414BF-49E8-4ACC-BF4D-9C3A47925EB1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/BotwSaveManager.Core.Tests/BotwSaveManager.Core.Tests.csproj b/BotwSaveManager.Core.Tests/BotwSaveManager.Core.Tests.csproj new file mode 100644 index 0000000..5ea39bb --- /dev/null +++ b/BotwSaveManager.Core.Tests/BotwSaveManager.Core.Tests.csproj @@ -0,0 +1,24 @@ + + + + net6.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/BotwSaveManager.Core.Tests/StringConversionTests.cs b/BotwSaveManager.Core.Tests/StringConversionTests.cs new file mode 100644 index 0000000..a4ad81c --- /dev/null +++ b/BotwSaveManager.Core.Tests/StringConversionTests.cs @@ -0,0 +1,121 @@ +using System.Buffers.Binary; +using System.Text; +using BotwSaveManager.Core; +using Xunit; + +namespace BotwSaveManager.Core.Tests; + +public class StringConversionTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConvertPlatformPreservesSavedMapName(bool fromWiiU) + { + AssertStringConversion(0x0BEE9E46, "Z-9", 32, fromWiiU); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConvertPlatformPreservesWolfLinkDetectedUid(bool fromWiiU) + { + AssertStringConversion(0x1C10782F, "test-wolf-uid-0123456789", 64, fromWiiU); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConvertPlatformStillPreservesHorseNames(bool fromWiiU) + { + AssertStringConversion(0x7B74E117, "Sage", 64, fromWiiU); + } + + private static void AssertStringConversion(uint hash, string text, int capacity, bool fromWiiU) + { + // All input is generated here; no player saves or game assets are needed. + string root = Path.Combine(Path.GetTempPath(), "BotwSaveManager.Tests-" + Guid.NewGuid()); + string source = Path.Combine(root, "source"); + string output = Path.Combine(root, "output"); + string roundTrip = Path.Combine(root, "round-trip"); + Directory.CreateDirectory(Path.Combine(source, "0")); + + try { + byte[] options = BuildOptions(fromWiiU); + byte[] original = BuildSave(hash, text, capacity, fromWiiU); + byte[] expected = BuildSave(hash, text, capacity, !fromWiiU); + byte[] photo = { 0xFF, 0xD8, 0xFF, 0xD9 }; + File.WriteAllBytes(Path.Combine(source, "option.sav"), options); + File.WriteAllBytes(Path.Combine(source, "0", "game_data.sav"), original); + File.WriteAllBytes(Path.Combine(source, "0", "caption.jpg"), photo); + + BotwSave save = new(source); + Assert.Equal(fromWiiU ? SaveType.WiiU : SaveType.Switch, save.SaveType); + save.ConvertPlatform(output); + + // A round trip alone would also pass with the old, corrupting converter. + // Check the independently generated destination byte order on the first pass. + Assert.Equal(expected, File.ReadAllBytes(Path.Combine(output, "0", "game_data.sav"))); + Assert.Equal(BuildOptions(!fromWiiU), File.ReadAllBytes(Path.Combine(output, "option.sav"))); + Assert.Equal(fromWiiU ? SaveType.Switch : SaveType.WiiU, save.SaveType); + Assert.Equal(original, File.ReadAllBytes(Path.Combine(source, "0", "game_data.sav"))); + Assert.Equal(options, File.ReadAllBytes(Path.Combine(source, "option.sav"))); + Assert.Equal(photo, File.ReadAllBytes(Path.Combine(output, "0", "caption.jpg"))); + + BotwSave converted = new(output); + converted.ConvertPlatform(roundTrip); + Assert.Equal(original, File.ReadAllBytes(Path.Combine(roundTrip, "0", "game_data.sav"))); + Assert.Equal(options, File.ReadAllBytes(Path.Combine(roundTrip, "option.sav"))); + } + finally { + Directory.Delete(root, true); + } + } + + private static byte[] BuildOptions(bool bigEndian) + { + using MemoryStream stream = new(); + WriteHeader(stream, bigEndian); + WriteWord(stream, 0, bigEndian); + return stream.ToArray(); + } + + private static byte[] BuildSave(uint hash, string text, int capacity, bool bigEndian) + { + using MemoryStream stream = new(); + WriteHeader(stream, bigEndian); + + // Numeric entries before and after the string must still change byte order. + WriteWord(stream, 0x10203040, bigEndian); + WriteWord(stream, 0x12345678, bigEndian); + byte[] value = new byte[capacity]; + Encoding.UTF8.GetBytes(text).CopyTo(value, 0); + for (int i = 0; i < value.Length; i += 4) { + WriteWord(stream, hash, bigEndian); + stream.Write(value, i, 4); + } + WriteWord(stream, 0x50607080, bigEndian); + WriteWord(stream, 0x87654321, bigEndian); + WriteWord(stream, 0, bigEndian); + return stream.ToArray(); + } + + private static void WriteHeader(Stream stream, bool bigEndian) + { + WriteWord(stream, 0x471B, bigEndian); + WriteWord(stream, 0xFFFFFFFF, bigEndian); + WriteWord(stream, 1, bigEndian); + } + + private static void WriteWord(Stream stream, uint value, bool bigEndian) + { + Span bytes = stackalloc byte[4]; + if (bigEndian) { + BinaryPrimitives.WriteUInt32BigEndian(bytes, value); + } + else { + BinaryPrimitives.WriteUInt32LittleEndian(bytes, value); + } + stream.Write(bytes); + } +} diff --git a/BotwSaveManager.Core/BotwSave.cs b/BotwSaveManager.Core/BotwSave.cs index a425f56..827d135 100644 --- a/BotwSaveManager.Core/BotwSave.cs +++ b/BotwSaveManager.Core/BotwSave.cs @@ -44,7 +44,10 @@ public class BotwSave 0xFD71ABCC, 0xCBC6B5E4, 0xE4B5C6CB, 0x2CADB0E7, 0xE7B0AD2C, 0xA6EB3EF4, 0xF43EEBA6, 0x21D4CFFA, 0xFACFD421, 0x22A510D1, 0xD110A522, 0x98D10D53, 0x530DD198, 0x55A22047, 0x4720A255, 0xE5A63A33, 0x333AA6E5, 0xBEC65061, 0x6150C6BE, 0xBC118370, 0x708311BC, - 0x0E9D0E75, 0x750E9D0E + 0x0E9D0E75, 0x750E9D0E, + // String field IDs in both byte orders: reverse the ID, not its UTF-8 value. + 0x0BEE9E46, 0x469EEE0B, // PlayerSavePosMapName (string32) + 0x1C10782F, 0x2F78101C // WolfLink_TodayFirstDetectedUID (string64) }; public SaveType SaveType { get; set; }