From 2097a801e2ac0436518eb7d69e3f3e9cc798e213 Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Fri, 7 Aug 2026 00:16:56 +0200 Subject: [PATCH 1/8] feat: add BinaryPrefs facade over BinaryStorage --- src/Runtime/BinaryPrefs.cs | 153 ++++++++++++++++++++++++++++++++ src/Runtime/BinaryPrefs.cs.meta | 3 + 2 files changed, 156 insertions(+) create mode 100644 src/Runtime/BinaryPrefs.cs create mode 100644 src/Runtime/BinaryPrefs.cs.meta diff --git a/src/Runtime/BinaryPrefs.cs b/src/Runtime/BinaryPrefs.cs new file mode 100644 index 0000000..fa8a298 --- /dev/null +++ b/src/Runtime/BinaryPrefs.cs @@ -0,0 +1,153 @@ +using System.IO; +using UnityEngine; + +namespace Appegy.Storage +{ + public static class BinaryPrefs + { + private static readonly BinaryStorage _storage = BinaryStorage + .Construct(Path.Combine(Application.persistentDataPath, PackageInfo.Name, "player_prefs.bin")) + .AddPrimitiveTypes() + .EnableAutoSaveOnChange() + .SetMissingKeyBehaviour(MissingKeyBehavior.ReturnDefaultValueOnly) + .SetTypeMismatchBehaviour(TypeMismatchBehaviour.OverrideValueAndType) + .Build(); + + /// + /// Sets the value of the preference identified by the given key. + /// + /// The key to set the value for. + /// The value to set. + public static void SetInt(string key, int value) + { + _storage.Set(key, value); + } + + /// + /// Returns the value corresponding to key in the preference file if it exists. + /// If the key is not found in the current storage, it checks PlayerPrefs. + /// + /// The key to retrieve the value for. + /// The default value to return if the key does not exist. + /// The value corresponding to key. + public static int GetInt(string key, int defaultValue = 0) + { + if (_storage.Has(key)) + { + return _storage.Get(key, defaultValue); + } + + if (PlayerPrefs.HasKey(key)) + { + var value = PlayerPrefs.GetInt(key, defaultValue); + _storage.Set(key, value); + return value; + } + + return defaultValue; + } + + /// + /// Sets the value of the preference identified by the given key. + /// + /// The key to set the value for. + /// The value to set. + public static void SetFloat(string key, float value) + { + _storage.Set(key, value); + } + + /// + /// Returns the value corresponding to key in the preference file if it exists. + /// If the key is not found in the current storage, it checks PlayerPrefs. + /// + /// The key to retrieve the value for. + /// The default value to return if the key does not exist. + /// The value corresponding to key. + public static float GetFloat(string key, float defaultValue = 0f) + { + if (_storage.Has(key)) + { + return _storage.Get(key, defaultValue); + } + + if (PlayerPrefs.HasKey(key)) + { + var value = PlayerPrefs.GetFloat(key, defaultValue); + _storage.Set(key, value); + return value; + } + + return defaultValue; + } + + /// + /// Sets the value of the preference identified by the given key. + /// + /// The key to set the value for. + /// The value to set. + public static void SetString(string key, string value) + { + _storage.Set(key, value); + } + + /// + /// Returns the value corresponding to key in the preference file if it exists. + /// If the key is not found in the current storage, it checks PlayerPrefs. + /// + /// The key to retrieve the value for. + /// The default value to return if the key does not exist. + /// The value corresponding to key. + public static string GetString(string key, string defaultValue = "") + { + if (_storage.Has(key)) + { + return _storage.Get(key, defaultValue); + } + + if (PlayerPrefs.HasKey(key)) + { + var value = PlayerPrefs.GetString(key, defaultValue); + _storage.Set(key, value); + return value; + } + + return defaultValue; + } + + /// + /// Returns true if the key exists in the preference file. + /// + /// The key to check for existence. + /// True if the key exists; otherwise, false. + public static bool HasKey(string key) + { + return _storage.Has(key) || PlayerPrefs.HasKey(key); + } + + /// + /// Removes the given key from the preference file. + /// + /// The key to remove. + public static void DeleteKey(string key) + { + _storage.Remove(key); + } + + /// + /// Removes all keys and values from the preference file. + /// + public static void DeleteAll() + { + _storage.RemoveAll(); + } + + /// + /// Writes all modified preferences to disk. + /// + public static void Save() + { + _storage.Save(); + } + } +} \ No newline at end of file diff --git a/src/Runtime/BinaryPrefs.cs.meta b/src/Runtime/BinaryPrefs.cs.meta new file mode 100644 index 0000000..3588755 --- /dev/null +++ b/src/Runtime/BinaryPrefs.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 6aa0713cf5824cd5acef4288973e8500 +timeCreated: 1718807205 \ No newline at end of file From 7b2ceb4d6af28c358d9f44df3611fb796288c4fc Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Fri, 7 Aug 2026 00:16:57 +0200 Subject: [PATCH 2/8] test: cover BinaryPrefs api --- src/Tests/BinaryPrefsTests.cs | 289 +++++++++++++++++++++++++++++ src/Tests/BinaryPrefsTests.cs.meta | 3 + 2 files changed, 292 insertions(+) create mode 100644 src/Tests/BinaryPrefsTests.cs create mode 100644 src/Tests/BinaryPrefsTests.cs.meta diff --git a/src/Tests/BinaryPrefsTests.cs b/src/Tests/BinaryPrefsTests.cs new file mode 100644 index 0000000..1f58c39 --- /dev/null +++ b/src/Tests/BinaryPrefsTests.cs @@ -0,0 +1,289 @@ +using FluentAssertions; +using NUnit.Framework; +using UnityEngine; + +namespace Appegy.Storage +{ + [TestFixture] + public class BinaryPrefsIntTests + { + [SetUp, TearDown] + public void SetUp() + { + // Clear PlayerPrefs and BinaryPrefs before each test + PlayerPrefs.DeleteAll(); + BinaryPrefs.DeleteAll(); + } + + #region Integer + + [Test] + public void SetInt_ShouldStoreValueInBinaryStorage() + { + // Arrange + var key = "key"; + var value = 42; + + // Act + BinaryPrefs.SetInt(key, value); + + // Assert + BinaryPrefs.GetInt(key).Should().Be(value); + } + + [Test] + public void GetInt_ShouldReturnDefaultValueIfKeyNotFound() + { + // Arrange + var key = "unknownKey"; + var defaultValue = 10; + + // Act + var result = BinaryPrefs.GetInt(key, defaultValue); + + // Assert + result.Should().Be(defaultValue); + } + + [Test] + public void GetInt_ShouldReturnValueFromPlayerPrefsIfNotInBinaryStorage() + { + // Arrange + var key = "key"; + var playerPrefsValue = 100; + + // Store value in PlayerPrefs only + PlayerPrefs.SetInt(key, playerPrefsValue); + + // Act + var result = BinaryPrefs.GetInt(key); + + // Assert + result.Should().Be(playerPrefsValue); + + // Verify that the value is now stored in BinaryStorage + BinaryPrefs.GetInt(key).Should().Be(playerPrefsValue); + } + + [Test] + public void GetInt_ShouldReturnDefaultValueIfKeyNotFoundInBothStorages() + { + // Arrange + var key = "nonExistentKey"; + var defaultValue = 20; + + // Act + var result = BinaryPrefs.GetInt(key, defaultValue); + + // Assert + result.Should().Be(defaultValue); + } + + [Test] + public void SetInt_ShouldOverrideExistingValueInBinaryStorage() + { + // Arrange + var key = "key"; + var initialValue = 42; + var newValue = 84; + + // Store initial value + BinaryPrefs.SetInt(key, initialValue); + + // Act + BinaryPrefs.SetInt(key, newValue); + + // Assert + BinaryPrefs.GetInt(key).Should().Be(newValue); + } + + #endregion + + #region Float + + [Test] + public void SetFloat_ShouldStoreValueInBinaryStorage() + { + // Arrange + var key = "key"; + var value = 42f; + + // Act + BinaryPrefs.SetFloat(key, value); + + // Assert + BinaryPrefs.GetFloat(key).Should().Be(value); + } + + [Test] + public void GetFloat_ShouldReturnDefaultValueIfKeyNotFound() + { + // Arrange + var key = "unknownKey"; + var defaultValue = 10f; + + // Act + var result = BinaryPrefs.GetFloat(key, defaultValue); + + // Assert + result.Should().Be(defaultValue); + } + + [Test] + public void GetFloat_ShouldReturnValueFromPlayerPrefsIfNotInBinaryStorage() + { + // Arrange + var key = "key"; + var playerPrefsValue = 100f; + + // Store value in PlayerPrefs only + PlayerPrefs.SetFloat(key, playerPrefsValue); + + // Act + var result = BinaryPrefs.GetFloat(key); + + // Assert + result.Should().Be(playerPrefsValue); + + // Verify that the value is now stored in BinaryStorage + BinaryPrefs.GetFloat(key).Should().Be(playerPrefsValue); + } + + [Test] + public void GetFloat_ShouldReturnDefaultValueIfKeyNotFoundInBothStorages() + { + // Arrange + var key = "nonExistentKey"; + var defaultValue = 20f; + + // Act + var result = BinaryPrefs.GetFloat(key, defaultValue); + + // Assert + result.Should().Be(defaultValue); + } + + [Test] + public void SetFloat_ShouldOverrideExistingValueInBinaryStorage() + { + // Arrange + var key = "key"; + var initialValue = 42f; + var newValue = 84f; + + // Store initial value + BinaryPrefs.SetFloat(key, initialValue); + + // Act + BinaryPrefs.SetFloat(key, newValue); + + // Assert + BinaryPrefs.GetFloat(key).Should().Be(newValue); + } + + #endregion + + #region String + + [Test] + public void SetString_ShouldStoreValueInBinaryStorage() + { + // Arrange + var key = "key"; + var value = "42"; + + // Act + BinaryPrefs.SetString(key, value); + + // Assert + BinaryPrefs.GetString(key).Should().Be(value); + } + + [Test] + public void GetString_ShouldReturnDefaultValueIfKeyNotFound() + { + // Arrange + var key = "unknownKey"; + var defaultValue = "10"; + + // Act + var result = BinaryPrefs.GetString(key, defaultValue); + + // Assert + result.Should().Be(defaultValue); + } + + [Test] + public void GetString_ShouldReturnValueFromPlayerPrefsIfNotInBinaryStorage() + { + // Arrange + var key = "key"; + var playerPrefsValue = "100"; + + // Store value in PlayerPrefs only + PlayerPrefs.SetString(key, playerPrefsValue); + + // Act + var result = BinaryPrefs.GetString(key); + + // Assert + result.Should().Be(playerPrefsValue); + + // Verify that the value is now stored in BinaryStorage + BinaryPrefs.GetString(key).Should().Be(playerPrefsValue); + } + + [Test] + public void GetString_ShouldReturnDefaultValueIfKeyNotFoundInBothStorages() + { + // Arrange + var key = "nonExistentKey"; + var defaultValue = "20"; + + // Act + var result = BinaryPrefs.GetString(key, defaultValue); + + // Assert + result.Should().Be(defaultValue); + } + + [Test] + public void SetString_ShouldOverrideExistingValueInBinaryStorage() + { + // Arrange + var key = "key"; + var initialValue = "42"; + var newValue = "84"; + + // Store initial value + BinaryPrefs.SetString(key, initialValue); + + // Act + BinaryPrefs.SetString(key, newValue); + + // Assert + BinaryPrefs.GetString(key).Should().Be(newValue); + } + + #endregion + + #region Edges + + [Test] + public void SetInt_GetFloat_ShouldReturnCorrectInt() + { + // Arrange + var key = "key"; + var value = 42; + + // Act + PlayerPrefs.SetInt(key, value); + PlayerPrefs.SetFloat(key, value); + + // Assert + BinaryPrefs.GetFloat(key).Should().Be(value); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/Tests/BinaryPrefsTests.cs.meta b/src/Tests/BinaryPrefsTests.cs.meta new file mode 100644 index 0000000..0f6a260 --- /dev/null +++ b/src/Tests/BinaryPrefsTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e14b5abec5094cdc82b51f6861f43f94 +timeCreated: 1723741900 \ No newline at end of file From b4e37eebba157baa4aefc4744c579e29633c7bf1 Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Fri, 7 Aug 2026 00:16:59 +0200 Subject: [PATCH 3/8] chore: enable full stack traces in lab project --- lab/ProjectSettings/ProjectSettings.asset | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lab/ProjectSettings/ProjectSettings.asset b/lab/ProjectSettings/ProjectSettings.asset index b5ca404..00ec385 100644 --- a/lab/ProjectSettings/ProjectSettings.asset +++ b/lab/ProjectSettings/ProjectSettings.asset @@ -54,7 +54,7 @@ PlayerSettings: mipStripping: 0 numberOfMipsStripped: 0 numberOfMipsStrippedPerMipmapLimitGroup: {} - m_StackTraceTypes: 000000000000000000000000000000000000000001000000 + m_StackTraceTypes: 010000000100000001000000010000000100000001000000 iosShowActivityIndicatorOnLoading: -1 androidShowActivityIndicatorOnLoading: -1 iosUseCustomAppBackgroundBehavior: 0 From 879ca29ae97ff17ab72167315b07a139845520f8 Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Fri, 7 Aug 2026 00:56:18 +0200 Subject: [PATCH 4/8] refactor: make BinaryPrefs type-safe and converge PlayerPrefs migration --- src/Runtime/BinaryPrefs.cs | 407 ++++++++++++++++++++++++++++++++----- 1 file changed, 351 insertions(+), 56 deletions(-) diff --git a/src/Runtime/BinaryPrefs.cs b/src/Runtime/BinaryPrefs.cs index fa8a298..8bf63ca 100644 --- a/src/Runtime/BinaryPrefs.cs +++ b/src/Runtime/BinaryPrefs.cs @@ -1,153 +1,448 @@ +using System; using System.IO; using UnityEngine; namespace Appegy.Storage { + /// + /// A drop-in replacement for backed by . + /// Values missing from the binary storage are read from once, moved into the + /// binary storage and removed from , so every key converges to a single source of truth. + /// public static class BinaryPrefs { - private static readonly BinaryStorage _storage = BinaryStorage - .Construct(Path.Combine(Application.persistentDataPath, PackageInfo.Name, "player_prefs.bin")) + private const string StorageFileName = "player_prefs.bin"; + + private const int IntProbeA = int.MinValue; + private const int IntProbeB = int.MaxValue; + private const float FloatProbeA = float.MinValue; + private const float FloatProbeB = float.MaxValue; + private const string StringProbeA = "appegy.binary-prefs.probe.a"; + private const string StringProbeB = "appegy.binary-prefs.probe.b"; + + private static BinaryStorage _storage; + private static string _storageFilePath; + + private static string StorageFilePath => _storageFilePath ??= Path.Combine(PackageInfo.PersistentFolder, StorageFileName); + + private static BinaryStorage Storage => _storage ??= BinaryStorage + .Construct(StorageFilePath) .AddPrimitiveTypes() .EnableAutoSaveOnChange() .SetMissingKeyBehaviour(MissingKeyBehavior.ReturnDefaultValueOnly) .SetTypeMismatchBehaviour(TypeMismatchBehaviour.OverrideValueAndType) .Build(); - /// - /// Sets the value of the preference identified by the given key. - /// + #region Int + + /// Sets the value of the preference identified by the given key. /// The key to set the value for. /// The value to set. public static void SetInt(string key, int value) { - _storage.Set(key, value); + Write(key, value); } /// /// Returns the value corresponding to key in the preference file if it exists. - /// If the key is not found in the current storage, it checks PlayerPrefs. + /// If the key is not found in the binary storage, it is looked up in and migrated. /// /// The key to retrieve the value for. - /// The default value to return if the key does not exist. + /// The default value to return if the key does not exist or was stored with another type. /// The value corresponding to key. public static int GetInt(string key, int defaultValue = 0) { - if (_storage.Has(key)) + if (TryReadStored(key, out int stored)) { - return _storage.Get(key, defaultValue); + return stored; } - - if (PlayerPrefs.HasKey(key)) + if (TryReadLegacyInt(key, out var legacy)) { - var value = PlayerPrefs.GetInt(key, defaultValue); - _storage.Set(key, value); - return value; + return Migrate(key, legacy); } - return defaultValue; } - /// - /// Sets the value of the preference identified by the given key. - /// + #endregion + + #region Float + + /// Sets the value of the preference identified by the given key. /// The key to set the value for. /// The value to set. public static void SetFloat(string key, float value) { - _storage.Set(key, value); + Write(key, value); } /// /// Returns the value corresponding to key in the preference file if it exists. - /// If the key is not found in the current storage, it checks PlayerPrefs. + /// If the key is not found in the binary storage, it is looked up in and migrated. /// /// The key to retrieve the value for. - /// The default value to return if the key does not exist. + /// The default value to return if the key does not exist or was stored with another type. /// The value corresponding to key. public static float GetFloat(string key, float defaultValue = 0f) { - if (_storage.Has(key)) + if (TryReadStored(key, out float stored)) { - return _storage.Get(key, defaultValue); + return stored; } - - if (PlayerPrefs.HasKey(key)) + if (TryReadLegacyFloat(key, out var legacy)) { - var value = PlayerPrefs.GetFloat(key, defaultValue); - _storage.Set(key, value); - return value; + return Migrate(key, legacy); } - return defaultValue; } - /// - /// Sets the value of the preference identified by the given key. - /// + #endregion + + #region String + + /// Sets the value of the preference identified by the given key. /// The key to set the value for. /// The value to set. public static void SetString(string key, string value) { - _storage.Set(key, value); + Write(key, value); } /// /// Returns the value corresponding to key in the preference file if it exists. - /// If the key is not found in the current storage, it checks PlayerPrefs. + /// If the key is not found in the binary storage, it is looked up in and migrated. /// /// The key to retrieve the value for. - /// The default value to return if the key does not exist. + /// The default value to return if the key does not exist or was stored with another type. /// The value corresponding to key. public static string GetString(string key, string defaultValue = "") { - if (_storage.Has(key)) + if (TryReadStored(key, out string stored)) { - return _storage.Get(key, defaultValue); + return stored; } - - if (PlayerPrefs.HasKey(key)) + if (TryReadLegacyString(key, out var legacy)) { - var value = PlayerPrefs.GetString(key, defaultValue); - _storage.Set(key, value); - return value; + return Migrate(key, legacy); } + return defaultValue; + } + + #endregion + + #region Bool + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetBool(string key, bool value) + { + Write(key, value); + } + + /// + /// Returns the value corresponding to key in the preference file if it exists. + /// If the key is not found in the binary storage, it is looked up in as an int + /// (the conventional way of storing booleans there) and migrated. Only 0 and 1 are treated as booleans, + /// so an int preference holding any other value is neither migrated nor removed. + /// + /// The key to retrieve the value for. + /// The default value to return if the key does not exist or was stored with another type. + /// The value corresponding to key. + public static bool GetBool(string key, bool defaultValue = false) + { + if (TryReadStored(key, out bool stored)) + { + return stored; + } + if (TryReadLegacyInt(key, out var legacy) && legacy is 0 or 1) + { + return Migrate(key, legacy == 1); + } return defaultValue; } + #endregion + + #region Extended types + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetLong(string key, long value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static long GetLong(string key, long defaultValue = 0L) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetDouble(string key, double value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static double GetDouble(string key, double defaultValue = 0d) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetDateTime(string key, DateTime value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static DateTime GetDateTime(string key, DateTime defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetTimeSpan(string key, TimeSpan value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static TimeSpan GetTimeSpan(string key, TimeSpan defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetVector2(string key, Vector2 value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static Vector2 GetVector2(string key, Vector2 defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetVector3(string key, Vector3 value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static Vector3 GetVector3(string key, Vector3 defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetVector4(string key, Vector4 value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static Vector4 GetVector4(string key, Vector4 defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetVector2Int(string key, Vector2Int value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static Vector2Int GetVector2Int(string key, Vector2Int defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetVector3Int(string key, Vector3Int value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static Vector3Int GetVector3Int(string key, Vector3Int defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetQuaternion(string key, Quaternion value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static Quaternion GetQuaternion(string key, Quaternion defaultValue = default) => Read(key, defaultValue); + + /// + /// Sets the value of the preference identified by the given key. + /// The enum is stored as its underlying integral value, so no per-enum registration is required. + /// + /// The enum type to store. + /// The key to set the value for. + /// The value to set. + public static void SetEnum(string key, T value) + where T : unmanaged, Enum + { + Write(key, ToRawEnumValue(value)); + } + + /// Returns the enum value corresponding to key, or if it is missing or was stored with another type. + /// The enum type to read. + /// The key to retrieve the value for. + /// The default value to return if the key does not exist. + public static T GetEnum(string key, T defaultValue = default) + where T : unmanaged, Enum + { + return TryReadStored(key, out long stored) ? (T)Enum.ToObject(typeof(T), stored) : defaultValue; + } + /// - /// Returns true if the key exists in the preference file. + /// Sets the value of the preference identified by the given key. + /// The type must be registered in the storage; all types added by AddPrimitiveTypes are supported. /// + /// The type of the value. + /// The key to set the value for. + /// The value to set. + /// Thrown if the type is not supported by the storage. + public static void Set(string key, T value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + /// The type of the value. + /// The key to retrieve the value for. + /// The default value to return if the key does not exist. + /// Thrown if the type is not supported by the storage. + public static T Get(string key, T defaultValue = default) => Read(key, defaultValue); + + /// Returns the type the key is stored with, or null if the key is not present in the binary storage. + /// The key to get the type for. + public static Type TypeOf(string key) => Storage.TypeOf(key); + + #endregion + + #region Management + + /// Returns true if the key exists in the preference file or in the not yet migrated . /// The key to check for existence. /// True if the key exists; otherwise, false. public static bool HasKey(string key) { - return _storage.Has(key) || PlayerPrefs.HasKey(key); + return Storage.Has(key) || PlayerPrefs.HasKey(key); } - /// - /// Removes the given key from the preference file. - /// + /// Removes the given key from the preference file and from . /// The key to remove. public static void DeleteKey(string key) { - _storage.Remove(key); + Storage.Remove(key); + if (PlayerPrefs.HasKey(key)) + { + PlayerPrefs.DeleteKey(key); + PlayerPrefs.Save(); + } } /// - /// Removes all keys and values from the preference file. + /// Removes all keys and values from the preference file and from . + /// Mirrors , which wipes every key of the application, including keys written by Unity and third-party packages. /// public static void DeleteAll() { - _storage.RemoveAll(); + Storage.RemoveAll(); + PlayerPrefs.DeleteAll(); + PlayerPrefs.Save(); } - /// - /// Writes all modified preferences to disk. - /// + /// Writes all modified preferences to disk. public static void Save() { - _storage.Save(); + Storage.Save(); + } + + #endregion + + #region Internals + + internal static void OverrideStorageFilePath(string filePath) + { + DisposeStorage(); + _storageFilePath = filePath; + } + + internal static void Reset() + { + DisposeStorage(); + _storageFilePath = null; + } + + private static void DisposeStorage() + { + _storage?.Dispose(); + _storage = null; + } + + private static void Write(string key, T value) + { + var exists = Storage.Has(key); + Storage.Set(key, value); + if (!exists && PlayerPrefs.HasKey(key)) + { + PlayerPrefs.DeleteKey(key); + PlayerPrefs.Save(); + } + } + + private static T Read(string key, T defaultValue) + { + return TryReadStored(key, out T stored) ? stored : defaultValue; + } + + private static bool TryReadStored(string key, out T value) + { + if (Storage.TypeOf(key) == typeof(T)) + { + value = Storage.Get(key); + return true; + } + value = default; + return false; } + + private static T Migrate(string key, T value) + { + Storage.Set(key, value); + PlayerPrefs.DeleteKey(key); + PlayerPrefs.Save(); + return value; + } + + private static bool TryReadLegacyInt(string key, out int value) + { + if (!PlayerPrefs.HasKey(key)) + { + value = default; + return false; + } + value = PlayerPrefs.GetInt(key, IntProbeA); + if (value != IntProbeA) + { + return true; + } + value = PlayerPrefs.GetInt(key, IntProbeB); + return value != IntProbeB; + } + + private static bool TryReadLegacyFloat(string key, out float value) + { + if (!PlayerPrefs.HasKey(key)) + { + value = default; + return false; + } + value = PlayerPrefs.GetFloat(key, FloatProbeA); + if (value != FloatProbeA) + { + return true; + } + value = PlayerPrefs.GetFloat(key, FloatProbeB); + return value != FloatProbeB; + } + + private static bool TryReadLegacyString(string key, out string value) + { + if (!PlayerPrefs.HasKey(key)) + { + value = default; + return false; + } + value = PlayerPrefs.GetString(key, StringProbeA); + if (value != StringProbeA) + { + return true; + } + value = PlayerPrefs.GetString(key, StringProbeB); + return value != StringProbeB; + } + + private static long ToRawEnumValue(T value) + where T : unmanaged, Enum + { + return Enum.GetUnderlyingType(typeof(T)) == typeof(ulong) + ? unchecked((long)Convert.ToUInt64(value)) + : Convert.ToInt64(value); + } + + #endregion } -} \ No newline at end of file +} From af3561ef7310799e79f00596627be23941f2d2f6 Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Fri, 7 Aug 2026 00:56:19 +0200 Subject: [PATCH 5/8] test: cover BinaryPrefs migration, deletion and extended types --- src/Tests/BinaryPrefsTests.cs | 654 +++++++++++++++++++++++++--------- 1 file changed, 490 insertions(+), 164 deletions(-) diff --git a/src/Tests/BinaryPrefsTests.cs b/src/Tests/BinaryPrefsTests.cs index 1f58c39..2ffe9de 100644 --- a/src/Tests/BinaryPrefsTests.cs +++ b/src/Tests/BinaryPrefsTests.cs @@ -1,289 +1,615 @@ +using System; +using System.IO; using FluentAssertions; using NUnit.Framework; using UnityEngine; namespace Appegy.Storage { - [TestFixture] - public class BinaryPrefsIntTests + public class BinaryPrefsTestsBase { + protected static readonly string PrefsPath = Path.Combine(Application.temporaryCachePath, "test_prefs.bin"); + [SetUp, TearDown] - public void SetUp() + public void CleanPrefsBetweenTests() { - // Clear PlayerPrefs and BinaryPrefs before each test + BinaryPrefs.Reset(); PlayerPrefs.DeleteAll(); - BinaryPrefs.DeleteAll(); + PlayerPrefs.Save(); + if (File.Exists(PrefsPath)) + { + File.Delete(PrefsPath); + } + BinaryPrefs.OverrideStorageFilePath(PrefsPath); } + } - #region Integer - + [TestFixture] + public class BinaryPrefsPrimitiveTests : BinaryPrefsTestsBase + { [Test] public void SetInt_ShouldStoreValueInBinaryStorage() { - // Arrange - var key = "key"; - var value = 42; - - // Act - BinaryPrefs.SetInt(key, value); + BinaryPrefs.SetInt("key", 42); - // Assert - BinaryPrefs.GetInt(key).Should().Be(value); + BinaryPrefs.GetInt("key").Should().Be(42); + BinaryPrefs.TypeOf("key").Should().Be(typeof(int)); } [Test] public void GetInt_ShouldReturnDefaultValueIfKeyNotFound() { - // Arrange - var key = "unknownKey"; - var defaultValue = 10; + BinaryPrefs.GetInt("unknownKey", 10).Should().Be(10); + } + + [Test] + public void SetInt_ShouldOverrideExistingValue() + { + BinaryPrefs.SetInt("key", 42); + BinaryPrefs.SetInt("key", 84); - // Act - var result = BinaryPrefs.GetInt(key, defaultValue); + BinaryPrefs.GetInt("key").Should().Be(84); + } - // Assert - result.Should().Be(defaultValue); + [Test] + public void SetFloat_ShouldStoreValueInBinaryStorage() + { + BinaryPrefs.SetFloat("key", 42.5f); + + BinaryPrefs.GetFloat("key").Should().Be(42.5f); + BinaryPrefs.TypeOf("key").Should().Be(typeof(float)); + } + + [Test] + public void GetFloat_ShouldReturnDefaultValueIfKeyNotFound() + { + BinaryPrefs.GetFloat("unknownKey", 10f).Should().Be(10f); + } + + [Test] + public void SetString_ShouldStoreValueInBinaryStorage() + { + BinaryPrefs.SetString("key", "value"); + + BinaryPrefs.GetString("key").Should().Be("value"); + BinaryPrefs.TypeOf("key").Should().Be(typeof(string)); + } + + [Test] + public void GetString_ShouldReturnDefaultValueIfKeyNotFound() + { + BinaryPrefs.GetString("unknownKey", "fallback").Should().Be("fallback"); } [Test] - public void GetInt_ShouldReturnValueFromPlayerPrefsIfNotInBinaryStorage() + public void SetBool_ShouldStoreValueInBinaryStorage() { - // Arrange - var key = "key"; - var playerPrefsValue = 100; + BinaryPrefs.SetBool("key", true); - // Store value in PlayerPrefs only - PlayerPrefs.SetInt(key, playerPrefsValue); + BinaryPrefs.GetBool("key").Should().BeTrue(); + BinaryPrefs.TypeOf("key").Should().Be(typeof(bool)); + } - // Act - var result = BinaryPrefs.GetInt(key); + [Test] + public void GetBool_ShouldReturnDefaultValueIfKeyNotFound() + { + BinaryPrefs.GetBool("unknownKey", true).Should().BeTrue(); + } + } - // Assert - result.Should().Be(playerPrefsValue); + [TestFixture] + public class BinaryPrefsTypeMismatchTests : BinaryPrefsTestsBase + { + [Test] + public void GetString_ShouldNotThrowWhenKeyStoredAsInt() + { + BinaryPrefs.SetInt("key", 42); - // Verify that the value is now stored in BinaryStorage - BinaryPrefs.GetInt(key).Should().Be(playerPrefsValue); + FluentActions.Invoking(() => BinaryPrefs.GetString("key")).Should().NotThrow(); } [Test] - public void GetInt_ShouldReturnDefaultValueIfKeyNotFoundInBothStorages() + public void GetString_ShouldReturnDefaultWhenKeyStoredAsInt() { - // Arrange - var key = "nonExistentKey"; - var defaultValue = 20; + BinaryPrefs.SetInt("key", 42); + + BinaryPrefs.GetString("key", "fallback").Should().Be("fallback"); + } - // Act - var result = BinaryPrefs.GetInt(key, defaultValue); + [Test] + public void GetInt_ShouldReturnDefaultWhenKeyStoredAsFloat() + { + BinaryPrefs.SetFloat("key", 42.5f); - // Assert - result.Should().Be(defaultValue); + BinaryPrefs.GetInt("key", 7).Should().Be(7); } [Test] - public void SetInt_ShouldOverrideExistingValueInBinaryStorage() + public void GetInt_ShouldReturnDefaultWhenKeyStoredAsBool() { - // Arrange - var key = "key"; - var initialValue = 42; - var newValue = 84; + BinaryPrefs.SetBool("key", true); - // Store initial value - BinaryPrefs.SetInt(key, initialValue); + BinaryPrefs.GetInt("key", 7).Should().Be(7); + } - // Act - BinaryPrefs.SetInt(key, newValue); + [Test] + public void Set_ShouldReplaceTypeOfExistingKey() + { + BinaryPrefs.SetInt("key", 42); + BinaryPrefs.SetString("key", "value"); - // Assert - BinaryPrefs.GetInt(key).Should().Be(newValue); + BinaryPrefs.GetString("key").Should().Be("value"); + BinaryPrefs.TypeOf("key").Should().Be(typeof(string)); } + } - #endregion + [TestFixture] + public class BinaryPrefsMigrationTests : BinaryPrefsTestsBase + { + [Test] + public void GetInt_ShouldReturnValueFromPlayerPrefsWhenNotInBinaryStorage() + { + PlayerPrefs.SetInt("key", 100); - #region Float + BinaryPrefs.GetInt("key").Should().Be(100); + } [Test] - public void SetFloat_ShouldStoreValueInBinaryStorage() + public void GetInt_ShouldMoveValueIntoBinaryStorage() { - // Arrange - var key = "key"; - var value = 42f; + PlayerPrefs.SetInt("key", 100); - // Act - BinaryPrefs.SetFloat(key, value); + BinaryPrefs.GetInt("key"); - // Assert - BinaryPrefs.GetFloat(key).Should().Be(value); + BinaryPrefs.TypeOf("key").Should().Be(typeof(int)); } [Test] - public void GetFloat_ShouldReturnDefaultValueIfKeyNotFound() + public void GetInt_ShouldRemoveMigratedKeyFromPlayerPrefs() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.GetInt("key"); + + PlayerPrefs.HasKey("key").Should().BeFalse(); + } + + [Test] + public void GetFloat_ShouldMigrateValueFromPlayerPrefs() { - // Arrange - var key = "unknownKey"; - var defaultValue = 10f; + PlayerPrefs.SetFloat("key", 100.5f); + + BinaryPrefs.GetFloat("key").Should().Be(100.5f); + PlayerPrefs.HasKey("key").Should().BeFalse(); + BinaryPrefs.TypeOf("key").Should().Be(typeof(float)); + } - // Act - var result = BinaryPrefs.GetFloat(key, defaultValue); + [Test] + public void GetString_ShouldMigrateValueFromPlayerPrefs() + { + PlayerPrefs.SetString("key", "legacy"); - // Assert - result.Should().Be(defaultValue); + BinaryPrefs.GetString("key").Should().Be("legacy"); + PlayerPrefs.HasKey("key").Should().BeFalse(); + BinaryPrefs.TypeOf("key").Should().Be(typeof(string)); } [Test] - public void GetFloat_ShouldReturnValueFromPlayerPrefsIfNotInBinaryStorage() + public void GetBool_ShouldMigrateIntValueFromPlayerPrefs() { - // Arrange - var key = "key"; - var playerPrefsValue = 100f; + PlayerPrefs.SetInt("key", 1); + + BinaryPrefs.GetBool("key").Should().BeTrue(); + PlayerPrefs.HasKey("key").Should().BeFalse(); + BinaryPrefs.TypeOf("key").Should().Be(typeof(bool)); + } - // Store value in PlayerPrefs only - PlayerPrefs.SetFloat(key, playerPrefsValue); + [Test] + public void GetBool_ShouldMigrateZeroIntAsFalse() + { + PlayerPrefs.SetInt("key", 0); - // Act - var result = BinaryPrefs.GetFloat(key); + BinaryPrefs.GetBool("key", true).Should().BeFalse(); + } - // Assert - result.Should().Be(playerPrefsValue); + [Test] + public void GetBool_ShouldNotMigrateIntValueOutsideZeroAndOne() + { + PlayerPrefs.SetInt("key", 5); - // Verify that the value is now stored in BinaryStorage - BinaryPrefs.GetFloat(key).Should().Be(playerPrefsValue); + BinaryPrefs.GetBool("key", true).Should().BeTrue(); } [Test] - public void GetFloat_ShouldReturnDefaultValueIfKeyNotFoundInBothStorages() + public void GetBool_ShouldNotDeleteIntValueOutsideZeroAndOne() { - // Arrange - var key = "nonExistentKey"; - var defaultValue = 20f; + PlayerPrefs.SetInt("key", 5); - // Act - var result = BinaryPrefs.GetFloat(key, defaultValue); + BinaryPrefs.GetBool("key"); + + BinaryPrefs.GetInt("key").Should().Be(5); + } + + [TestCase(int.MinValue)] + [TestCase(int.MaxValue)] + [TestCase(0)] + public void GetInt_ShouldMigrateValuesEqualToProbeSentinels(int value) + { + PlayerPrefs.SetInt("key", value); - // Assert - result.Should().Be(defaultValue); + BinaryPrefs.GetInt("key").Should().Be(value); + } + + [TestCase(float.MinValue)] + [TestCase(float.MaxValue)] + [TestCase(0f)] + public void GetFloat_ShouldMigrateValuesEqualToProbeSentinels(float value) + { + PlayerPrefs.SetFloat("key", value); + + BinaryPrefs.GetFloat("key").Should().Be(value); } [Test] - public void SetFloat_ShouldOverrideExistingValueInBinaryStorage() + public void GetInt_ShouldNotMigrateKeyStoredAsStringInPlayerPrefs() { - // Arrange - var key = "key"; - var initialValue = 42f; - var newValue = 84f; + PlayerPrefs.SetString("key", "legacy"); - // Store initial value - BinaryPrefs.SetFloat(key, initialValue); + BinaryPrefs.GetInt("key", 7).Should().Be(7); + } + + [Test] + public void GetInt_ShouldNotDeleteKeyStoredAsStringInPlayerPrefs() + { + PlayerPrefs.SetString("key", "legacy"); - // Act - BinaryPrefs.SetFloat(key, newValue); + BinaryPrefs.GetInt("key"); - // Assert - BinaryPrefs.GetFloat(key).Should().Be(newValue); + PlayerPrefs.GetString("key").Should().Be("legacy"); } - #endregion + [Test] + public void GetString_ShouldNotMigrateKeyStoredAsIntInPlayerPrefs() + { + PlayerPrefs.SetInt("key", 100); - #region String + BinaryPrefs.GetString("key", "fallback").Should().Be("fallback"); + PlayerPrefs.GetInt("key").Should().Be(100); + } [Test] - public void SetString_ShouldStoreValueInBinaryStorage() + public void SetString_ShouldRemoveShadowedPlayerPrefsKey() { - // Arrange - var key = "key"; - var value = "42"; + PlayerPrefs.SetInt("key", 100); - // Act - BinaryPrefs.SetString(key, value); + BinaryPrefs.SetString("key", "value"); - // Assert - BinaryPrefs.GetString(key).Should().Be(value); + PlayerPrefs.HasKey("key").Should().BeFalse(); } [Test] - public void GetString_ShouldReturnDefaultValueIfKeyNotFound() + public void SetString_ShouldRemoveShadowedPlayerPrefsKeyAfterStorageReload() + { + PlayerPrefs.SetInt("key", 100); + BinaryPrefs.SetString("other", "value"); + BinaryPrefs.OverrideStorageFilePath(PrefsPath); + + BinaryPrefs.SetString("key", "value"); + + PlayerPrefs.HasKey("key").Should().BeFalse(); + } + + [Test] + public void GetInt_ShouldNotResurrectPlayerPrefsValueAfterKeyWasOverwrittenWithAnotherType() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.SetString("key", "value"); + + BinaryPrefs.GetInt("key", 7).Should().Be(7); + } + } + + [TestFixture] + public class BinaryPrefsDeletionTests : BinaryPrefsTestsBase + { + [Test] + public void HasKey_ShouldBeTrueForKeyOnlyInPlayerPrefs() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.HasKey("key").Should().BeTrue(); + } + + [Test] + public void HasKey_ShouldBeFalseForUnknownKey() + { + BinaryPrefs.HasKey("unknownKey").Should().BeFalse(); + } + + [Test] + public void DeleteKey_ShouldRemoveKeyFromBinaryStorage() + { + BinaryPrefs.SetInt("key", 42); + + BinaryPrefs.DeleteKey("key"); + + BinaryPrefs.HasKey("key").Should().BeFalse(); + } + + [Test] + public void DeleteKey_ShouldRemoveKeyFromPlayerPrefs() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.DeleteKey("key"); + + PlayerPrefs.HasKey("key").Should().BeFalse(); + } + + [Test] + public void DeleteKey_ShouldNotResurrectValueFromPlayerPrefs() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.DeleteKey("key"); + + BinaryPrefs.GetInt("key", 7).Should().Be(7); + } + + [Test] + public void DeleteAll_ShouldRemoveEverythingFromBothStorages() + { + BinaryPrefs.SetInt("stored", 42); + PlayerPrefs.SetInt("legacy", 100); + + BinaryPrefs.DeleteAll(); + + BinaryPrefs.HasKey("stored").Should().BeFalse(); + BinaryPrefs.HasKey("legacy").Should().BeFalse(); + } + + [Test] + public void DeleteAll_ShouldNotResurrectValuesFromPlayerPrefs() + { + PlayerPrefs.SetInt("legacy", 100); + + BinaryPrefs.DeleteAll(); + + BinaryPrefs.GetInt("legacy", 7).Should().Be(7); + } + } + + [TestFixture] + public class BinaryPrefsPersistenceTests : BinaryPrefsTestsBase + { + [Test] + public void Values_ShouldSurviveStorageReload() + { + BinaryPrefs.SetInt("int", 42); + BinaryPrefs.SetString("string", "value"); + BinaryPrefs.SetBool("bool", true); + + ReopenStorage(); + + BinaryPrefs.GetInt("int").Should().Be(42); + BinaryPrefs.GetString("string").Should().Be("value"); + BinaryPrefs.GetBool("bool").Should().BeTrue(); + } + + [Test] + public void MigratedValues_ShouldSurviveStorageReload() + { + PlayerPrefs.SetInt("key", 100); + BinaryPrefs.GetInt("key"); + + ReopenStorage(); + + BinaryPrefs.GetInt("key").Should().Be(100); + } + + [Test] + public void DeletedKeys_ShouldNotComeBackAfterStorageReload() + { + BinaryPrefs.SetInt("key", 42); + BinaryPrefs.DeleteKey("key"); + + ReopenStorage(); + + BinaryPrefs.HasKey("key").Should().BeFalse(); + } + + private static void ReopenStorage() + { + BinaryPrefs.Save(); + BinaryPrefs.OverrideStorageFilePath(PrefsPath); + } + } + + [TestFixture] + public class BinaryPrefsExtendedTypesTests : BinaryPrefsTestsBase + { + [Test] + public void SetLong_GetLong_ShouldRoundTrip() + { + BinaryPrefs.SetLong("key", long.MaxValue); + + BinaryPrefs.GetLong("key").Should().Be(long.MaxValue); + } + + [Test] + public void SetDouble_GetDouble_ShouldRoundTrip() + { + BinaryPrefs.SetDouble("key", 42.125d); + + BinaryPrefs.GetDouble("key").Should().Be(42.125d); + } + + [Test] + public void SetDateTime_GetDateTime_ShouldRoundTrip() + { + var value = new DateTime(2024, 5, 17, 13, 45, 30, DateTimeKind.Utc); + + BinaryPrefs.SetDateTime("key", value); + + BinaryPrefs.GetDateTime("key").Should().Be(value); + } + + [Test] + public void SetTimeSpan_GetTimeSpan_ShouldRoundTrip() + { + var value = TimeSpan.FromMinutes(90); + + BinaryPrefs.SetTimeSpan("key", value); + + BinaryPrefs.GetTimeSpan("key").Should().Be(value); + } + + [Test] + public void SetVector2_GetVector2_ShouldRoundTrip() { - // Arrange - var key = "unknownKey"; - var defaultValue = "10"; + BinaryPrefs.SetVector2("key", new Vector2(1f, 2f)); + + BinaryPrefs.GetVector2("key").Should().Be(new Vector2(1f, 2f)); + } - // Act - var result = BinaryPrefs.GetString(key, defaultValue); + [Test] + public void SetVector3_GetVector3_ShouldRoundTrip() + { + BinaryPrefs.SetVector3("key", new Vector3(1f, 2f, 3f)); - // Assert - result.Should().Be(defaultValue); + BinaryPrefs.GetVector3("key").Should().Be(new Vector3(1f, 2f, 3f)); } [Test] - public void GetString_ShouldReturnValueFromPlayerPrefsIfNotInBinaryStorage() + public void SetVector4_GetVector4_ShouldRoundTrip() { - // Arrange - var key = "key"; - var playerPrefsValue = "100"; + BinaryPrefs.SetVector4("key", new Vector4(1f, 2f, 3f, 4f)); + + BinaryPrefs.GetVector4("key").Should().Be(new Vector4(1f, 2f, 3f, 4f)); + } + + [Test] + public void SetVector2Int_GetVector2Int_ShouldRoundTrip() + { + BinaryPrefs.SetVector2Int("key", new Vector2Int(1, 2)); + + BinaryPrefs.GetVector2Int("key").Should().Be(new Vector2Int(1, 2)); + } + + [Test] + public void SetVector3Int_GetVector3Int_ShouldRoundTrip() + { + BinaryPrefs.SetVector3Int("key", new Vector3Int(1, 2, 3)); + + BinaryPrefs.GetVector3Int("key").Should().Be(new Vector3Int(1, 2, 3)); + } + + [Test] + public void SetQuaternion_GetQuaternion_ShouldRoundTrip() + { + BinaryPrefs.SetQuaternion("key", new Quaternion(1f, 2f, 3f, 4f)); + + BinaryPrefs.GetQuaternion("key").Should().Be(new Quaternion(1f, 2f, 3f, 4f)); + } - // Store value in PlayerPrefs only - PlayerPrefs.SetString(key, playerPrefsValue); + [Test] + public void GetLong_ShouldReturnDefaultWhenKeyStoredAsInt() + { + BinaryPrefs.SetInt("key", 42); - // Act - var result = BinaryPrefs.GetString(key); + BinaryPrefs.GetLong("key", 7L).Should().Be(7L); + } - // Assert - result.Should().Be(playerPrefsValue); + [Test] + public void SetGeneric_GetGeneric_ShouldRoundTrip() + { + BinaryPrefs.Set("key", 42.125d); - // Verify that the value is now stored in BinaryStorage - BinaryPrefs.GetString(key).Should().Be(playerPrefsValue); + BinaryPrefs.Get("key").Should().Be(42.125d); } [Test] - public void GetString_ShouldReturnDefaultValueIfKeyNotFoundInBothStorages() + public void GetGeneric_ShouldReturnDefaultWhenTypeDoesNotMatch() { - // Arrange - var key = "nonExistentKey"; - var defaultValue = "20"; + BinaryPrefs.SetInt("key", 42); - // Act - var result = BinaryPrefs.GetString(key, defaultValue); + BinaryPrefs.Get("key", 7d).Should().Be(7d); + } - // Assert - result.Should().Be(defaultValue); + [Test] + public void SetGeneric_ShouldThrowForUnregisteredType() + { + FluentActions.Invoking(() => BinaryPrefs.Set("key", new object())).Should().Throw(); } [Test] - public void SetString_ShouldOverrideExistingValueInBinaryStorage() + public void TypeOf_ShouldReturnNullForUnknownKey() { - // Arrange - var key = "key"; - var initialValue = "42"; - var newValue = "84"; + BinaryPrefs.TypeOf("unknownKey").Should().BeNull(); + } + } - // Store initial value - BinaryPrefs.SetString(key, initialValue); + [TestFixture] + public class BinaryPrefsEnumTests : BinaryPrefsTestsBase + { + private enum IntBacked + { + None = 0, + Second = 2, + Negative = -5 + } - // Act - BinaryPrefs.SetString(key, newValue); + private enum ByteBacked : byte + { + None = 0, + Max = byte.MaxValue + } - // Assert - BinaryPrefs.GetString(key).Should().Be(newValue); + private enum ULongBacked : ulong + { + None = 0, + Max = ulong.MaxValue } - #endregion + [TestCase(IntBacked.Second)] + [TestCase(IntBacked.Negative)] + [TestCase(IntBacked.None)] + public void SetEnum_GetEnum_ShouldRoundTripIntBackedEnum(IntBacked value) + { + BinaryPrefs.SetEnum("key", value); - #region Edges + BinaryPrefs.GetEnum("key").Should().Be(value); + } [Test] - public void SetInt_GetFloat_ShouldReturnCorrectInt() + public void SetEnum_GetEnum_ShouldRoundTripByteBackedEnum() { - // Arrange - var key = "key"; - var value = 42; + BinaryPrefs.SetEnum("key", ByteBacked.Max); + + BinaryPrefs.GetEnum("key").Should().Be(ByteBacked.Max); + } - // Act - PlayerPrefs.SetInt(key, value); - PlayerPrefs.SetFloat(key, value); + [Test] + public void SetEnum_GetEnum_ShouldRoundTripULongBackedEnum() + { + BinaryPrefs.SetEnum("key", ULongBacked.Max); - // Assert - BinaryPrefs.GetFloat(key).Should().Be(value); + BinaryPrefs.GetEnum("key").Should().Be(ULongBacked.Max); } - #endregion + [Test] + public void GetEnum_ShouldReturnDefaultWhenKeyNotFound() + { + BinaryPrefs.GetEnum("unknownKey", IntBacked.Second).Should().Be(IntBacked.Second); + } + + [Test] + public void GetEnum_ShouldReturnDefaultWhenKeyStoredAsInt() + { + BinaryPrefs.SetInt("key", 2); + + BinaryPrefs.GetEnum("key", IntBacked.Negative).Should().Be(IntBacked.Negative); + } } -} \ No newline at end of file +} From 37eef24fcfc97bb73457a6013765dab7e333f2c2 Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Fri, 7 Aug 2026 00:56:19 +0200 Subject: [PATCH 6/8] docs: document BinaryPrefs and migration from PlayerPrefs --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/README.md b/README.md index 8f074e4..17271af 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ And the feature I like most: **every change is persisted the moment it happens** - [Package installation](#package-installation) - [Quick start](#quick-start) +- [Replacing PlayerPrefs](#replacing-playerprefs) - [Configuring storage](#configuring-storage) - [Reading and writing](#reading-and-writing) - [Collections](#collections) @@ -82,6 +83,58 @@ string name = storage.Get("player_name", "Unknown"); > `BinaryStorage` implements `IDisposable`. Dispose it (e.g. with `using`) to flush and release the file. In the Editor the file path is locked while a storage instance is open, preventing accidental concurrent access to the same file. +## Replacing PlayerPrefs + +If you already use `PlayerPrefs` and just want a better one, `BinaryPrefs` is a static drop-in replacement. Rename the type and you are done - no path, no builder, no lifetime to manage. + +```csharp +using Appegy.Storage; + +// PlayerPrefs.SetInt("player_score", 100); +BinaryPrefs.SetInt("player_score", 100); + +int score = BinaryPrefs.GetInt("player_score", 0); +``` + +It keeps the whole `PlayerPrefs` surface - `SetInt`/`GetInt`, `SetFloat`/`GetFloat`, `SetString`/`GetString`, `HasKey`, `DeleteKey`, `DeleteAll`, `Save` - and adds the types `PlayerPrefs` never had: + +```csharp +BinaryPrefs.SetBool("music_enabled", false); +BinaryPrefs.SetLong("total_xp", 12_000_000_000L); +BinaryPrefs.SetDouble("precise_balance", 1234.5678d); +BinaryPrefs.SetDateTime("last_login", DateTime.UtcNow); +BinaryPrefs.SetTimeSpan("play_time", TimeSpan.FromHours(3)); +BinaryPrefs.SetVector3("last_position", transform.position); +BinaryPrefs.SetQuaternion("last_rotation", transform.rotation); +BinaryPrefs.SetEnum("difficulty", Difficulty.Hard); + +BinaryPrefs.Set("custom_key", 42.5d); // any type registered by AddPrimitiveTypes +double value = BinaryPrefs.Get("custom_key", 0d); +Type stored = BinaryPrefs.TypeOf("custom_key"); +``` + +Enums are stored as their underlying integral value, so they work through `SetEnum`/`GetEnum` without any registration. The generic `Set`/`Get` accepts every type registered by `AddPrimitiveTypes` and throws `UnregisteredTypeException` for anything else. + + +### Migration from PlayerPrefs + +Existing data is migrated lazily, one key at a time. When a key is missing from the binary file, `BinaryPrefs` looks it up in `PlayerPrefs`; if it is there with a matching type, the value is written to the binary storage and only then removed from `PlayerPrefs`. Every key therefore converges to a single source of truth, and nothing is deleted before it has been persisted. + +`bool` values are migrated from the conventional `PlayerPrefs` int representation, where a non-zero value means `true`. + +A key stored in `PlayerPrefs` under a different type is left untouched - reading `GetInt` for a key that `PlayerPrefs` holds as a string returns the default value and does not destroy the string. + + +### Differences from PlayerPrefs + +- Reads are type-safe but never throw. `SetInt("k", 1)` followed by `GetString("k")` returns the default value instead of garbage. +- `HasKey` returns `true` for keys that still live in `PlayerPrefs` and have not been migrated yet. +- `DeleteKey` removes the key from both storages, so a deleted key cannot come back from `PlayerPrefs`. +- `DeleteAll` mirrors `PlayerPrefs.DeleteAll` and wipes every key of the application, including keys written by Unity itself and by third-party packages. +- Every change is written to disk immediately, so calling `Save` is optional. + +The file lives at `Application.persistentDataPath/com.appegy.binary-prefs/player_prefs.bin`. When you need a different path, several files or scoped sub-storages, use `BinaryStorage` directly. + ## Configuring storage For full control use the fluent builder via `BinaryStorage.Construct`: From e460325cf0f5f84c1b4736e22b87fc70c4585ff8 Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Fri, 7 Aug 2026 01:04:27 +0200 Subject: [PATCH 7/8] chore: route Unity MCP to this checkout by project hash --- .mcp.json | 12 ++++++++++++ tools/unity-mcp.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 .mcp.json create mode 100644 tools/unity-mcp.py diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..9b99f72 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "UnityMCP": { + "command": "uv", + "args": [ + "run", "--no-project", "python", + "-c", "import os,runpy; runpy.run_path(os.environ['CLAUDE_PROJECT_DIR']+'/tools/unity-mcp.py', run_name='__main__')", + "lab" + ] + } + } +} diff --git a/tools/unity-mcp.py b/tools/unity-mcp.py new file mode 100644 index 0000000..d3b309a --- /dev/null +++ b/tools/unity-mcp.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Launch the MCP for Unity server, routed to THIS checkout's Unity Editor. + +Invoked from .mcp.json. The Unity subfolder name is passed as argv[1] so this +script stays identical across repos/worktrees. + +It derives the Unity project hash like the Editor bridge +(ProjectIdentityUtility.ComputeProjectHash: hex of sha1(Application.dataPath)), +then starts the server with --default-instance . Routing by hash (which is +a function of the absolute project path) is what makes a worktree resolve to its +own Editor instead of the main checkout's - their project names are identical, only +the path-derived hash differs. + +We use the first 8 hex chars: that is exactly what Unity names its stdio status +file (unity-mcp-status-<8>.json), so it matches by `==` in stdio discovery, and it +also prefix-matches the 16-char hash the HTTP hub reports - one value works in both. +""" +import hashlib +import os +import subprocess +import sys + +SERVER_PACKAGE = "mcpforunityserver==10.1.0" + +unity_subdir = sys.argv[1] + +# CLAUDE_PROJECT_DIR is set by Claude Code to the checkout/worktree root. Forward +# slashes + no trailing slash match Unity's Application.dataPath on every platform. +root = (os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()).replace("\\", "/").rstrip("/") +data_path = f"{root}/{unity_subdir}/Assets" +project_hash = hashlib.sha1(data_path.encode("utf-8")).hexdigest()[:8] + +sys.exit(subprocess.run([ + "uvx", "--from", SERVER_PACKAGE, "mcp-for-unity", + "--transport", "stdio", + "--default-instance", project_hash, +]).returncode) From 23a186ba6fca8ce1de2041c9522c3bc9511c1251 Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Fri, 7 Aug 2026 01:09:36 +0200 Subject: [PATCH 8/8] test: fix accessibility of BinaryPrefs test enums --- src/Tests/BinaryPrefsTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Tests/BinaryPrefsTests.cs b/src/Tests/BinaryPrefsTests.cs index 2ffe9de..d56d3db 100644 --- a/src/Tests/BinaryPrefsTests.cs +++ b/src/Tests/BinaryPrefsTests.cs @@ -553,20 +553,20 @@ public void TypeOf_ShouldReturnNullForUnknownKey() [TestFixture] public class BinaryPrefsEnumTests : BinaryPrefsTestsBase { - private enum IntBacked + public enum IntBacked { None = 0, Second = 2, Negative = -5 } - private enum ByteBacked : byte + public enum ByteBacked : byte { None = 0, Max = byte.MaxValue } - private enum ULongBacked : ulong + public enum ULongBacked : ulong { None = 0, Max = ulong.MaxValue