SSTU - Shadow Space Technologies Unlimited

SSTU - Shadow Space Technologies Unlimited

98.5k Downloads

Module Switching - Technical Discussion

blowfishpro opened this issue ยท 4 comments

commented

I've been doing some investigation of this on my own, and it looks doable, if complicated. But perhaps you've gained some insights I haven't.

Life Cycle

  • Part Load - data about switchable modules is stored somewhere. Possibly just as a List<ConfigNode>
  • Part instantiation - data about switchable modules has to be preserved (like any custom data). My current preference is to save to a ConfigNode and then save to a string field in OnBeforeSerialize and then re-load in OnAfterDeserialize, but there are other approaches
  • Part Start - If the custom module doesn't already exist (see part instance load below), it should be created in PartModule.Start(). OnStart won't work because KSP is iterationg over part.Modules so it can't be changed (maybe this will be different in KSP 1.2 with all the foreach being eliminated though). Parent module should call OnStart on the child module here, I /think/ Unity will call Start automatically
  • Module Switch in Editor - Old module is removed from part.Modules and destroyed, new module is created from saved ConfigNode and added, OnStart called on it with appropriate parameters. Part action window is refreshed
  • Part save - The current module is saved as normal
  • Part instance load - The parent module will load its fields and then create the child module. KSP does this step by searching for PartModules, so adding one here should be fine, and it will be loaded normally (since the child module will appear after the parent in the saved part data). This should only happen on instance load, not part parsing the part (this can be checked using module.part.partInfo != null or some other things.

Everything from here should progress normally

Unknowns

  • I'm not sure if anything has to be done to update the action groups menu when a module is added/removed. I can investigate this
  • This probably won't work for all modules. But many of the self-contained stock modules would probably work fine, including, as far as I can see ModuleDockingNode and ModuleDeployableSolarPanel (not sure about you solar panel module)
commented

What you have laid out is pretty much what I had figured out.

  • Don't add any module-switch controlled modules to the prefab (this prevents extra save-data from residing on the prefab).
  • Add the already activated modules to the part during the ModuleSwitches OnLoad method; stock code automatically calls OnLoad() and OnStart() for them, passing any any data from the persistence file for those modules properly.
  • New modules added while in the editor need to have their OnStart() method called manually.

Yes, it all works for the stock modules that I tested it with.

My problem comes when I try and use it for my custom modules that need access to the config node data for one reason or another (mostly because they use sub-nodes to define things, and stock doesn't pass the base config + sub-nodes consistently during OnLoad).

My current method around the lack of base-node being passed in OnLoad() is is to cache the entire part config after the database has been loaded, and then query this part config from the part-modules that need their sub-nodes. Works great. Until you try and access the part-config level MODULE node for a PartModule that isn't defined in the part but is added through the ModuleSwitch; at which point it all blows up because there is no MODULE node defined in the part config for that module.

I could hack around this and make my modules search for the ModuleSwitch config, and then the sub-node within; but then you run into problems of -- what if there are multiples of a module defined in the ModuleSwitch, how to know which config belongs to the currently loaded/active module? You can't.... (without yet-more special handling of fields for some sort of module-id).

The other option is to revert how I handle caching of config node data and to cache it directly when the module is first passed its config node with sub-nodes during the initial OnLoad() call. This is how I was handling things a few months ago, and it does work. However this requires adding more fields and code to each and every PartModule to handle the caching of this data; I moved away from this method because I found it painful to manage and a bit ugly to implement.

-IF- I could rely on stock code -always- passing the full config for the Module during every OnLoad() call, none of this would be a problem. I could simply use the data from the node as it was passed or cache it there if I really needed the data later. Sadly, I cannot; stock only passes the full config during the prefabs' OnLoad() call. Hell, OnLoad() doesn't even get called in the editor for new parts at all. I can't even rely on the method being called for any given instance of a Part/PartModule.

In short, my problem wasn't with the actual switching of modules -- it is with the base loading and handling of modules and the hacks I've had to implement to work around the terrible system that is currently in place.

All I need in order to make it work (without ugly hacks and workarounds) is reliable and consistent passing of the entire PartModule config to the module on each and every OnLoad() call, and for OnLoad() to -always- be called whenever that PartModule is instantiated.

Below is the code I was using, which was working great for the stock modules (and other simple modules) that I was testing it with.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using UnityEngine;

namespace SSTUTools
{
    public class SSTUModuleSwitch : PartModule
    {
        //merely tracks what modules have been activated, and what order they should be restored in.
        [KSPField(isPersistant = true)]
        public string persistentData;

        private ModuleSwitchData[] moduleData;
        private bool initialized = false;

        public override void OnLoad(ConfigNode node)
        {
            base.OnLoad(node);
            MonoBehaviour.print("SSTUModuleSwitch OnLoad():\n"+node);
            init();
        }

        public override void OnSave(ConfigNode node)
        {
            base.OnSave(node);
            updatePersistentData();
            node.SetValue("persistentData", persistentData, true);
            MonoBehaviour.print("SSTUModuleSwitch OnSave:\n"+node.ToString());
        }

        public override void OnStart(StartState state)
        {
            base.OnStart(state);
            MonoBehaviour.print("SSTUModuleSwitch OnStart(): "+state);
            init();
        }

        public void Start()
        {
            MonoBehaviour.print("SSTUModuleSwitch Start");
        }

        private void init()
        {
            //only run init once
            if (initialized) { return; }
            //not active on prefab part
            //this prevents modules from being copied from the prefab into the live part
            //all modules intended for toggling must be activated only in editor or flight scenes
            if (!HighLogic.LoadedSceneIsEditor && !HighLogic.LoadedSceneIsFlight)
            {
                return;
            }
            MonoBehaviour.print("SSTUModuleSwitch Init");

            //load module data
            //this is where I load the cached PartModule config, to get access to the sub-nodes within
            ConfigNode n = SSTUStockInterop.getPartModuleConfig(this);
            ConfigNode[] managedModuleNodes = n.GetNodes("MANAGED_MODULE");
            int len = managedModuleNodes.Length;
            moduleData = new ModuleSwitchData[len];
            for (int i = 0; i < len; i++)
            {
                moduleData[i] = new ModuleSwitchData(managedModuleNodes[i]);
            }
            MonoBehaviour.print("Loaded: " + moduleData.Length + " module configs.");
        }

        private void restorePersistentData()
        {
            //re-enable any modules that were flagged as enabled in the persistence data
            string[] moduleStrings = SSTUUtils.parseCSV(persistentData);
            string[] split;
            int id;
            int index;
            int len = moduleStrings.Length;
            List<ModuleSortData> datas = new List<ModuleSortData>();
            for (int i = 0; i < len; i++)
            {
                split = moduleStrings[i].Split(new char[] { '-' });
                id = int.Parse(split[0]);
                index = int.Parse(split[1]);
                datas.Add(new ModuleSortData(id, index));
            }
            datas.Sort(ModuleSortData.indexComparator());
            len = datas.Count;
            for (int i = 0; i < len; i++)
            {
                id = datas[i].id;
                index = datas[i].index;
                if (index != part.Modules.Count) { MonoBehaviour.print("ERROR: Module count is incorrect for added module index; this may cause persistent-data loading problems."); }
                enableModule(id, false);
            }
        }

        private void updatePersistentData()
        {
            if (moduleData == null) { return; }//not yet initialized; persist whatever data is already present
            int len = moduleData.Length;
            for (int i = 0; i < len; i++)
            {
                if (moduleData[i].active)
                {
                    if (persistentData.Length > 0) { persistentData = persistentData + ","; }
                    persistentData = persistentData + moduleData[i].getPersistentData();
                }
            }
        }

        public void enableModule(int moduleID, bool startup = true)
        {
            ModuleSwitchData data = getModuleData(moduleID);
            if (data != null) { data.enable(part, startup); }
            printPartModules();
        }

        public void disableModule(int moduleID)
        {
            ModuleSwitchData data = getModuleData(moduleID);
            if (data != null) { data.disable(part); }
            printPartModules();
        }

        private ModuleSwitchData getModuleData(int id)
        {
            int len = moduleData.Length;
            for (int i = 0; i < len; i++)
            {
                if (moduleData[i].moduleID == id) { return moduleData[i]; }
            }
            MonoBehaviour.print("ERROR: No module found for id: " + id);            
            return null;
        }

        private void printPartModules()
        {
            int len = part.Modules.Count;
            for (int i = 0; i < len; i++)
            {
                MonoBehaviour.print("Module at index: " + i + " :: " + part.Modules[i]);
            }
        }

    }

    public class ModuleSwitchData
    {
        public readonly ConfigNode moduleNode;
        public readonly int moduleID;        
        public bool active = false;
        public int moduleIndex;
        public PartModule module;

        public ModuleSwitchData(ConfigNode node)
        {
            this.moduleNode = node;
            this.moduleID = node.GetIntValue("managedModuleID");
            this.active = false;
        }

        public void enable(Part part, bool startup = true)
        {
            if (active) { return; }
            active = true;
            module = part.AddModule(moduleNode);
            moduleIndex = part.Modules.IndexOf(module);
            if (startup)//only happens when modules are swapped in the editor
            {
                PartModule.StartState state = PartModule.StartState.Editor;
                module.OnStart(state);
            }
        }

        public void disable(Part part)
        {
            if (!active) { return; }
            active = false;
            part.RemoveModule(module);
            module = null;
            moduleIndex = 0;
        }

        public string getPersistentData()
        {
            return moduleID + "-" + moduleIndex;
        }

    }

    public class ModuleSortData
    {
        public readonly int id;
        public readonly int index;

        public ModuleSortData(int id, int index)
        {
            this.id = id;
            this.index = index;
        }

        public static Comparison<ModuleSortData> indexComparator()
        {
            Comparison<ModuleSortData> comparator = (x, y) => x.index - y.index;
            return comparator;
        }
    }

}
commented

Yep, that pretty much covers it. The OnLoad() handling likely is intentional; it makes sense from a design perspective given Unity's serialization support. So I don't think it is likely that they'll change it.

I had previously tried using custom serializable classes as can be seen in the KSP Propellant class/etc, but they do not load/work for classes added through mod .dll's (as you pointed out). Which leaves mods unable to pull complex data from the prefab part without workarounds, which is where I'm currently at.

My current implementation is to cache all part-level config nodes during the MM database-loaded callback, which gave me some nice single-line methods for grabbing the config node data that I needed; however this presents the above problems for ModuleSwitch use.

My previous implementation of storing the sub-node data would have worked with a ModuleSwitch setup (as I cached what was passed to the prefab during OnLoad() ), but required adding extra fields and methods to every PartModule in order to cache the sub-node data into a serializable string so that it would persist to non-prefab parts. It worked, but was not a very good solution from my point of view.

But yes, the root of my problem with ModuleSwitching is not the actual switching of modules, but with the lack of proper methods to persist complex data from prefab to live parts that resulted in me implementing a caching method that is incompatible with module-switching.

I'm considering moving back to my old caching system so that I might be able to implement some form of ModuleSwitching in the future, or at least have my modules be compatible if any other module-switch options pop up. It would be a fair bit of work, still a bit of an ugly workaround, and I've already moved on to part designs that do not need it... so at this point I'm not in any huge rush.

Still... would be nice if all of these workarounds weren't needed in the first place.

commented

Hmm yeah. And it wouldn't be sufficient to put all of this in a superclass because it looks like you are deriving from some of the stock modules.

I think the best option (both here and in general with KSP) is to break out and isolate the hacky workarounds so that the code you work with most of the time is as clean as possible.

Here is one possible solution. It's not the only possible solution but it seems reasonable to me (1) The number of lines that need to be added to each PartModule can be counted on one hand and (2) Because nothing depends on global state

public class MyModule : PartModule
{
    [SerializeField]
    private string savedData;

    public override void OnAwake()
    {
        base.OnAwake();

        LoadDataIfNecessary(savedData);

        // ...
    }

    public override void OnLoad(ConfigNode node)
    {
        base.OnLoad(node);

        SaveDataIfNecessary(node, ref savedData);

        // ...
    }
}

public static class Extensions
{
    public static void SaveDataIfNecessary(this PartModule module, ConfigNode node, ref string savedData)
    {
        if (string.IsNullOrEmpty(savedData))
        {
            savedData = node.ToString();
        }
    }

    public static void LoadDataIfNecessary(this PartModule module, string savedData)
    {
        if (!string.IsNullOrEmpty(savedData))
        {
            ConfigNode node = ConfigNode.Parse(savedData);
            module.OnLoad(node); // Could call load here but the extra stuff it does isn't necessary
        }
    }
}

Now, you could theoretically instead save the ConfigNode in some global cache instead of an instance variable. This would eliminate another couple of lines from each module and avoid duplicating data, but of course the cache would have to be invalidated on database reloads and it introduces dependence on global state (which I don't like but maybe that's just my opinion).

There are definitely variations on this / completely different alternatives that would work here. Let me know if you want to hear about them.

commented

I think that the way OnLoad works is intentional. When parsing the part for the first time, it loads most of the PartModule's data. If it's called again on the instantiated part (and it may not be), it's only to load any data specific to /that instance/ - i.e. persistent fields, etc. Theoretically, this is fine because all of that data was already loaded onto the module, and was carried over from the prefab ... /theoretically/

The complication is that not all data actually gets carried over from the prefab because Unity will not serialize custom types that aren't present when it starts, which excludes anything declared in a mod DLL because those are loaded by KSP after the game starts.

It seems like this is the heart of the problem to me. Of course correct me if I'm wrong.