An attempt to replace saving system with XML

Recently I came across the post from Brian_Trotter :
Saving System Upgrade: Replacing BinaryFormatter with Json.NET [ON HOLD]
Basically Microsoft announced that BinaryFormatter is insecure and should no longer be used.

After digging around Google, I found that using XML may be a solution.
What I did may not be a good solution. So I am posting it here and try to get some opinions.

ISaveable

using System.Xml;

namespace RPG.Saving
{
    public interface ISaveable
    {
        XmlDocument CaptureState();
        void RestoreState(XmlDocument state);
    }
}

XmlNode/XmlElement is the class that hold the data we need to save. Unfortunately node or element cannot pass as reference by themselves. From what I tried, it seems they must attach to the XmlDocument they are created in. So everything must first wrap in XmlDocument before passing to other method or class.

        /// <summary>
        /// Capture states of this SaveableMover and pass it as XmlDocument
        /// </summary>
        /// <returns>(XmlDocument) states of this SaveableMover</returns>
        public XmlDocument CaptureState()
        {
            XmlDocument tempDoc = new XmlDocument();
            XmlElement root = tempDoc.CreateElement(this.GetType().ToString());
            tempDoc.AppendChild(root);
            XmlElement position = tempDoc.CreateElement("position");
            position.InnerText = transform.position.ToString();
            root.AppendChild(position);
            XmlElement rotation = tempDoc.CreateElement("rotation");
            rotation.InnerText = transform.rotation.eulerAngles.ToString();
            root.AppendChild(rotation);

            return tempDoc;
        }

        /// <summary>
        /// Restore states of this SaveableMover from the XmlDocument
        /// </summary>
        /// <param name="state">(XmlDocument) Loaded data</param>
        public void RestoreState(XmlDocument state)
        {
            XmlNodeList readNodes;
            readNodes = state.GetElementsByTagName("position");
            if(!string.IsNullOrEmpty(readNodes[0].InnerText))
            {
                Vector3 restorePos = readNodes[0].InnerText.ToVector3();
                readNodes = state.GetElementsByTagName("rotation");
                if (!string.IsNullOrEmpty(readNodes[0].InnerText))
                {
                    Quaternion restoreRot = Quaternion.Euler(readNodes[0].InnerText.ToVector3());
                    Teleport(restorePos, restoreRot);
                }
                else
                {
                    Teleport(restorePos);
                }
            }
        }

This code is the CaptureState and RestoreState from Mover (or SaveableMover in my case cause I have a non-saveable one.) Everytime a xml data structure is needed, a XmlDocument instance need to be made. Every XmlDocument must have one and only one root node. In my CaptureState the root node is root and the node tag is the Component type. Everything is saved as node InnerText and is always a string. So when doing a restore, you will need a way to translate that back in to the datatype you want.
For those who wonder what the ToVector3() is, it is just a extension I made to translate string back to Vector3. Here is the code:

 /// <summary>
    /// Take a string and return the Vector3 value
    /// </summary>
    /// <param name="inputString">A Vector3 in string</param>
    /// <returns>Vector3</returns>
    public static Vector3 ToVector3(this string inputString)
    {
        Vector3 returnVector3 = new Vector3();
        string processString = inputString;

        if(processString.StartsWith("("))
        {
            processString = processString.Substring(1);
        }
        if (processString.EndsWith(")"))
        {
            processString = processString.Substring(0, processString.Length - 1);
        }

        string[] inputArray = processString.Split(',');

        returnVector3 = new Vector3(float.Parse(inputArray[0]), float.Parse(inputArray[1]), float.Parse(inputArray[2]));

        return returnVector3;
    }

SaveableEntity

        /// <summary>
        /// Capture states of all ISaveables in the same game object and pass it as XmlDocument
        /// </summary>
        /// <returns>(XmlDocument) All states of this game object</returns>
        public XmlDocument CaptureState()
        {
            XmlDocument state = new XmlDocument();
            XmlElement root = state.CreateElement(uniqueIdentifier);
            state.AppendChild(root);
            foreach (ISaveable saveable in GetComponents<ISaveable>())
            {
                XmlElement captureElement = (XmlElement)state.ImportNode(saveable.CaptureState().DocumentElement, true);
                root.AppendChild(captureElement);
            }
            return state;
        }

        /// <summary>
        /// Restore states of all ISaveables in the same game object from the XmlDocument
        /// </summary>
        /// <param name="state">(XmlDocument) Loaded data</param>
        public void RestoreState(XmlDocument state)
        {
            foreach (ISaveable saveable in GetComponents<ISaveable>())
            {
                string typeString = saveable.GetType().ToString();
                XmlNodeList readNodes = state.GetElementsByTagName(typeString);
                if (readNodes.Count > 0)
                {
                    XmlDocument restoreState = new XmlDocument();
                    restoreState.AppendChild(restoreState.ImportNode(readNodes[0], true));
                    saveable.RestoreState(restoreState);
                }
            }
        }

In the SaveableEntity, the root node is created with the uuid as the tag. It will then get all the captured data form all ISaveable in the same GameObject and import it as a xml node/element. Every imported node will become a child node of the new root node.
In the RestoreState, it will find the node by the ISaveable Type. GetElementsByTagName() will reture a list of nodes with the same tag. As there should be only one of each type in the restored data, just get the first element in the list should be fine.

SavingSystem

        /// <summary>
        /// Get the full save file path.
        /// </summary>
        /// <param name="saveFile"></param>
        /// <returns>(string) File Path</returns>
        private string GetSavePath(string saveFile)
        {
            return Path.Combine(Application.persistentDataPath, saveFile);
        }
        /// <summary>
        /// Save to file
        /// </summary>
        /// <param name="saveFile">File name</param>
        public void Save(string saveFile)
        {
            string path = GetSavePath(saveFile);
            CaptureState().Save(path);
        }

        /// <summary>
        /// Load from file
        /// </summary>
        /// <param name="saveFile">File name</param>
        public void Load(string saveFile)
        {
            XmlDocument xmlDocument = new XmlDocument();
            xmlDocument.PreserveWhitespace = true;

            string path = GetSavePath(saveFile);

            if (!File.Exists(path))
            {
                return;
            }

            xmlDocument.Load(path);

            RestoreState(xmlDocument);
        }

Save and Load is simple. The Class XmlDocument has its own Save and Load methods. You can give the methods a file path in string and it will save and load the file.
According to XmlDocument.Save Method and XmlDocument.Load Method, you can even us a Stream type like a FileStream to save it.

        /// <summary>
        /// Capture all data from all SaveableEntity and put into XML format
        /// </summary>
        /// <returns>(XmlDocument) Captured data</returns>
        private XmlDocument CaptureState()
        {
            XmlDocument state = new XmlDocument();

            XmlElement root = state.CreateElement("Save");
            root.SetAttribute("Version", versionNum.ToString());
            state.AppendChild(root);

            foreach (SaveableEntity saveable in FindObjectsOfType<SaveableEntity>())
            {
                root.AppendChild(state.ImportNode(saveable.CaptureState().DocumentElement, true));
            }

            return state;
        }

        /// <summary>
        /// Restore all data to SaveableEntity from the XmlDocument
        /// </summary>
        /// <param name="state">Data to restore</param>
        private void RestoreState(XmlDocument state)
        {
            foreach (SaveableEntity saveable in FindObjectsOfType<SaveableEntity>())
            {
                XmlNodeList restoreNodes = state.GetElementsByTagName(saveable.GetUniqueIdentifier());
                if(restoreNodes.Count > 0)
                {
                    XmlDocument tempDoc = new XmlDocument();
                    tempDoc.AppendChild(tempDoc.ImportNode(restoreNodes[0], true));
                    saveable.RestoreState(tempDoc);
                }
            }
        }

As for CaptureState and RestoreState, the workflow here is simular to before.CaptureState is just create a XmlDocument, make a root node, and import from all SaveableEntity and make them as a child node of the root node. As for RestoreState, get the node by tag (this time the tag is the uuid of the SaveableEntity) and pass it as XmlDocument to the SaveableEntity accordingly.

The save file look a bit like this:

<Save Version="1">
  <ad8cb867-921c-41cf-a9ca-60a6d294fe8e>
    <RPG.CharacterControl.SaveableMover>
      <position>(-5.9, 0.0, 4.0)</position>
      <rotation>(0.0, 180.0, 0.0)</rotation>
    </RPG.CharacterControl.SaveableMover>
  </ad8cb867-921c-41cf-a9ca-60a6d294fe8e>
  <4f06a8f0-97dd-46aa-b03a-c4321f1858ed>
    <RPG.CharacterControl.SaveableMover>
      <position>(19.0, 0.2, -14.6)</position>
      <rotation>(0.0, 122.7, 0.0)</rotation>
    </RPG.CharacterControl.SaveableMover>
  </4f06a8f0-97dd-46aa-b03a-c4321f1858ed>
  <Player>
    <RPG.CharacterControl.SaveableMover>
      <position>(30.2, 0.1, -16.5)</position>
      <rotation>(0.0, 57.1, 0.0)</rotation>
    </RPG.CharacterControl.SaveableMover>
  </Player>
</Save>

If you don’t want to save the file in plain text, you may want to look into CryptoStream Class. As I mention, the XmlDocument Save and Load can support Stream type. That said, I am still trying to wrap my head around the CryptoStream and hope if anyone could shed some light on it.

1 Like

Wow, this is impressive, well done!

You may want to look at my updated Json Saving method (which I will be releasing more publicly after I get back from vacation) for some ideas about encryption (the last post in the chain). Both the resulting Json document and your XML document should be able to benefit from the simple encryption schemes I present there.

2 Likes

Thanks Brian.
Also I finally figured out how to use the CryptoStream.

First choose an encryption type and create a provider.

Aes aes = new AesCryptoServiceProvider();

In my case, I used AES encryption.

private void Start()
{
    aes.Key = System.Text.Encoding.UTF8.GetBytes("TjWnZr4u7x!A%C*F-JaNdRgUkXp2s5v8");
    aes.IV = System.Text.Encoding.UTF8.GetBytes("p3s6v9y$B&E)H@Mc");
}

Then give the provider a key and IV. Both have some strict rule on size. In AES case, key can be 128bits, 192bits or 256bit, and IV must be 128bits. Both needed to be a Byte array.

        /// <summary>
        /// Save to file
        /// </summary>
        /// <param name="saveFile">File name</param>
        public void Save(string saveFile)
        {
            string path = GetSavePath(saveFile);
            FileStream fs = null;
            CryptoStream cs = null;
            try
            {
                fs = new FileStream(path, FileMode.Create);
                cs = new CryptoStream(fs, aes.CreateEncryptor(), CryptoStreamMode.Write);
                CaptureState().Save(cs);
            }
            catch(Exception e)
            {
                Debug.LogError($"Error! Save failed: {e}");
            }
            finally
            {
                if(cs != null)
                {
                    cs.Close();
                }
                if(fs != null)
                {
                    fs.Close();
                }
            }
        }

        /// <summary>
        /// Load from file
        /// </summary>
        /// <param name="saveFile">File name</param>
        public void Load(string saveFile)
        {
            XmlDocument xmlDocument = new XmlDocument();
            xmlDocument.PreserveWhitespace = true;

            string path = GetSavePath(saveFile);

            if (!File.Exists(path))
            {
                return;
            }

            FileStream fs = null;
            CryptoStream cs = null;
            try
            {
                fs = new FileStream(path, FileMode.Open);
                cs = new CryptoStream(fs, aes.CreateDecryptor(), CryptoStreamMode.Read);
                xmlDocument.Load(cs);
            }
            catch (Exception e)
            {
                Debug.LogError($"Error! Load failed: {e}");
            }
            finally
            {
                if (cs != null)
                {
                    cs.Close();
                }
                if (fs != null)
                {
                    fs.Close();
                }
            }

            RestoreState(xmlDocument);
        }

As you can see, we need to first create a FileStream. The CrytoStream only encrypt the data to pass to other Stream and cannot write data itself. Create the CrytoStream. Use Encryptor and Write mode to save and Decryptor and Read mode to load.

KŠ6f•°pO=²Æ?B€`~)úgFÇt¾y±¾qÚ¥y'd£L„kbÉ>×È*’½aäw2¤ñ¿SíaZksñ!4JËgˆ¸§²|Ù
tW[xôæHA-„~äÊ×۰г(ÿ‰	6¨ÿ¸amW”ïkPØ’Tu]ª•åud²«¨û8È/€êÔŸ„²GY)"Þ,æYw좗T,‚¤oeËÄü2Z»_8ÓgK%f*<Režc!-‚CaåˇýÖ³\lìS‚$¢ck•e9§úßöÔ~òM>jŒïø€ZvÌI ‘¥N£³s3uáz

Now the save file is unreadable to human.
Obviously I only do it to obfuscated the save data and not truly want to encrypt the data securely.