From b45131679237f9229de48457e3e67ca57ac65cf2 Mon Sep 17 00:00:00 2001
From: Saransh Jain <13137998+saranshjain29@users.noreply.github.com>
Date: Sun, 25 Oct 2026 13:30:14 +0530
Subject: [PATCH] Preserve saved map name and Wolf Link UID during conversion
---
.github/workflows/tests.yml | 18 +++
BotWSaveManager.sln | 6 +
.../BotwSaveManager.Core.Tests.csproj | 24 ++++
.../StringConversionTests.cs | 121 ++++++++++++++++++
BotwSaveManager.Core/BotwSave.cs | 5 +-
5 files changed, 173 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/tests.yml
create mode 100644 BotwSaveManager.Core.Tests/BotwSaveManager.Core.Tests.csproj
create mode 100644 BotwSaveManager.Core.Tests/StringConversionTests.cs
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; }