diff --git a/StarMap.API/SaveAttributes.cs b/StarMap.API/SaveAttributes.cs
new file mode 100644
index 0000000..f073787
--- /dev/null
+++ b/StarMap.API/SaveAttributes.cs
@@ -0,0 +1,77 @@
+using System.IO;
+using System.Reflection;
+
+namespace StarMap.API
+{
+ ///
+ /// Methods marked with this attribute will be called after KSA has written a save, including an
+ /// overwrite and a save-as. Not called when the write fails.
+ ///
+ ///
+ /// Methods using this attribute must match the following signature:
+ ///
+ ///
+ /// public void MethodName(System.IO.DirectoryInfo saveDirectory);
+ ///
+ ///
+ /// Parameter requirements:
+ ///
+ /// -
+ ///
+ /// is the folder the save was written to.
+ ///
+ ///
+ ///
+ ///
+ /// Requirements:
+ ///
+ /// - Return type must be .
+ /// - Method must be an instance method (non-static).
+ ///
+ ///
+ public sealed class StarMapAfterSaveAttribute : StarMapMethodAttribute
+ {
+ public override bool IsValidSignature(MethodInfo method)
+ {
+ return method.ReturnType == typeof(void) &&
+ method.GetParameters().Length == 1 &&
+ method.GetParameters()[0].ParameterType == typeof(DirectoryInfo);
+ }
+ }
+
+ ///
+ /// Methods marked with this attribute will be called after KSA has loaded a save and rebuilt the
+ /// world from it. Not called when the save could not be read.
+ ///
+ ///
+ /// Methods using this attribute must match the following signature:
+ ///
+ ///
+ /// public void MethodName(System.IO.DirectoryInfo saveDirectory);
+ ///
+ ///
+ /// Parameter requirements:
+ ///
+ /// -
+ ///
+ /// is the folder the save was read from.
+ ///
+ ///
+ ///
+ ///
+ /// Requirements:
+ ///
+ /// - Return type must be .
+ /// - Method must be an instance method (non-static).
+ ///
+ ///
+ public sealed class StarMapAfterLoadAttribute : StarMapMethodAttribute
+ {
+ public override bool IsValidSignature(MethodInfo method)
+ {
+ return method.ReturnType == typeof(void) &&
+ method.GetParameters().Length == 1 &&
+ method.GetParameters()[0].ParameterType == typeof(DirectoryInfo);
+ }
+ }
+}
diff --git a/StarMap.Core/ModRepository/ModsLoader.cs b/StarMap.Core/ModRepository/ModsLoader.cs
index 42a1ff4..9d05947 100644
--- a/StarMap.Core/ModRepository/ModsLoader.cs
+++ b/StarMap.Core/ModRepository/ModsLoader.cs
@@ -10,6 +10,8 @@ namespace StarMap.Core.ModRepository
{
internal interface IModsLoader : IDisposable
{
+ ModRegistry ModRegistry { get; }
+
void Init();
}
diff --git a/StarMap.Core/ModRepository/RuntimeMod.cs b/StarMap.Core/ModRepository/RuntimeMod.cs
index 360cfe8..ca69a69 100644
--- a/StarMap.Core/ModRepository/RuntimeMod.cs
+++ b/StarMap.Core/ModRepository/RuntimeMod.cs
@@ -155,6 +155,8 @@ public bool InitializeMod(ModRegistry modRegistry, IPatchManager patchManager, I
{
case StarMapBeforeMainAttribute:
case StarMapUnloadAttribute:
+ case StarMapAfterSaveAttribute:
+ case StarMapAfterLoadAttribute:
modRegistry.AddModMethod(ModId, attr, modObject, classMethod);
break;
case StarMapAllModsLoadedAttribute:
diff --git a/StarMap.Core/Patches/SaveLoadPatches.cs b/StarMap.Core/Patches/SaveLoadPatches.cs
new file mode 100644
index 0000000..6b048ba
--- /dev/null
+++ b/StarMap.Core/Patches/SaveLoadPatches.cs
@@ -0,0 +1,104 @@
+using HarmonyLib;
+using KSA;
+using StarMap.API;
+using StarMap.API.Services;
+using StarMap.Core.ModRepository;
+using System.Reflection;
+
+namespace StarMap.Core.Patches
+{
+ internal interface ISaveLoadPatches
+ {
+ void Init();
+ }
+
+ internal class SaveLoadPatches : ISaveLoadPatches
+ {
+ private readonly IPatchManager _patchManager;
+ private readonly IModsLoader _modsLoader;
+
+ // UncompressedSave.Load returns early when the save cannot be read, so its postfix alone
+ // would report loads that never happened. OnGameLoaded runs only once the world is rebuilt.
+ private bool _worldRebuilt;
+
+ public SaveLoadPatches(IPatchManager patchManager, IModsLoader modsLoader)
+ {
+ _patchManager = patchManager;
+ _modsLoader = modsLoader;
+ }
+
+ public void Init()
+ {
+ // Every save, overwrite and save-as goes through Make.
+ var make = AccessTools.Method(typeof(UncompressedSave), nameof(UncompressedSave.Make), [typeof(string)]);
+ var load = AccessTools.Method(typeof(UncompressedSave), nameof(UncompressedSave.Load), Type.EmptyTypes);
+ var gameLoaded = AccessTools.Method(typeof(Program), nameof(Program.OnGameLoaded), Type.EmptyTypes);
+
+ if (make is null)
+ {
+ Console.WriteLine("StarMap - UncompressedSave.Make(string) not found; StarMapAfterSave will not be called");
+ }
+ else
+ {
+ _patchManager.RegisterStaticPatch(IPatchService.PatchType.Postfix, null, make, AfterMake);
+ }
+
+ if (load is null || gameLoaded is null)
+ {
+ Console.WriteLine("StarMap - UncompressedSave.Load() or Program.OnGameLoaded() not found; StarMapAfterLoad will not be called");
+ return;
+ }
+
+ _patchManager.RegisterInstanceVoidPatch(IPatchService.PatchType.Prefix, null, load, BeforeLoad);
+ _patchManager.RegisterInstanceVoidPatch(IPatchService.PatchType.Postfix, null, load, AfterLoad);
+ _patchManager.RegisterStaticVoidPatch(IPatchService.PatchType.Postfix, null, gameLoaded, AfterGameLoaded);
+ }
+
+ private bool AfterMake(object[] args, ref GameSave? result)
+ {
+ if (result is UncompressedSave save)
+ Dispatch(save.Directory);
+
+ return true;
+ }
+
+ private bool BeforeLoad(object[] args, UncompressedSave save)
+ {
+ _worldRebuilt = false;
+ return true;
+ }
+
+ private bool AfterGameLoaded(object[] args)
+ {
+ _worldRebuilt = true;
+ return true;
+ }
+
+ private bool AfterLoad(object[] args, UncompressedSave save)
+ {
+ if (!_worldRebuilt)
+ return true;
+
+ _worldRebuilt = false;
+ Dispatch(save.Directory);
+ return true;
+ }
+
+ // These run inside KSA's save and load, so one mod's exception must not reach the game
+ // or stop the mods after it.
+ private void Dispatch(DirectoryInfo directory) where TAttribute : StarMapMethodAttribute
+ {
+ foreach (var (_, modObject, method) in _modsLoader.ModRegistry.Get())
+ {
+ try
+ {
+ method.Invoke(modObject, [directory]);
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine($"StarMap - {typeof(TAttribute).Name} in {method.DeclaringType?.FullName} threw: {(e as TargetInvocationException)?.InnerException ?? e}");
+ }
+ }
+ }
+ }
+}
diff --git a/StarMap.Core/Services/CoreService.cs b/StarMap.Core/Services/CoreService.cs
index f06c814..72adc90 100644
--- a/StarMap.Core/Services/CoreService.cs
+++ b/StarMap.Core/Services/CoreService.cs
@@ -8,11 +8,12 @@ internal interface ICoreService
void Init();
}
- internal class CoreService(IStarMapPatches _starmapPatches, IModsLoader _modsLoader) : ICoreService
+ internal class CoreService(IStarMapPatches _starmapPatches, ISaveLoadPatches _saveLoadPatches, IModsLoader _modsLoader) : ICoreService
{
public void Init()
{
_starmapPatches.Init();
+ _saveLoadPatches.Init();
_modsLoader.Init();
}
}
diff --git a/StarMap.Core/StarMapCore.cs b/StarMap.Core/StarMapCore.cs
index 45d6855..5b59a8e 100644
--- a/StarMap.Core/StarMapCore.cs
+++ b/StarMap.Core/StarMapCore.cs
@@ -22,6 +22,7 @@ public StarMapCore(AssemblyLoadContext coreAssemblyLoadContext)
serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
diff --git a/docs/modding/lifecycle.md b/docs/modding/lifecycle.md
index 3fe00f1..3b36386 100644
--- a/docs/modding/lifecycle.md
+++ b/docs/modding/lifecycle.md
@@ -13,6 +13,8 @@ Mark the entry class with `[StarMapMod]`. StarMap creates one instance for the m
| `[StarMapBeforeGui]` | `void Method(double dt)` | Before KSA's GUI frame callback. |
| `[StarMapAfterGui]` | `void Method(double dt)` | After KSA's GUI drawing pass. |
| `[StarMapAfterOnFrame]` | `void Method(double currentPlayerTime, double dtPlayer)` | After KSA's `Program.OnFrame`. |
+| `[StarMapAfterSave]` | `void Method(System.IO.DirectoryInfo saveDirectory)` | After KSA writes a save, including an overwrite or a save-as. Not called if the write fails. |
+| `[StarMapAfterLoad]` | `void Method(System.IO.DirectoryInfo saveDirectory)` | After KSA loads a save and rebuilds the world from it. Not called if the save could not be read. |
The callback methods return `void`. Use the exact parameter types and counts; methods with a different signature are ignored.