Files
Workspace/Assets/Modules/ShrinkDataSaver/Runtime/ShrinkSave.cs
T
cneicy d74c2f08ca 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 迁移与旧代码地图
2026-08-18 18:06:34 +08:00

674 lines
25 KiB
C#

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));
}
}
}