diff --git a/Patches/Saves/ModdedSaveDeletePatch.cs b/Patches/Saves/ModdedSaveDeletePatch.cs new file mode 100644 index 00000000..e51d600a --- /dev/null +++ b/Patches/Saves/ModdedSaveDeletePatch.cs @@ -0,0 +1,29 @@ +using BaseLib.Utils; +using HarmonyLib; +using MegaCrit.Sts2.Core.Modding; +using MegaCrit.Sts2.Core.Saves; + +namespace BaseLib.Patches.Saves; + +[HarmonyPatch(typeof(CloudSaveStore), "DeleteFile")] +static class ModdedSaveDeletePatch +{ + public static void Postfix(CloudSaveStore __instance, string path) + { + var isTargetFile = path.EndsWith("current_run.save") || + path.EndsWith("current_run_mp.save") || + path.EndsWith(".save.backup"); + if (!isTargetFile) return; + + foreach (var mod in ModManager.GetLoadedMods()) + { + var modId = ModSaveUtils.GetModId(mod); + var modPath = ModSaveUtils.GetModPath(path, modId); + + if (__instance.FileExists(modPath)) + { + __instance.DeleteFile(modPath); + } + } + } +} \ No newline at end of file diff --git a/Patches/Saves/ModdedSaveStorePatch.cs b/Patches/Saves/ModdedSaveStorePatch.cs new file mode 100644 index 00000000..2c58fd6b --- /dev/null +++ b/Patches/Saves/ModdedSaveStorePatch.cs @@ -0,0 +1,100 @@ +using BaseLib.Utils; +using HarmonyLib; +using MegaCrit.Sts2.Core.Modding; +using MegaCrit.Sts2.Core.Saves; + +namespace BaseLib.Patches.Saves; + + + +[HarmonyPatch(typeof(CloudSaveStore))] +public static class ModdedSaveStorePatch +{ + private static bool _isInternal; + + [HarmonyPostfix] + [HarmonyPatch(nameof(CloudSaveStore.WriteFile), new[] { typeof(string), typeof(string) })] + static void PostfixSyncString(CloudSaveStore __instance, string path) + => ProcessTrigger(__instance, path); + + [HarmonyPostfix] + [HarmonyPatch(nameof(CloudSaveStore.WriteFile), new[] { typeof(string), typeof(byte[]) })] + static void PostfixSyncBytes(CloudSaveStore __instance, string path) + => ProcessTrigger(__instance, path); + + [HarmonyPostfix] + [HarmonyPatch(nameof(CloudSaveStore.WriteFileAsync), new[] { typeof(string), typeof(string) })] + static void PostfixAsyncString(CloudSaveStore __instance, string path) + => ProcessTrigger(__instance, path); + + + [HarmonyPostfix] + [HarmonyPatch(nameof(CloudSaveStore.WriteFileAsync), new[] { typeof(string), typeof(byte[]) })] + static void PostfixAsyncBytes(CloudSaveStore __instance, string path) + => ProcessTrigger(__instance, path); + + static void ProcessTrigger(CloudSaveStore __instance, string path) + { + var isRunSave = path.EndsWith("current_run.save") || path.EndsWith("current_run_mp.save"); + if (_isInternal || !isRunSave) return; + + _isInternal = true; + try + { + foreach (var mod in ModManager.GetLoadedMods()) + { + var modId = ModSaveUtils.GetModId(mod); + var modPath = ModSaveUtils.GetModPath(path, modId); + var modData = ModSaveUtils.GetModDataToSave(mod); + if (!string.IsNullOrEmpty(modData) && !modData.Equals("{}")) + { + __instance.WriteFile(modPath, modData); + } + } + } + finally + { + _isInternal = false; + } + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(CloudSaveStore.ReadFile), new[] { typeof(string) })] + static void PostfixReadFile(CloudSaveStore __instance, string path, ref string __result) + { + if (IsInvalidRead(path, __result)) return; + ProcessRead(__instance, path); + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(CloudSaveStore.ReadFileAsync), new[] { typeof(string) })] + static async Task PostfixReadFileAsync(Task __result, CloudSaveStore __instance, string path) + { + var content = await __result; + if (!IsInvalidRead(path, content)) + { + ProcessRead(__instance, path); + } + return content; + } + + private static void ProcessRead(CloudSaveStore store, string vanillaPath) + { + foreach (var mod in ModManager.GetLoadedMods()) + { + var modPath = ModSaveUtils.GetModPath(vanillaPath, ModSaveUtils.GetModId(mod)); + + if (!store.FileExists(modPath)) continue; + var modJson = store.ReadFile(modPath); + if (!string.IsNullOrEmpty(modJson)) + { + ModSaveUtils.LoadDataIntoMod(mod, modJson); + } + } + } + + private static bool IsInvalidRead(string path, string? content) => + string.IsNullOrEmpty(content) || + (!path.EndsWith("current_run.save") && !path.EndsWith("current_run_mp.save")); + +} diff --git a/Patches/Saves/ModdedSaveSyncPatch.cs b/Patches/Saves/ModdedSaveSyncPatch.cs new file mode 100644 index 00000000..8863a039 --- /dev/null +++ b/Patches/Saves/ModdedSaveSyncPatch.cs @@ -0,0 +1,22 @@ +using HarmonyLib; +using MegaCrit.Sts2.Core.Saves; + +namespace BaseLib.Patches.Saves; + +[HarmonyPatch(typeof(SaveManager), "EnumerateCloudSyncTasks")] +static class ModdedSaveSyncPatch +{ + public static IEnumerable Postfix(IEnumerable __result, CloudSaveStore cloudStore) + { + foreach (var task in __result) yield return task; + for (var i = 1; i <= 3; i++) + { + var profileModDir = UserDataPathProvider.GetProfileDir(i) + "/saves/mods"; + foreach (var modFolder in cloudStore.CloudStore.GetDirectoriesInDirectory(profileModDir)) + { + yield return cloudStore.SyncCloudToLocal($"{profileModDir}/{modFolder}/current_run.save"); + yield return cloudStore.SyncCloudToLocal($"{profileModDir}/{modFolder}/current_run_mp.save"); + } + } + } +} \ No newline at end of file diff --git a/Utils/ModSaveUtils.cs b/Utils/ModSaveUtils.cs new file mode 100644 index 00000000..7bb5c690 --- /dev/null +++ b/Utils/ModSaveUtils.cs @@ -0,0 +1,179 @@ +using System.Reflection; +using System.Text.Json; +using MegaCrit.Sts2.Core.Modding; + +namespace BaseLib.Utils; + +/// +/// Mod save entry point. +/// +/// +/// Set entry point for custom run data: +/// +/// public static class MySaveManager +/// { +/// [ModSave] public static MyRunData RunData = new(); +/// } +/// +/// +/// Root save container +/// +/// public class MyRunData : ISaveSchema, IPacketSerializable +/// { +/// [JsonPropertyName("schema_version")] +/// public int SchemaVersion { get; set; } = 1; +/// +/// // Example save data +/// [JsonPropertyName("player_data")] +/// public List<MyPlayerData> PlayerData { get; set; } = []; +/// +/// public void Serialize(PacketWriter writer) +/// { +/// writer.WriteInt(SchemaVersion); +/// writer.WriteInt(PlayerData.Count); +/// foreach (var pData in PlayerData) +/// { +/// pData.Serialize(writer); +/// } +/// } +/// +/// public void Deserialize(PacketReader reader) +/// { +/// ... +/// } +/// } +/// +/// +/// Player save data: +/// +/// public class MyPlayerData : IPacketSerializable +/// { +/// [JsonPropertyName("net_id")] +/// public ulong NetId { get; set; } +/// +/// [JsonPropertyName("collector_deck")] +/// public List<SerializableCard> SavedCards { get; set; } = []; +/// +/// [JsonPropertyName("essence")] +/// public int Essence { get; set; } +/// +/// public void Serialize(PacketWriter writer) +/// { +/// writer.WriteULong(NetId); +/// writer.WriteInt(Essence); +/// writer.WriteList(SavedCards); +/// } +/// +/// public void Deserialize(PacketReader reader) +/// { +/// NetId = reader.ReadULong(); +/// Essence = reader.ReadInt(); +/// SavedCards = reader.ReadList<SerializableCard>(); +/// } +/// +/// public static MyPlayerData FromPlayer(Player player) +/// { +/// var netId = player.NetId; +/// var data = MyRunData.PlayerData.Find(p => p.NetId == netId); +/// if (data != null) return data; +/// data = new MyPlayerData { NetId = netId }; +/// MyRunData.PlayerData.Add(data); +/// return data; +/// } +/// +/// public static List<CardModel> GetSavedCards(Player player) => +/// MyPlayerData.FromPlayer(player).SavedCards.Select(CardModel.FromSerializable) +/// .ToList(); +/// +/// public static void AddSavedCard(Player player, CardModel card) => +/// MyPlayerData.FromPlayer(player).SavedCards.Add(card.ToSerializable()); +/// +/// } +/// +/// +[AttributeUsage(AttributeTargets.Field)] +public class ModSaveAttribute : Attribute; + + + + +public static class ModSaveUtils +{ + /// + /// Gets the unique identifier of a mod. + /// + public static string GetModId(Mod mod) + { + return mod.manifest?.id ?? "UnknownMod"; + } + + /// + /// Builds the mod-specific save file path based on a vanilla save path. + /// + public static string GetModPath(string vanillaPath, string modId) + { + var directory = Path.GetDirectoryName(vanillaPath) ?? ""; + var fileName = Path.GetFileName(vanillaPath); + return Path.Combine(directory, "mods", modId, fileName).Replace("\\", "/"); + } + + private static readonly Dictionary SaveFieldCache = new(); + + private static FieldInfo? GetSaveField(Mod mod) + { + var modId = GetModId(mod); + if (SaveFieldCache.TryGetValue(modId, out var cachedField)) return cachedField; + var field = mod.assembly?.GetTypes() + .SelectMany(t => t.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)) + .FirstOrDefault(f => f.GetCustomAttribute() != null); + + if (field != null) SaveFieldCache[modId] = field; + return field; + } + + /// + /// Serializes the mod save field to JSON. + /// + /// Target mod. + /// JSON save data, or null if unavailable. + public static string? GetModDataToSave(Mod mod) + { + var field = GetSaveField(mod); + if (field == null) return null; + var liveData = field.GetValue(null); + if (liveData == null) return null; + return JsonSerializer.Serialize(liveData, field.FieldType, new JsonSerializerOptions { + WriteIndented = true, + IncludeFields = true + }); + } + + /// + /// Deserializes JSON save data and injects it into the mod's save field. + /// + /// Target mod instance. + /// Serialized save data. + /// + /// Uses reflection to locate the [ModSave] field and replaces its value. + /// If deserialization fails, the error is logged and the operation is safely ignored. + /// + public static void LoadDataIntoMod(Mod mod, string json) + { + var field = GetSaveField(mod); + if (field == null) return; + + try + { + var loadedData = JsonSerializer.Deserialize(json, field.FieldType, new JsonSerializerOptions { + IncludeFields = true + }); + + if (loadedData == null) return; + field.SetValue(null, loadedData); + } + catch (Exception ex) + { + BaseLibMain.Logger.Error($"Load error for {mod.manifest?.id}: {ex.Message}"); + } + } +} \ No newline at end of file