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.