Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions Patches/Saves/ModdedSaveDeletePatch.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
}
100 changes: 100 additions & 0 deletions Patches/Saves/ModdedSaveStorePatch.cs
Original file line number Diff line number Diff line change
@@ -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<string?> PostfixReadFileAsync(Task<string?> __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"));

}
22 changes: 22 additions & 0 deletions Patches/Saves/ModdedSaveSyncPatch.cs
Original file line number Diff line number Diff line change
@@ -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<Task> Postfix(IEnumerable<Task> __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");
}
}
}
}
179 changes: 179 additions & 0 deletions Utils/ModSaveUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
using System.Reflection;
using System.Text.Json;
using MegaCrit.Sts2.Core.Modding;

namespace BaseLib.Utils;

/// <summary>
/// Mod save entry point.
/// </summary>
/// <example>
/// Set entry point for custom run data:
/// <code>
/// public static class MySaveManager
/// {
/// [ModSave] public static MyRunData RunData = new();
/// }
/// </code>
///
/// Root save container
/// <code>
/// public class MyRunData : ISaveSchema, IPacketSerializable
/// {
/// [JsonPropertyName("schema_version")]
/// public int SchemaVersion { get; set; } = 1;
/// &#10;
/// // Example save data
/// [JsonPropertyName("player_data")]
/// public List&lt;MyPlayerData&gt; PlayerData { get; set; } = [];
/// &#10;
/// public void Serialize(PacketWriter writer)
/// {
/// writer.WriteInt(SchemaVersion);
/// writer.WriteInt(PlayerData.Count);
/// foreach (var pData in PlayerData)
/// {
/// pData.Serialize(writer);
/// }
/// }
/// &#10;
/// public void Deserialize(PacketReader reader)
/// {
/// ...
/// }
/// }
/// </code>
///
/// Player save data:
/// <code>
/// public class MyPlayerData : IPacketSerializable
/// {
/// [JsonPropertyName("net_id")]
/// public ulong NetId { get; set; }
/// &#10;
/// [JsonPropertyName("collector_deck")]
/// public List&lt;SerializableCard> SavedCards { get; set; } = [];
/// &#10;
/// [JsonPropertyName("essence")]
/// public int Essence { get; set; }
/// &#10;
/// public void Serialize(PacketWriter writer)
/// {
/// writer.WriteULong(NetId);
/// writer.WriteInt(Essence);
/// writer.WriteList(SavedCards);
/// }
/// &#10;
/// public void Deserialize(PacketReader reader)
/// {
/// NetId = reader.ReadULong();
/// Essence = reader.ReadInt();
/// SavedCards = reader.ReadList&lt;SerializableCard>();
/// }
/// &#10;
/// 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&lt;CardModel> GetSavedCards(Player player) =>
/// MyPlayerData.FromPlayer(player).SavedCards.Select(CardModel.FromSerializable)
/// .ToList();
/// &#10;
/// public static void AddSavedCard(Player player, CardModel card) =>
/// MyPlayerData.FromPlayer(player).SavedCards.Add(card.ToSerializable());
/// &#10;
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Field)]
public class ModSaveAttribute : Attribute;




public static class ModSaveUtils
{
/// <summary>
/// Gets the unique identifier of a mod.
/// </summary>
public static string GetModId(Mod mod)
{
return mod.manifest?.id ?? "UnknownMod";
}

/// <summary>
/// Builds the mod-specific save file path based on a vanilla save path.
/// </summary>
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<string, FieldInfo> 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<ModSaveAttribute>() != null);

if (field != null) SaveFieldCache[modId] = field;
return field;
}

/// <summary>
/// Serializes the mod save field to JSON.
/// </summary>
/// <param name="mod">Target mod.</param>
/// <returns>JSON save data, or null if unavailable.</returns>
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
});
}

/// <summary>
/// Deserializes JSON save data and injects it into the mod's save field.
/// </summary>
/// <param name="mod">Target mod instance.</param>
/// <param name="json">Serialized save data.</param>
/// <remarks>
/// Uses reflection to locate the <c>[ModSave]</c> field and replaces its value.
/// If deserialization fails, the error is logged and the operation is safely ignored.
/// </remarks>
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}");
}
}
}