Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions BotWSaveManager.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions BotwSaveManager.Core.Tests/BotwSaveManager.Core.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../BotwSaveManager.Core/BotwSaveManager.Core.csproj" />
</ItemGroup>

</Project>
121 changes: 121 additions & 0 deletions BotwSaveManager.Core.Tests/StringConversionTests.cs
Original file line number Diff line number Diff line change
@@ -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<byte> bytes = stackalloc byte[4];
if (bigEndian) {
BinaryPrimitives.WriteUInt32BigEndian(bytes, value);
}
else {
BinaryPrimitives.WriteUInt32LittleEndian(bytes, value);
}
stream.Write(bytes);
}
}
5 changes: 4 additions & 1 deletion BotwSaveManager.Core/BotwSave.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down