feat(packages): 内置 SDK 包并完善 ContextLoader 集成

- 将 ShrinkEventBus、ShrinkDataSaver 及其 EventBus 集成从 gitlink 转为仓库直接维护的完整 UPM 包,补齐运行时、编辑器工具、测试与文档
- 新增 Command 和 Network 的 App 集成组件,支持 ContextLoader 服务发布、可逆注销及 Network Loopback 生命周期管理
- 更新 Starter 与演示组合逻辑,缺失模块时可注册、已有兼容安装器时可覆盖,并补充宿主启动断言
- 升级内部包依赖与 Shared CodeGen 包定义,放宽 Integration.App 包的 Git 忽略规则
- 将独立服务器生成器改为基于已编译程序集的语义扫描,支持 partial、复杂泛型、命名冲突检测及模板 SHA-256 覆写保护
- 新增 Network 语义扫描、模板保护和 App 组件生命周期测试
- 新增真实 UPM 消费工程验证脚本,校验内部版本一致性、程序集加载及 EditMode 测试
- 重构当前架构文档并归档已完成的 Cordis 迁移与旧代码地图
This commit is contained in:
2026-08-18 18:06:34 +08:00
parent 517c4cf46e
commit d74c2f08ca
240 changed files with 13647 additions and 545 deletions
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ShrinkDataSaver.Tests")]
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1e21adcea9de62441b55c09908f22530
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,28 @@
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ShrinkDataSaver
{
public static class DataSerializer
{
private static readonly JsonSerializerSettings Settings = new()
{
NullValueHandling = NullValueHandling.Ignore,
DefaultValueHandling = DefaultValueHandling.Include,
Formatting = Formatting.None
};
public static byte[] Serialize<T>(T obj)
=> Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(obj, Settings));
public static T Deserialize<T>(byte[] data)
=> JsonConvert.DeserializeObject<T>(Encoding.UTF8.GetString(data), Settings);
public static JObject ToJObject(byte[] data)
=> JObject.Parse(Encoding.UTF8.GetString(data));
public static byte[] FromJObject(JObject obj)
=> Encoding.UTF8.GetBytes(obj.ToString(Formatting.None));
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 19531b42fff04fd9b2fde3cfb450881e
timeCreated: 1773046107
@@ -0,0 +1,14 @@
using System.Threading;
using Cysharp.Threading.Tasks;
namespace ShrinkDataSaver
{
public interface IStorageProvider
{
UniTask WriteAsync(string path, byte[] data, CancellationToken ct = default);
UniTask<byte[]> ReadAsync(string path, CancellationToken ct = default);
UniTask<bool> ExistsAsync(string path, CancellationToken ct = default);
UniTask DeleteAsync(string path, CancellationToken ct = default);
UniTask<string[]> ListAsync(string prefix = "", CancellationToken ct = default);
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0ea76ef6ec0a455aabd1fe2346be4ddf
timeCreated: 1773046056
@@ -0,0 +1,197 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace ShrinkDataSaver
{
public class LocalStorageProvider : IStorageProvider
{
private const string BackupPrimarySuffix = ".bak1";
private const string BackupSecondarySuffix = ".bak2";
private const string TempWriteSuffix = ".tmp";
private static readonly ConcurrentDictionary<string, SemaphoreSlim> PathLocks =
new(StringComparer.OrdinalIgnoreCase);
private readonly string _rootPath;
public LocalStorageProvider(string rootPath = null)
{
_rootPath = rootPath ?? Application.persistentDataPath;
}
private string Resolve(string path) =>
Path.IsPathRooted(path) ? path : Path.Combine(_rootPath, path);
public async UniTask WriteAsync(string path, byte[] data, CancellationToken ct = default)
{
var fullPath = Resolve(path);
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
await UniTask.RunOnThreadPool(() => Directory.CreateDirectory(dir), cancellationToken: ct);
}
var tempPath = fullPath + TempWriteSuffix;
var backupPath = fullPath + BackupPrimarySuffix;
var secondaryBackupPath = fullPath + BackupSecondarySuffix;
var pathLock = GetPathLock(fullPath);
await pathLock.WaitAsync(ct);
try
{
await WriteFileBytesAsync(tempPath, data, ct);
await UniTask.RunOnThreadPool(() =>
{
if (File.Exists(fullPath))
{
if (File.Exists(secondaryBackupPath))
{
File.Delete(secondaryBackupPath);
}
if (File.Exists(backupPath))
{
File.Move(backupPath, secondaryBackupPath);
}
File.Replace(tempPath, fullPath, backupPath, true);
}
else
{
File.Move(tempPath, fullPath);
}
}, cancellationToken: ct);
}
catch
{
await UniTask.RunOnThreadPool(() =>
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
}, cancellationToken: CancellationToken.None);
throw;
}
finally
{
pathLock.Release();
}
}
public async UniTask<byte[]> ReadAsync(string path, CancellationToken ct = default)
{
var fullPath = Resolve(path);
var pathLock = GetPathLock(fullPath);
await pathLock.WaitAsync(ct);
try
{
if (!File.Exists(fullPath))
{
throw new FileNotFoundException($"ShrinkDataSaver: file not found: {fullPath}");
}
return await ReadFileBytesAsync(fullPath, ct);
}
finally
{
pathLock.Release();
}
}
public UniTask<bool> ExistsAsync(string path, CancellationToken ct = default) =>
UniTask.RunOnThreadPool(() => File.Exists(Resolve(path)), cancellationToken: ct);
public async UniTask DeleteAsync(string path, CancellationToken ct = default)
{
var fullPath = Resolve(path);
var pathLock = GetPathLock(fullPath);
await pathLock.WaitAsync(ct);
try
{
await UniTask.RunOnThreadPool(() =>
{
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
}, cancellationToken: ct);
}
finally
{
pathLock.Release();
}
}
public async UniTask<string[]> ListAsync(string prefix = "", CancellationToken ct = default)
{
return await UniTask.RunOnThreadPool(() =>
{
var dir = string.IsNullOrEmpty(prefix) ? _rootPath : Path.Combine(_rootPath, prefix);
if (!Directory.Exists(dir))
{
return Array.Empty<string>();
}
return Directory.GetFiles(dir)
.Select(f => Path.GetRelativePath(_rootPath, f))
.ToArray();
}, cancellationToken: ct);
}
private static SemaphoreSlim GetPathLock(string fullPath)
{
return PathLocks.GetOrAdd(fullPath, _ => new SemaphoreSlim(1, 1));
}
private static async UniTask WriteFileBytesAsync(string fullPath, byte[] data, CancellationToken ct)
{
await using var stream = new FileStream(
fullPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
4096,
FileOptions.Asynchronous);
await stream.WriteAsync(data, 0, data.Length, ct);
stream.Flush(true);
}
private static async UniTask<byte[]> ReadFileBytesAsync(string fullPath, CancellationToken ct)
{
await using var stream = new FileStream(
fullPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
4096,
FileOptions.Asynchronous | FileOptions.SequentialScan);
var length = stream.Length;
if (length > int.MaxValue)
{
throw new IOException($"ShrinkDataSaver: file too large to read into memory: {fullPath}");
}
var buffer = new byte[length];
var offset = 0;
while (offset < buffer.Length)
{
var read = await stream.ReadAsync(buffer, offset, buffer.Length - offset, ct);
if (read <= 0)
{
throw new EndOfStreamException($"ShrinkDataSaver: unexpected EOF while reading {fullPath}");
}
offset += read;
}
return buffer;
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: dde6d5dd1c3f47db9453a3d0122d39d4
timeCreated: 1773046075
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace ShrinkDataSaver
{
public static class MigrationChain
{
private static readonly SortedDictionary<int, Migration> Migrations = new();
private struct Migration
{
public int ToVersion;
public Func<JObject, JObject> Migrate;
}
public static void Register(int fromVersion, int toVersion, Func<JObject, JObject> migrate)
{
if (fromVersion >= toVersion)
throw new ArgumentException(
$"[ShrinkDataSaver] fromVersion({fromVersion}) 必须小于 toVersion({toVersion})");
Migrations[fromVersion] = new Migration { ToVersion = toVersion, Migrate = migrate };
}
internal static (JObject data, int finalVersion) Apply(JObject data, int currentVersion, int targetVersion)
{
var version = currentVersion;
var backup = (JObject)data.DeepClone();
while (version < targetVersion)
{
if (!Migrations.TryGetValue(version, out var migration))
{
Debug.LogWarning($"[ShrinkDataSaver] 未找到 v{version} → v{version + 1} 的迁移逻辑,数据可能不完整。");
break;
}
try
{
var fromV = version;
data = migration.Migrate(data) ?? data;
version = migration.ToVersion;
Debug.Log($"[ShrinkDataSaver] 存档已迁移 v{fromV} → v{version}");
}
catch (Exception e)
{
Debug.LogError(
$"[ShrinkDataSaver] 迁移 v{version} → v{migration.ToVersion} 失败: {e.Message},已回滚至 v{currentVersion}");
return (backup, currentVersion);
}
}
return (data, version);
}
internal static void Clear() => Migrations.Clear();
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 56cc513596cc4b30b82412e080e8b716
timeCreated: 1773047408
@@ -0,0 +1,86 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace ShrinkDataSaver
{
public static class SaveEncryptor
{
private const int KeySize = 256;
private const int BlockSize = 128;
private const int IvBytes = 16;
private const int SaltBytes = 16;
private const int Iterations = 10000;
public static byte[] Encrypt(byte[] data, string password)
{
if (data == null) throw new ArgumentNullException(nameof(data));
if (string.IsNullOrEmpty(password)) throw new ArgumentException("Password must not be empty.");
var salt = GenerateRandom(SaltBytes);
var iv = GenerateRandom(IvBytes);
var key = DeriveKey(password, salt);
using var aes = CreateAes(key, iv);
using var encryptor = aes.CreateEncryptor();
var cipher = encryptor.TransformFinalBlock(data, 0, data.Length);
var result = new byte[SaltBytes + IvBytes + cipher.Length];
Buffer.BlockCopy(salt, 0, result, 0, SaltBytes);
Buffer.BlockCopy(iv, 0, result, SaltBytes, IvBytes);
Buffer.BlockCopy(cipher, 0, result, SaltBytes + IvBytes, cipher.Length);
return result;
}
public static byte[] Decrypt(byte[] data, string password)
{
if (data == null) throw new ArgumentNullException(nameof(data));
if (string.IsNullOrEmpty(password)) throw new ArgumentException("Password must not be empty.");
if (data.Length < SaltBytes + IvBytes)
throw new ArgumentException("Data is too short to be valid encrypted content.");
var salt = new byte[SaltBytes];
var iv = new byte[IvBytes];
var cipher = new byte[data.Length - SaltBytes - IvBytes];
Buffer.BlockCopy(data, 0, salt, 0, SaltBytes);
Buffer.BlockCopy(data, SaltBytes, iv, 0, IvBytes);
Buffer.BlockCopy(data, SaltBytes + IvBytes, cipher, 0, cipher.Length);
var key = DeriveKey(password, salt);
using var aes = CreateAes(key, iv);
using var decryptor = aes.CreateDecryptor();
return decryptor.TransformFinalBlock(cipher, 0, cipher.Length);
}
private static byte[] DeriveKey(string password, byte[] salt)
{
using var deriveBytes = new Rfc2898DeriveBytes(
Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256);
return deriveBytes.GetBytes(KeySize / 8);
}
private static Aes CreateAes(byte[] key, byte[] iv)
{
var aes = Aes.Create();
aes.KeySize = KeySize;
aes.BlockSize = BlockSize;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.Key = key;
aes.IV = iv;
return aes;
}
private static byte[] GenerateRandom(int length)
{
var bytes = new byte[length];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return bytes;
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 751846b93860451e8864bf71f593d4ff
timeCreated: 1773046087
@@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ShrinkDataSaver
{
[Serializable]
public class SaveMeta
{
public int SlotIndex { get; set; }
public string SlotName { get; set; } = "New Save";
public int SaveVersion { get; set; } = 1;
public long LastModified { get; set; }
public float PlaytimeSeconds { get; set; }
public string ScreenshotBase64 { get; set; }
public bool IsEncrypted { get; set; }
[JsonIgnore]
public DateTime LastModifiedTime => DateTimeOffset.FromUnixTimeSeconds(LastModified).LocalDateTime;
}
[Serializable]
internal class SavePacket
{
public SaveMeta Meta { get; set; }
public Dictionary<string, JToken> Modules { get; set; } = new();
public string EncryptedModules { get; set; }
}
public interface ISaveModule
{
string Key { get; }
object SerializeRaw();
void DeserializeRaw(JToken data);
}
public interface ISaveModule<T> : ISaveModule
{
T Serialize();
void Deserialize(T data);
object ISaveModule.SerializeRaw() => Serialize();
void ISaveModule.DeserializeRaw(JToken data) => Deserialize(data.ToObject<T>());
}
internal class LambdaSaveModule<T> : ISaveModule<T>
{
public string Key { get; }
private readonly Func<T> _serialize;
private readonly Action<T> _deserialize;
public LambdaSaveModule(string key, Func<T> serialize, Action<T> deserialize)
{
Key = key; _serialize = serialize; _deserialize = deserialize;
}
public T Serialize() => _serialize();
public void Deserialize(T data) => _deserialize(data);
}
public class SaveOptions
{
public string SlotName = "New Save";
public bool CaptureScreenshot = false;
public UnityEngine.Texture2D Screenshot = null;
public bool Encrypt = false;
public string EncryptionKey = null;
}
public class ModuleConfig
{
/// <summary>是否参与云存档同步(设置模块应设为 false</summary>
public bool EnableCloudSync { get; set; } = true;
/// <summary>自动保存间隔(秒),0 表示不自动保存</summary>
public float AutoSaveIntervalSeconds { get; set; } = 0f;
/// <summary>是否为关键模块(关键模块序列化失败将中止整个保存操作)</summary>
public bool CriticalModule { get; set; } = false;
}
public class SaveStartedEventArgs
{
public int SlotIndex { get; set; }
public long Timestamp { get; set; }
}
public class SaveCompletedEventArgs
{
public int SlotIndex { get; set; }
public string[] ModuleNames { get; set; }
public long Timestamp { get; set; }
}
public class SaveFailedEventArgs
{
public int SlotIndex { get; set; }
public string ErrorMessage { get; set; }
}
public class LoadStartedEventArgs
{
public int SlotIndex { get; set; }
public long Timestamp { get; set; }
}
public class LoadCompletedEventArgs
{
public int SlotIndex { get; set; }
public string[] ModuleNames { get; set; }
public int Version { get; set; }
public long Timestamp { get; set; }
}
public class LoadFailedEventArgs
{
public int SlotIndex { get; set; }
public string ErrorMessage { get; set; }
}
public class MigrationCompletedEventArgs
{
public int SlotIndex { get; set; }
public int FromVersion { get; set; }
public int ToVersion { get; set; }
}
public class SlotDeletedEventArgs
{
public int SlotIndex { get; set; }
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 7cb5188d2a984a1b8f6a701789b8d0f1
timeCreated: 1773047394
@@ -0,0 +1,17 @@
{
"name": "ShrinkDataSaver.Runtime",
"rootNamespace": "ShrinkDataSaver",
"references": [
"UniTask",
"Newtonsoft.Json"
],
"optionalUnityReferences": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5b60ff3063fc45e39b61d1df0c2a6339
timeCreated: 1773047537
@@ -0,0 +1,44 @@
using UnityEngine;
namespace ShrinkDataSaver
{
/// <summary>
/// 兼容旧用法:仍可把该组件挂到首场景中,
/// 但真正初始化已下沉到 ShrinkDataSaverRuntime,便于宿主层统一接管。
/// </summary>
[DefaultExecutionOrder(-2000)]
public class ShrinkDataSaverBootstrap : MonoBehaviour
{
[Header("Override (留空使用 ScriptableObject 设置)")] [SerializeField]
private ShrinkDataSaverSettings settingsOverride;
[Header("Save Version")] [SerializeField]
private int currentSaveVersion = 1;
private static bool _initialized;
private void Awake()
{
if (ShrinkDataSaverRuntime.IsInitialized)
{
Destroy(gameObject);
return;
}
if (_initialized)
{
Destroy(gameObject);
return;
}
_initialized = true;
DontDestroyOnLoad(gameObject);
ShrinkDataSaverRuntime.Initialize(new ShrinkDataSaverRuntimeConfig
{
SettingsOverride = settingsOverride,
CurrentSaveVersion = currentSaveVersion,
DontDestroyOnLoadDriver = true
});
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 30e24d3823c349c99bd78fa0992be0e7
timeCreated: 1773047454
@@ -0,0 +1,98 @@
#nullable enable
using System.IO;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace ShrinkDataSaver
{
public sealed class ShrinkDataSaverRuntimeConfig
{
public ShrinkDataSaverSettings? SettingsOverride { get; set; }
public int CurrentSaveVersion { get; set; } = 1;
public bool DontDestroyOnLoadDriver { get; set; } = true;
}
public static class ShrinkDataSaverRuntime
{
public static bool IsInitialized { get; private set; }
public static void Initialize(ShrinkDataSaverRuntimeConfig? config = null)
{
if (IsInitialized)
return;
config ??= new ShrinkDataSaverRuntimeConfig();
var cfg = config.SettingsOverride ?? ShrinkDataSaverSettings.Instance;
if (config.SettingsOverride)
ShrinkDataSaverSettings.Instance = config.SettingsOverride;
if (cfg == null)
throw new System.InvalidOperationException("ShrinkDataSaverSettings instance is not available.");
var rootPath = string.IsNullOrEmpty(cfg.customSavePath)
? Application.persistentDataPath
: cfg.customSavePath;
var savesDir = Path.Combine(rootPath, "saves");
var settingsPath = Path.Combine(rootPath, cfg.settingsFileName);
var storage = new LocalStorageProvider(rootPath);
ShrinkSettings.Initialize(storage, settingsPath);
ShrinkSave.Initialize(storage, savesDir, cfg.saveFileExtension, config.CurrentSaveVersion);
ShrinkSettings.LoadAsync().Forget();
ShrinkDataSaverLifecycleDriver.EnsureCreated(config.DontDestroyOnLoadDriver);
IsInitialized = true;
Debug.Log($"[ShrinkDataSaver] Runtime initialized. Root: {rootPath}");
}
internal static void ResetForTesting()
{
IsInitialized = false;
}
}
[DefaultExecutionOrder(-1999)]
internal sealed class ShrinkDataSaverLifecycleDriver : MonoBehaviour
{
private static ShrinkDataSaverLifecycleDriver? _instance;
private float _autoSaveTimer;
public static void EnsureCreated(bool dontDestroyOnLoad)
{
if (_instance != null)
return;
var go = new GameObject("ShrinkDataSaverRuntimeDriver");
_instance = go.AddComponent<ShrinkDataSaverLifecycleDriver>();
if (dontDestroyOnLoad)
DontDestroyOnLoad(go);
}
private void Update()
{
var interval = ShrinkSave.GetMinAutoSaveInterval();
if (interval <= 0f || ShrinkSave.LoadedSlot < 0)
return;
_autoSaveTimer += Time.unscaledDeltaTime;
if (_autoSaveTimer < interval)
return;
_autoSaveTimer = 0f;
ShrinkSave.SaveSlotAsync(ShrinkSave.LoadedSlot).Forget();
}
private async void OnApplicationQuit()
{
await ShrinkSettings.SaveAsync();
}
private async void OnApplicationPause(bool paused)
{
if (paused)
await ShrinkSettings.SaveAsync();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d9b14a9eecf6eb746b24241eac07c939
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,61 @@
using UnityEngine;
namespace ShrinkDataSaver
{
[CreateAssetMenu(fileName = "ShrinkDataSaverSettings", menuName = "ShrinkDataSaver/Settings")]
public class ShrinkDataSaverSettings : ScriptableObject
{
private static ShrinkDataSaverSettings _instance;
public static ShrinkDataSaverSettings Instance
{
get
{
if (_instance) return _instance;
_instance = Resources.Load<ShrinkDataSaverSettings>("ShrinkDataSaverSettings");
#if UNITY_EDITOR
if (!_instance)
{
var guids = UnityEditor.AssetDatabase.FindAssets("t:ShrinkDataSaverSettings");
if (guids.Length > 0)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[0]);
_instance = UnityEditor.AssetDatabase.LoadAssetAtPath<ShrinkDataSaverSettings>(path);
}
}
#endif
if (!_instance)
{
_instance = CreateInstance<ShrinkDataSaverSettings>();
Debug.LogWarning(
"[ShrinkDataSaver] 未找到 ShrinkDataSaverSettings 配置文件!当前使用默认配置。\n" +
"请通过菜单 Assets → Create → ShrinkDataSaver → Settings 创建配置文件,\n" +
"可放置在项目任意目录下(编辑器会自动搜索),或在 ShrinkDataSaverBootstrap 组件上手动指定。");
}
return _instance;
}
internal set => _instance = value;
}
[Header("Storage")]
public string customSavePath = "";
[Header("Settings File")]
public string settingsFileName = "settings.json";
public bool syncSettingsToCloud; //todo
[Header("Save Slots")]
public int maxSlots;
public string saveFileExtension = ".sav";
[Header("Screenshot")]
public int screenshotMaxWidth = 256;
[Header("Settings Debounce")]
public float settingsWriteDebounceSeconds = 0.3f;
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5df22787ec2544b9abbb40cf28536c7e
timeCreated: 1773047342
@@ -0,0 +1,673 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using Cysharp.Threading.Tasks;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace ShrinkDataSaver
{
public static class ShrinkSave
{
private const string BackupPrimarySuffix = ".bak1";
private const string BackupSecondarySuffix = ".bak2";
private const string TempWriteSuffix = ".tmp";
private const string RecentSlotIndexSettingKey = "ShrinkDataSaver.RecentSlotIndex";
private static IStorageProvider _storage;
private static string _savesDir;
private static string _fileExt;
private static int _currentSaveVersion = 1;
private static readonly Dictionary<string, ISaveModule> _modules = new();
private static readonly Dictionary<string, ModuleConfig> _moduleConfigs = new();
private static int _loadedSlot = -1;
private static float _sessionStart;
private static float _storedPlaytime;
private static Dictionary<string, JToken> _loadedModuleData;
public static event Action<SaveStartedEventArgs> OnSaveStarted;
public static event Action<SaveCompletedEventArgs> OnSaveCompleted;
public static event Action<SaveFailedEventArgs> OnSaveFailed;
public static event Action<LoadStartedEventArgs> OnLoadStarted;
public static event Action<LoadCompletedEventArgs> OnLoadCompleted;
public static event Action<LoadFailedEventArgs> OnLoadFailed;
public static event Action<MigrationCompletedEventArgs> OnMigrationCompleted;
public static event Action<SlotDeletedEventArgs> OnDeleteCompleted;
public static int LoadedSlot => _loadedSlot;
public static int GetRecentSlotIndex() => ShrinkSettings.Get(RecentSlotIndexSettingKey, -1);
internal static void Initialize(IStorageProvider storage, string savesDir, string fileExt, int saveVersion)
{
_storage = storage;
_savesDir = savesDir;
_fileExt = fileExt;
_currentSaveVersion = saveVersion;
}
public static void SetCurrentSaveVersion(int version) => _currentSaveVersion = version;
public static void RegisterModule(ISaveModule module, ModuleConfig config = null)
{
_modules[module.Key] = module;
_moduleConfigs[module.Key] = config ?? new ModuleConfig();
}
public static void RegisterModule<T>(string key, Func<T> serialize, Action<T> deserialize, ModuleConfig config = null)
{
_modules[key] = new LambdaSaveModule<T>(key, serialize, deserialize);
_moduleConfigs[key] = config ?? new ModuleConfig();
}
public static void UnregisterModule(string key)
{
_modules.Remove(key);
_moduleConfigs.Remove(key);
}
public static ModuleConfig GetModuleConfig(string key)
=> _moduleConfigs.TryGetValue(key, out var cfg) ? cfg : null;
public static IReadOnlyCollection<string> GetRegisteredModuleNames() => _modules.Keys;
public static T QueryModule<T>(string moduleName)
{
if (!_modules.TryGetValue(moduleName, out var module))
{
return default;
}
try
{
var raw = module.SerializeRaw();
if (raw is T typed)
{
return typed;
}
return JToken.FromObject(raw).ToObject<T>();
}
catch
{
return default;
}
}
public static bool HasKey(string moduleName, string key)
{
if (_loadedModuleData == null)
{
return false;
}
if (!_loadedModuleData.TryGetValue(moduleName, out var token))
{
return false;
}
return token is JObject obj && obj.ContainsKey(key);
}
public static bool HasModule(string moduleName) => _modules.ContainsKey(moduleName);
public static async UniTask<SaveMeta[]> GetAllMetaAsync(CancellationToken ct = default)
{
var results = new List<SaveMeta>();
var files = await _storage.ListAsync(_savesDir, ct);
var discoveredSlots = new HashSet<int>();
foreach (var file in files)
{
if (TryExtractSlotIndex(file, out var slotIndex))
{
discoveredSlots.Add(slotIndex);
}
}
foreach (var slotIndex in discoveredSlots)
{
try
{
var readResult = await ReadPacketWithFallbackAsync(slotIndex, ct);
if (readResult.packet?.Meta != null)
{
results.Add(readResult.packet.Meta);
}
}
catch (Exception e)
{
Debug.LogWarning($"[ShrinkDataSaver] 读取元数据失败 slot_{slotIndex}: {e.Message}");
}
}
results.Sort((a, b) => a.SlotIndex.CompareTo(b.SlotIndex));
return results.ToArray();
}
public async static UniTask<bool> SlotExistsAsync(int slotIndex, CancellationToken ct = default)
=> await _storage.ExistsAsync(SlotPath(slotIndex), ct);
public static async UniTask<SaveMeta> GetMetaAsync(int slotIndex, CancellationToken ct = default)
{
try
{
var readResult = await ReadPacketWithFallbackAsync(slotIndex, ct);
return readResult.packet?.Meta;
}
catch (FileNotFoundException)
{
return null;
}
}
public static async UniTask<int> GetRecommendedContinueSlotAsync(CancellationToken ct = default)
{
var metas = await GetAllMetaAsync(ct);
var resolvedSlot = ResolveRecommendedContinueSlotIndex(GetRecentSlotIndex(), metas);
if (resolvedSlot != GetRecentSlotIndex())
{
await PersistRecentSlotIndexAsync(resolvedSlot, ct);
}
return resolvedSlot;
}
public static async UniTask SaveSlotAsync(int slotIndex, SaveOptions options = null, CancellationToken ct = default)
{
options ??= new SaveOptions();
ValidateSlotIndex(slotIndex);
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
OnSaveStarted?.Invoke(new SaveStartedEventArgs { SlotIndex = slotIndex, Timestamp = timestamp });
try
{
var packet = new SavePacket
{
Meta = new SaveMeta
{
SlotIndex = slotIndex,
SlotName = options.SlotName,
SaveVersion = _currentSaveVersion,
LastModified = timestamp,
PlaytimeSeconds = GetCurrentPlaytime(),
IsEncrypted = options.Encrypt
}
};
if (options.Screenshot || options.CaptureScreenshot)
{
packet.Meta.ScreenshotBase64 = await CaptureScreenshotAsync(options, ct);
}
var modulesDict = new Dictionary<string, JToken>();
var moduleNames = new List<string>();
foreach (var module in _modules.Values)
{
try
{
modulesDict[module.Key] = JToken.FromObject(module.SerializeRaw());
moduleNames.Add(module.Key);
}
catch (Exception e)
{
var isCritical = _moduleConfigs.TryGetValue(module.Key, out var cfg) && cfg.CriticalModule;
if (isCritical)
{
throw new InvalidOperationException($"关键模块 '{module.Key}' 序列化失败: {e.Message}", e);
}
Debug.LogError($"[ShrinkDataSaver] 模块 '{module.Key}' 序列化失败(已跳过): {e.Message}");
}
}
if (options.Encrypt)
{
if (string.IsNullOrEmpty(options.EncryptionKey))
{
throw new ArgumentException("EncryptionKey missing.");
}
var modulesBytes = DataSerializer.Serialize(modulesDict);
var encryptedBytes = SaveEncryptor.Encrypt(modulesBytes, options.EncryptionKey);
packet.EncryptedModules = Convert.ToBase64String(encryptedBytes);
}
else
{
packet.Modules = modulesDict;
}
var finalBytes = DataSerializer.Serialize(packet);
await _storage.WriteAsync(SlotPath(slotIndex), finalBytes, ct);
OnSaveCompleted?.Invoke(new SaveCompletedEventArgs
{
SlotIndex = slotIndex,
ModuleNames = moduleNames.ToArray(),
Timestamp = timestamp
});
Debug.Log($"[ShrinkDataSaver] 槽位 {slotIndex} 已保存。({moduleNames.Count} 个模块)");
}
catch (Exception e)
{
OnSaveFailed?.Invoke(new SaveFailedEventArgs
{
SlotIndex = slotIndex,
ErrorMessage = e.Message
});
throw;
}
}
public static async UniTask LoadSlotAsync(int slotIndex, string decryptionKey = null, CancellationToken ct = default)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
OnLoadStarted?.Invoke(new LoadStartedEventArgs { SlotIndex = slotIndex, Timestamp = timestamp });
try
{
var loadResult = await ReadLoadResultWithFallbackAsync(slotIndex, decryptionKey, ct);
var packet = loadResult.packet;
var loadedModules = loadResult.loadedModules;
_loadedModuleData = new Dictionary<string, JToken>(loadedModules);
var moduleNames = new List<string>();
foreach (var module in _modules.Values)
{
if (!loadedModules.TryGetValue(module.Key, out var token))
{
continue;
}
try
{
module.DeserializeRaw(token);
moduleNames.Add(module.Key);
}
catch (Exception e)
{
Debug.LogError($"[ShrinkDataSaver] 反序列化模块 '{module.Key}' 失败: {e.Message}");
}
}
_loadedSlot = slotIndex;
_storedPlaytime = packet.Meta.PlaytimeSeconds;
_sessionStart = Time.realtimeSinceStartup;
await PersistRecentSlotIndexAsync(slotIndex, ct);
OnLoadCompleted?.Invoke(new LoadCompletedEventArgs
{
SlotIndex = slotIndex,
ModuleNames = moduleNames.ToArray(),
Version = packet.Meta.SaveVersion,
Timestamp = timestamp
});
Debug.Log($"[ShrinkDataSaver] 槽位 {slotIndex} 已加载。(v{packet.Meta.SaveVersion}, {moduleNames.Count} 个模块)");
}
catch (Exception e)
{
OnLoadFailed?.Invoke(new LoadFailedEventArgs
{
SlotIndex = slotIndex,
ErrorMessage = e.Message
});
throw;
}
}
public static async UniTask DeleteSlotAsync(int slotIndex, CancellationToken ct = default)
{
foreach (var candidatePath in EnumerateAllSlotPaths(slotIndex))
{
await _storage.DeleteAsync(candidatePath, ct);
}
if (_loadedSlot == slotIndex)
{
_loadedSlot = -1;
_storedPlaytime = 0f;
_loadedModuleData = null;
}
if (GetRecentSlotIndex() == slotIndex)
{
var metas = await GetAllMetaAsync(ct);
var fallbackSlot = ResolveRecommendedContinueSlotIndex(-1, metas);
await PersistRecentSlotIndexAsync(fallbackSlot, ct);
}
OnDeleteCompleted?.Invoke(new SlotDeletedEventArgs { SlotIndex = slotIndex });
Debug.Log($"[ShrinkDataSaver] 槽位 {slotIndex} 已删除。");
}
public static float GetMinAutoSaveInterval()
{
var min = float.MaxValue;
var hasAny = false;
foreach (var cfg in _moduleConfigs.Values)
{
if (cfg.AutoSaveIntervalSeconds > 0f)
{
min = Mathf.Min(min, cfg.AutoSaveIntervalSeconds);
hasAny = true;
}
}
return hasAny ? min : 0f;
}
internal static void ResetForTesting()
{
_modules.Clear();
_moduleConfigs.Clear();
_loadedSlot = -1;
_sessionStart = 0f;
_storedPlaytime = 0f;
_loadedModuleData = null;
OnSaveStarted = null;
OnSaveCompleted = null;
OnSaveFailed = null;
OnLoadStarted = null;
OnLoadCompleted = null;
OnLoadFailed = null;
OnMigrationCompleted = null;
OnDeleteCompleted = null;
}
private static string SlotPath(int slotIndex) => Path.Combine(_savesDir, $"slot_{slotIndex}{_fileExt}");
private static IEnumerable<string> EnumerateAllSlotPaths(int slotIndex)
{
var primaryPath = SlotPath(slotIndex);
yield return primaryPath;
yield return primaryPath + BackupPrimarySuffix;
yield return primaryPath + BackupSecondarySuffix;
yield return primaryPath + TempWriteSuffix;
}
private static IEnumerable<string> EnumerateReadCandidatePaths(string primaryPath)
{
yield return primaryPath;
yield return primaryPath + BackupPrimarySuffix;
yield return primaryPath + BackupSecondarySuffix;
}
private static void ValidateSlotIndex(int slotIndex)
{
if (slotIndex < 0)
{
throw new ArgumentOutOfRangeException(nameof(slotIndex), "槽位索引不能为负数。");
}
var max = ShrinkDataSaverSettings.Instance.maxSlots;
if (max > 0 && slotIndex >= max)
{
throw new ArgumentOutOfRangeException(nameof(slotIndex), $"超出最大槽位数 ({max})。");
}
}
private static bool TryExtractSlotIndex(string path, out int slotIndex)
{
slotIndex = -1;
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName) || fileName.EndsWith(TempWriteSuffix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var normalizedName = fileName;
if (normalizedName.EndsWith(BackupSecondarySuffix, StringComparison.OrdinalIgnoreCase))
{
normalizedName = normalizedName[..^BackupSecondarySuffix.Length];
}
else if (normalizedName.EndsWith(BackupPrimarySuffix, StringComparison.OrdinalIgnoreCase))
{
normalizedName = normalizedName[..^BackupPrimarySuffix.Length];
}
if (!normalizedName.StartsWith("slot_", StringComparison.OrdinalIgnoreCase) ||
!normalizedName.EndsWith(_fileExt, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var indexText = normalizedName.Substring(5, normalizedName.Length - 5 - _fileExt.Length);
return int.TryParse(indexText, out slotIndex);
}
private static async UniTask PersistRecentSlotIndexAsync(int slotIndex, CancellationToken ct)
{
if (GetRecentSlotIndex() == slotIndex)
{
return;
}
ShrinkSettings.Set(RecentSlotIndexSettingKey, slotIndex);
await ShrinkSettings.SaveAsync(ct);
}
private static int ResolveRecommendedContinueSlotIndex(int rememberedSlotIndex, IReadOnlyList<SaveMeta> metas)
{
if (metas == null || metas.Count == 0)
{
return -1;
}
if (rememberedSlotIndex >= 0 && ContainsSlot(metas, rememberedSlotIndex))
{
return rememberedSlotIndex;
}
return PickMostRecentSlot(metas);
}
private static bool ContainsSlot(IReadOnlyList<SaveMeta> metas, int slotIndex)
{
for (var i = 0; i < metas.Count; i++)
{
if (metas[i] != null && metas[i].SlotIndex == slotIndex)
{
return true;
}
}
return false;
}
private static int PickMostRecentSlot(IReadOnlyList<SaveMeta> metas)
{
SaveMeta best = null;
for (var i = 0; i < metas.Count; i++)
{
var meta = metas[i];
if (meta == null)
{
continue;
}
if (best == null ||
meta.LastModified > best.LastModified ||
(meta.LastModified == best.LastModified && meta.SlotIndex < best.SlotIndex))
{
best = meta;
}
}
return best?.SlotIndex ?? -1;
}
private static async UniTask<(SavePacket packet, byte[] rawBytes, string sourcePath)> ReadPacketWithFallbackAsync(
int slotIndex,
CancellationToken ct)
{
var primaryPath = SlotPath(slotIndex);
Exception lastError = null;
foreach (var candidatePath in EnumerateReadCandidatePaths(primaryPath))
{
if (!await _storage.ExistsAsync(candidatePath, ct))
{
continue;
}
try
{
var bytes = await _storage.ReadAsync(candidatePath, ct);
var packet = DataSerializer.Deserialize<SavePacket>(bytes);
if (packet?.Meta == null)
{
throw new InvalidDataException($"槽位 {slotIndex} 的存档包缺少 Meta。");
}
if (!string.Equals(candidatePath, primaryPath, StringComparison.Ordinal))
{
Debug.LogWarning($"[ShrinkDataSaver] 槽位 {slotIndex} 主文件损坏或缺失,已从备份恢复:{candidatePath}");
await _storage.WriteAsync(primaryPath, bytes, ct);
}
return (packet, bytes, candidatePath);
}
catch (Exception ex)
{
lastError = ex;
Debug.LogWarning($"[ShrinkDataSaver] 读取槽位副本失败:{candidatePath} / {ex.Message}");
}
}
if (lastError != null)
{
throw new InvalidDataException($"槽位 {slotIndex} 的主文件及备份均不可读取。", lastError);
}
throw new FileNotFoundException($"槽位 {slotIndex} 不存在。");
}
private static async UniTask<(SavePacket packet, Dictionary<string, JToken> loadedModules)> ReadLoadResultWithFallbackAsync(
int slotIndex,
string decryptionKey,
CancellationToken ct)
{
var primaryPath = SlotPath(slotIndex);
Exception lastError = null;
foreach (var candidatePath in EnumerateReadCandidatePaths(primaryPath))
{
if (!await _storage.ExistsAsync(candidatePath, ct))
{
continue;
}
try
{
var bytes = await _storage.ReadAsync(candidatePath, ct);
var packet = DataSerializer.Deserialize<SavePacket>(bytes);
if (packet?.Meta == null)
{
throw new InvalidDataException($"反序列化槽位 {slotIndex} 失败。");
}
Dictionary<string, JToken> loadedModules;
if (packet.Meta.IsEncrypted)
{
if (string.IsNullOrEmpty(decryptionKey))
{
throw new ArgumentException("需要解密密钥。");
}
var encryptedBytes = Convert.FromBase64String(packet.EncryptedModules);
var decryptedBytes = SaveEncryptor.Decrypt(encryptedBytes, decryptionKey);
loadedModules = DataSerializer.Deserialize<Dictionary<string, JToken>>(decryptedBytes);
}
else
{
loadedModules = packet.Modules ?? new Dictionary<string, JToken>();
}
if (packet.Meta.SaveVersion < _currentSaveVersion)
{
var fromVersion = packet.Meta.SaveVersion;
var modulesObj = JObject.FromObject(loadedModules);
(modulesObj, packet.Meta.SaveVersion) = MigrationChain.Apply(modulesObj, fromVersion, _currentSaveVersion);
loadedModules = new Dictionary<string, JToken>();
foreach (var key in modulesObj.Properties())
{
loadedModules[key.Name] = modulesObj[key.Name];
}
OnMigrationCompleted?.Invoke(new MigrationCompletedEventArgs
{
SlotIndex = slotIndex,
FromVersion = fromVersion,
ToVersion = packet.Meta.SaveVersion
});
}
if (!string.Equals(candidatePath, primaryPath, StringComparison.Ordinal))
{
Debug.LogWarning($"[ShrinkDataSaver] 槽位 {slotIndex} 主文件损坏或缺失,已从备份恢复:{candidatePath}");
await _storage.WriteAsync(primaryPath, bytes, ct);
}
return (packet, loadedModules);
}
catch (Exception ex)
{
lastError = ex;
Debug.LogWarning($"[ShrinkDataSaver] 加载槽位副本失败:{candidatePath} / {ex.Message}");
}
}
if (lastError != null)
{
throw new InvalidDataException($"槽位 {slotIndex} 的主文件及备份均不可加载。", lastError);
}
throw new FileNotFoundException($"槽位 {slotIndex} 不存在。");
}
private static float GetCurrentPlaytime() => _loadedSlot < 0 ? 0f : _storedPlaytime + (Time.realtimeSinceStartup - _sessionStart);
private static async UniTask<string> CaptureScreenshotAsync(SaveOptions options, CancellationToken ct)
{
var tex = options.Screenshot;
if (!tex && options.CaptureScreenshot)
{
await UniTask.Yield(PlayerLoopTiming.PostLateUpdate, ct);
tex = ScreenCapture.CaptureScreenshotAsTexture();
}
if (!tex)
{
return null;
}
var settings = ShrinkDataSaverSettings.Instance;
var maxW = settings.screenshotMaxWidth;
if (tex.width > maxW)
{
var scale = (float)maxW / tex.width;
var resized = new Texture2D(maxW, Mathf.RoundToInt(tex.height * scale));
for (var y = 0; y < resized.height; y++)
{
for (var x = 0; x < resized.width; x++)
{
resized.SetPixel(x, y, tex.GetPixelBilinear((float)x / resized.width, (float)y / resized.height));
}
}
resized.Apply();
tex = resized;
}
return Convert.ToBase64String(tex.EncodeToJPG(75));
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d41b28112039451ca4fca14c901a91a9
timeCreated: 1773047436
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace ShrinkDataSaver
{
[Serializable]
internal class SettingsData
{
public Dictionary<string, JToken> Values = new();
}
public static class ShrinkSettings
{
private const string BackupPrimarySuffix = ".bak1";
private const string BackupSecondarySuffix = ".bak2";
private static SettingsData _data = new();
private static IStorageProvider _storage;
private static string _filePath;
private static CancellationTokenSource _debounceCts;
private static readonly Dictionary<string, List<Action<object>>> _watchers = new();
public static event Action<string, object> OnChanged;
internal static void Initialize(IStorageProvider storage, string filePath)
{
_storage = storage; _filePath = filePath;
}
public static void Set<T>(string key, T value)
{
_data.Values[key] = JToken.FromObject(value);
FireChanged(key, value);
ScheduleWrite().Forget();
}
public static T Get<T>(string key, T defaultValue = default)
{
if (_data.Values.TryGetValue(key, out var token))
{
try { return token.ToObject<T>(); } catch { /* ignore */ }
}
return defaultValue;
}
public static bool Has(string key) => _data.Values.ContainsKey(key);
public static IReadOnlyDictionary<string, JToken> GetAllRaw() => _data.Values;
public static void Remove(string key)
{
if (_data.Values.Remove(key))
{
FireChanged(key, null);
ScheduleWrite().Forget();
}
}
public static void Watch<T>(string key, Action<T> callback)
{
if (!_watchers.ContainsKey(key)) _watchers[key] = new List<Action<object>>();
_watchers[key].Add(raw => callback((T)Convert.ChangeType(raw, typeof(T))));
}
public static void Unwatch(string key, Action<object> callback)
{
if (_watchers.TryGetValue(key, out var list)) list.Remove(callback);
}
public static async UniTask SaveAsync(CancellationToken ct = default)
{
_debounceCts?.Cancel();
if (_storage == null || string.IsNullOrWhiteSpace(_filePath))
{
// 未初始化(独立宿主/单测环境)时跳过持久化,与 LoadAsync 的守卫保持一致
return;
}
var bytes = DataSerializer.Serialize(_data);
await _storage.WriteAsync(_filePath, bytes, ct);
}
public static async UniTask LoadAsync(CancellationToken ct = default)
{
if (_storage == null || string.IsNullOrWhiteSpace(_filePath))
{
_data = new SettingsData();
return;
}
var loadedData = await TryLoadWithFallbackAsync(ct);
_data = loadedData ?? new SettingsData();
}
internal static void ResetForTesting()
{
_data = new SettingsData();
_debounceCts?.Cancel();
_debounceCts = null;
_watchers.Clear();
OnChanged = null;
}
private static void FireChanged(string key, object value)
{
OnChanged?.Invoke(key, value);
if (_watchers.TryGetValue(key, out var list))
foreach (var cb in list)
try { cb(value); } catch (Exception e) { Debug.LogException(e); }
}
private static async UniTaskVoid ScheduleWrite()
{
_debounceCts?.Cancel();
_debounceCts = new CancellationTokenSource();
var token = _debounceCts.Token;
try
{
var delay = (int)(ShrinkDataSaverSettings.Instance.settingsWriteDebounceSeconds * 1000);
await UniTask.Delay(delay, cancellationToken: token);
await SaveAsync(token);
}
catch (OperationCanceledException) { }
catch (Exception e) { Debug.LogException(e); }
}
private static async UniTask<SettingsData> TryLoadWithFallbackAsync(CancellationToken ct)
{
var primaryPath = _filePath;
Exception lastError = null;
foreach (var candidatePath in EnumerateCandidatePaths(primaryPath))
{
if (!await _storage.ExistsAsync(candidatePath, ct))
{
continue;
}
try
{
var bytes = await _storage.ReadAsync(candidatePath, ct);
var loaded = DataSerializer.Deserialize<SettingsData>(bytes) ?? new SettingsData();
if (!string.Equals(candidatePath, primaryPath, StringComparison.Ordinal))
{
Debug.LogWarning($"[ShrinkDataSaver] Settings 主文件损坏或缺失,已从备份恢复:{candidatePath}");
await _storage.WriteAsync(primaryPath, bytes, ct);
}
return loaded;
}
catch (Exception ex)
{
lastError = ex;
Debug.LogWarning($"[ShrinkDataSaver] 读取 Settings 副本失败:{candidatePath} / {ex.Message}");
}
}
if (lastError != null)
{
Debug.LogWarning("[ShrinkDataSaver] 所有 Settings 副本均不可用,已回退为空设置。");
}
return new SettingsData();
}
private static IEnumerable<string> EnumerateCandidatePaths(string primaryPath)
{
yield return primaryPath;
yield return primaryPath + BackupPrimarySuffix;
yield return primaryPath + BackupSecondarySuffix;
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ffdd053c674640a9987f34ddd93080d8
timeCreated: 1773047370