An alternate solution to BinaryFormatter in the Saving System [Deprecated]

This version of the Json saving system should be considered deprecated. After some peer review, it was determined that this method of saving and restoring objects is still not quite safe enough. Please see my Updated Tutorial on my tutorial repo on Gitlab.

Some students may have noticed a warning issued by Microsoft regarding the use of BinaryFormatter for data serialization. The long and the short of it is that there are potential security risks associated with using BinaryFormatter. The SavingSystem presented in our RPG series courses relies on BinaryFormatter, and several students have enquired as to whether this security warning applies in our case.

How The Saving System Works
First, a brief overview of how our saving system works.
We start with the SavingSystem, which gathers a list of SaveableEntities. On each GameObject that has data we wish to save, a component called a SaveableEntity is attached. Each SaveableEntity has it’s own unique id (Generally a GUID generated by a Microsoft library that virtually guarantees no duplicates ever in your project). The SaveableEntity finds all the components on the GameObject that have data to save (identified by the component’s name) and requests that component’s state in the form of an object. These components all implement an interface ISaveable

public interface ISaveable
{
    object CaptureState();
    void RestoreState(object state);
}

Each component is responsible for returning an object representing the state. It could be a float, or an int, or a string, or even a List or Dictionary. The only requirement is that all of the contents of the object be tagged as [System.Serializable]
Here’s a simple example in Health.cs

public object CaptureState()
{
     return currentHealth;
}
public void RestoreState(object state)
{
    currentHealth = (float)state;
}

So each SaveableEntity calls CaptureState() on each of the ISaveable components, and stores it in a `Dictionary<string, object>’ which is returned to the SavingSystem.

So to recap:
SavingSystem → SaveableEntity → ISaveable
The top object contains a Dictionary<string, object> which holds all SaveableEntities. Each SaveableEnitty returns another Dictionary<string, object> which is stored in the top structure’s object field. Each ISaveable can return whatever it likes to the SaveableEntity.
Restoring the state just follows the pattern in reverse, distributing each SaveableEntity the Dictionary<string, object> that was returned when saving, , each SaveableEntity returning the object to the respective components where it is converted back into the structure it had originally returned.

When the file is written to and read from disk, we use BinaryFormatter to serialize the object in such a way that it can be stored, and then when we load, we deserialize the object in the same way. It’s a convenient and efficient way to store all of the data which needs to be saved in our system.

The Flaw
The fatal flaw in BinaryFormatter is an obscure one, and by and large unlikely to actually affect us until our save files leave our immediate control.

BinaryFormatter works by storing the information about the class it is serializing along with the data that would populate that class. Sounds simple enough, that’s the whole idea of serialization, to store the data in a manner that will allow it to be deserialized and restored into the original classes. The issue is in BinaryFormatter’s implementation. It’s possible, for example, to include not only the class, but the information needed to create the class if that class is not already in your program. So if the class doesn’t exist, and the tools needed to recreate the class are included in the serialized file, then BinaryFormatter can inject the class into your program. This means that a malicoius hacker could theoretically inject a class capable of doing harm on your system. What’s worse is that we’d never even know that class had been injected, as everything BinaryFormatter does is behind the scenes. It would even initialize the class, potentially running code you never added to your project.

First of all, before you panic, this is very rare, and if the save file never leaves your possession (say a solo game), it would be difficult for hackers to gain access to your save file… But suppose we stored this save file in the cloud. The file could be vulnerable to hackers. Again, it’s rare, but once your game is published, you do not want it to be a security risk.

The Solution
Fortunately, there are other ways to serialize our save files. After some research, and experimenting, I was able to use JSon.Net for Unity to serve as a direct drop in replacement for BinaryFormatter. First, you’ll need to get the free asset JSon.NET for Unity. This is a port of the Newtonsoft JSon library, considered one of the best implementations of JSon out there. Unlike most of the JSon packages I was able to investigate, this one:

  • Handles Dictionaries and Lists
  • Works on iOS
  • Works with IL2CPP (native apps)
  • Will write a binary JSon file (BSon)

There are a few changes we’ll need to make for this to be a viable replacement. Returning a primitive like a float or an int to CaptureState yields strange results. Casting the object in RestoreState to a float or int will fail, so you have to do a double conversion ((double) then (float), or (int64 then int)). Fortunately, I’ve included some convenient Extensions to take care of this.

We’ll start with the replacement script for SavingSystem. Make sure that the JSon Net for Unity package is properly installed from the package manager:

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using UnityEngine;
using UnityEngine.SceneManagement;

namespace GameDevTV.Saving
{
    /// <summary>
    /// This component provides the interface to the saving system. It provides
    /// methods to save and restore a scene.
    ///
    /// This component should be created once and shared between all subsequent scenes.
    /// </summary>
    public class SavingSystem : MonoBehaviour
    {
        /// <summary>
        /// Will load the last scene that was saved and restore the state. This
        /// must be run as a coroutine.
        /// </summary>
        /// <param name="saveFile">The save file to consult for loading.</param>
        public IEnumerator LoadLastScene(string saveFile)
        {
            Dictionary<string, object> state = LoadFile(saveFile);
            int buildIndex = SceneManager.GetActiveScene().buildIndex;
            if (state.ContainsKey("lastSceneBuildIndex"))
            {
                buildIndex = state["lastSceneBuildIndex"].ToInt();
            }
            yield return SceneManager.LoadSceneAsync(buildIndex);
            RestoreState(state);
        }

        /// <summary>
        /// Save the current scene to the provided save file.
        /// </summary>
        public void Save(string saveFile)
        {
            Dictionary<string, object> state = LoadFile(saveFile);
            CaptureState(state);
            SaveFile(saveFile, state);
        }

        /// <summary>
        /// Delete the state in the given save file.
        /// </summary>
        public void Delete(string saveFile)
        {
            File.Delete(GetPathFromSaveFile(saveFile));
        }

        public void Load(string saveFile)
        {
            RestoreState(LoadFile(saveFile));
        }

        // PRIVATE

        private Dictionary<string, object> LoadFile(string saveFile)
        {
            string path = GetPathFromSaveFile(saveFile);
            if (!File.Exists(path))
            {
                return new Dictionary<string, object>();
            }

            using (StreamReader file = File.OpenText(path))
            {
                JsonSerializer serializer = new JsonSerializer();
                serializer.TypeNameHandling = TypeNameHandling.All;
                serializer.Formatting = Formatting.Indented;
                serializer.FloatParseHandling = FloatParseHandling.Double;

                return (Dictionary<string, object>) serializer.Deserialize(file, typeof(Dictionary<string, object>));
            }


            //using (FileStream stream = File.Open(path, FileMode.Open))
            //{
            //    BinaryFormatter formatter = new BinaryFormatter();
            //    return (Dictionary<string, object>)formatter.Deserialize(stream);
            //}
        }

        private void SaveFile(string saveFile, object state)
        {
            string path = GetPathFromSaveFile(saveFile);
            print("Saving to " + path);

            using (StreamWriter file = File.CreateText(path))
            {
                JsonSerializer serializer = new JsonSerializer();
                serializer.TypeNameHandling = TypeNameHandling.All;
                serializer.Formatting = Formatting.Indented;
                serializer.FloatParseHandling = FloatParseHandling.Double;
                serializer.Serialize(file, state);
            }
            //using (FileStream stream = File.Open(path, FileMode.Create))
            //{
            //    BinaryFormatter formatter = new BinaryFormatter();
            //    formatter.Serialize(stream, state);
            //}
        }

        private void CaptureState(Dictionary<string, object> state)
        {
            foreach (SaveableEntity saveable in FindObjectsOfType<SaveableEntity>())
            {
                state[saveable.GetUniqueIdentifier()] = saveable.CaptureState();
            }

            state["lastSceneBuildIndex"] = SceneManager.GetActiveScene().buildIndex;
        }

        private void RestoreState(Dictionary<string, object> state)
        {
            foreach (SaveableEntity saveable in FindObjectsOfType<SaveableEntity>())
            {
                string id = saveable.GetUniqueIdentifier();
                if (state.ContainsKey(id))
                {
                    saveable.RestoreState(state[id]);
                }
            }
        }

        private string GetPathFromSaveFile(string saveFile)
        {
            return Path.Combine(Application.persistentDataPath, saveFile + ".json");
        }
    }

    public static class SavingHelpers
    {
        /// <summary>
        /// Converts an object that has been decoded by JSon.Net For Unity into a float.  Will succeed if the object was deserialized as a double, float, or int64
        /// </summary>
        /// <param name="o">object that has been deserialized by Json.Net For Unity</param>
        /// <returns></returns>
        public static float ToFloat(this object o)
        {
            switch (o)
            {
                case double d: return (float) d;
                case float f: return f;
                case int i: return i;
                case Int64 q: return (float) q;
                default: return 0.0f;
            }
        }
        /// <summary>
        /// Converts an object that has been decoded by JSon.Net For Unity into an int.  Will succeed if the object was deserialized as a double, float, or int64.
        /// Float and double values will be truncated automatically.
        /// </summary>
        /// <param name="o">object that has been deserialized by Json.Net For Unity</param>
        /// <returns></returns>
        public static int ToInt(this object o)
        {
            switch (o)
            {
                case double d: return Mathf.FloorToInt((float) d);
                case float f: return Mathf.FloorToInt(f);
                case int i: return i;
                case Int64 q: return (int) q;
                default: return 0;
            }
        }
        /// <summary>
        /// Returns an object for JSon.Net for Unity to Serialize.  
        /// </summary>
        /// <param name="v"></param>
        /// <returns></returns>
        public static object ToObject(this Vector3 v)
        {
            return new List<float> { v.x, v.y, v.z};
        }
        /// <summary>
        /// Converts and object that was deserialized as a Vector3 by JSon.Net to a bonafide Vector3.
        /// </summary>
        /// <param name="o"></param>
        /// <returns></returns>
        public static Vector3 ToVector3(this object o)
        {
            Vector3 v = Vector3.zero;
            if (o is IList l)
            {
                if (l.Count == 3)
                {
                    v.x = l[0].ToFloat();
                    v.y = l[1].ToFloat();
                    v.z = l[2].ToFloat();
                }
            }
            return v;
        }
    }
}

At the end of the file, I’ve added four extension methods… These methods add themselves to object, and Vector3 to allow some easy conversions… We’ll start with the int and float conversions…
When returning just an int or a float to CaptureState, instead of converting the object in RestoreState with (int) or (float), simply call state.ToInt(); or State.ToFloat(); These extension methods will automatically handle the conversion process.
Example: in Health.cs

public object CaptureState() 
{
     return currentHealth;
}
public void RestoreState(object state)
{
    currentHealth = state.ToFloat();
}

The stranger one is Vector3… previously, we wrapped a Vector3 into a SerializableVector3. For some reason, JSon does not like this helper class, but the extension methods will manage conversion in a different way. We’ll return the Vector3 value.ToObject() and we’ll convert it back with ToVector3(); Here’s the example in Mover.cs:

public void CaptureState()
{
      return transform.position.ToObject();
}
public void RestoreState(object state)
{
      GetComponent<NavMeshAgent>().Warp(state.ToVector3());
}

That’s the long and the short of it. Right now, the resulting save file will be perfectly readable as a standard JSon file. Tomorrow, I’ll post the modifications needed to serialize the file as a BSon file (Binary JSon).

1 Like

This is great. Can this become part of the official course content? I don’t see any reason to support a potentially malicious exploit in the main course.

It’s tricky because all of the course relies on the current method, and fixing this in the repo all the way back to the first course would be a logistical nightmare. What we’ll likely be doing, after this draft of the new system is revised and made a teeny tiny bit clearer, is add a brief lesson to each course explaining the changes need for the system, and providing a UnityPackage with the changed SavingSystem.cs file.

1 Like

Sam has reviewed the code I’ve used here, and has raised the possibility that in it’s current form, it could still be used for an injection attack. After further review, I tend to agree. Fortunately, there is a way to make the JSon save a bit more secure, but it requires quite a bit of refactoring…

We’ll still be using the same JSon{dot]Net for Unity, but we’re going to take advantage of some helper classes within the package, JObject, JToken, and JList.

The changes that need to be made in a refactor look daunting, but mostly they will be time consuming, depending on how far along you are in the courses.

Before you do anything, Commit your project. If something goes horribly wrong, you’ll be able to roll back. Also, as I’ll be writing this over the next few days, if you’re following along in realtime, you might want to wait until the end of this thread to actually make any changes, so that you’re not stuck until I have time to write the next section.

We’re going to start with JToken. JToken is a sort of container class that wraps our return objects… so rather than returning a value to CaptureState that will automatically be converted to an object, and then casting that object back into your data in RestoreState, we’re going to wrap them in a JToken container. As you can see from the link, it’s not super well documented, but there are two methods in particular we are interested in.

JToken.FromObject(object)

https://www.newtonsoft.com/json/help/html/M_Newtonsoft_Json_Linq_JToken_FromObject.htm
This handy Static method will take any serializeable class, struct, or primitive and return a JToken that we can pass to CaptureState. In almost any case that we’ll be dealing with, we’ll be able to simply return JToken.FromObject(your data). For primitive types (int, float, etc), you don’t even need to do this conversion. For example, you can still return currentHealth.value; in Health’s CaptureState and it will make an implicit conversion to JToken.
The next method is how we’ll decode our data

state.ToObject<T>();

https://www.newtonsoft.com/json/help/html/M_Newtonsoft_Json_Linq_JToken_ToObject__1.htm
This method is called on a JToken itself, and will convert the JToken back to the type specified in T. For our afforementioned Health component, RestoreState would look like this:

public void RestoreState(JToken state)
{
    currentHealth.value = state.ToObject<int>();
}

Here’s the really good news. JToken will correctly serialize and deserialize Vector3 values. This means no extra boxing, as it’s all handled in JToken.

Getting Started
Ok, so now that we’ve introduced the important class that will be filling in for object, it’s time to start making changes to our code. We’ll start with the ISaveable interface.

using Newtonsoft.Json.Linq;
namespace GameDevTV.Saving
{
    /// <summary>
    /// Implement in any component that has state to save/restore.
    /// </summary>
    public interface ISaveable
    {
        /// <summary>
        /// Called when saving to capture the state of the component.
        /// </summary>
        /// <returns>
        /// Return a `System.Serializable` object that represents the state of the
        /// component.
        /// </returns>
        JToken CaptureState(); //formerly object CaptureState();

        /// <summary>
        /// Called when restoring the state of a scene.
        /// </summary>
        /// <param name="state">
        /// The same `System.Serializable` object that was returned by
        /// CaptureState when saving.
        /// </param>
        void RestoreState(JToken state); //Formerly void RestoreState(object state);
    }
}

Once you save this, and return to Unity, you will find you have a LOT of errors. Basically every single ISaveable in the game will begin to complain that the ISaveable interface has not been implemented.
You can use these error messages to get you to to the classes that will require conversion from object to JToken.

Let’s get started on the classes from the RPG Core Combat course. We’re not getting too complex in our saving structures here, so it’s a good place to start. You’ve already seen Health.cs, at least in pieces, but I’ll repeat it here:

        public JToken CaptureState()
        {
            return healthPoints.value;
        }

        public void RestoreState(JToken state)
        {
            healthPoints.value = state.ToObject<float>();
            
            if (healthPoints.value <= 0)
            {
                Die();
            }
        }

As you can see, we’re simply returning HealthPoints.value to CaptureState even though we’ve changed the return type to JToken. This is because the library includes an implicit conversion from float to JToken (but not the other way around). That’s why in RestoreState, we have to explicitly tell ToObject that the value should be a float.

Now that we’ve done Health, I’m going to give you a challenge to make the necessary changes to Experience.cs. I’ve put in the code below, but tagged it as a spoiler. Try to make your changes and then compare it to my code.

        public JToken CaptureState()
        {
            return experiencePoints;
        }

        public JToken CaptureState()
        {
            return experiencePoints;
        }

How did that go? As you can see, it’s fairly straightfoward with primitive types. Return the type, use ToObject() to get it back.

Now we’re going to have the same challenge in Fighter.cs. Remember that in Fighter, we’re saving the name of the WeaponConfig.

        public JToken CaptureState()
        {
            return currentWeaponConfig.name;
        }

        public void RestoreState(JToken state)
        {
            string weaponName = state.ToObject<string>();
            WeaponConfig weapon = UnityEngine.Resources.Load<WeaponConfig>(weaponName);
            EquipWeapon(weapon);
        }

I suspect, at this point, that you won’t have had any issues with Fighter, but the code is spoilered above for reference.
Now for the one that’s a bit trickier. In Mover.cs, we save and restore the player’s current position.
It turns out, you can’t just return a Vector3. We actually knew this in the classic save system, and created a new class SerializeableVector3. We can dispense with this SerializableVector3 now, it’s no longer necessary, as JToken already knows how to serialize and deserialize a Vector3.

So no spoiler tags for this one, I’m going to give you the code to put in Mover outright:

        public JToken CaptureState()
        {

            return JToken.FromObject(transform.position);
        }

        public void RestoreState(JToken state)
        {
            navMeshAgent.Warp(state.ToObject<Vector3>());
            GetComponent<ActionScheduler>().CancelCurrentAction();
        }

As you can see, what we’ve done is return a new JToken in CaptureState which is created from serializing Vector3. JToken is also able to deal with Vector2, Quaternion, etc, right out of the box.
In RestoreState, we simply set the player’s position to the stored Vector3.

That takes care of the RPG Core Combat classes… in the next post, I’ll start showing you more complex saving scenarios like Inventory, Equipment, and ActionStore, and we’ll go ahead and make the needed changes in SaveableEntity and the SavingSystem itself.

What to do with SaveableEntity
So SaveableEntity needs to have a few changes, as we’re no longer returning an object from our ISaveables, but instead are saving JTokens. We’re also going to be returning and restoring a JToken in the first place from the SavingSystem.

This JToken will represent a Dictionary<string, JToken>, but we’re going to put it together in a way you might not be familiar with…
We’ll start with creating a JObject that will represent the state we’ll be returning.

JObject state;

A JObject is another helper class from the Newtonsoft library which enables you to easily represent collections, and dictionaries. Since it inherits from JToken, it can be returned by CaptureState, but here’s the real magic:

IDictionary<string, JToken> stateDict = state;

What we’re doing here is creating stateDict as a, IDictionary<string, JToken> (meaning it can be treated exactly like a regular dictionary) and telling it that stateDict is actually state. Any changes we make to stateDict will automatically be reflected in state, but state will still be a JObject which we can return as a JToken.

foreach (ISaveable saveable in GetComponents<ISaveable>())
{
     stateDict[saveable.GetType().ToString()] = saveable.CaptureState();
}

Just like before, we’re populating the Dictionary by calling every ISaveable on the GameObject and putting it into the stateDict array.
finally, the method ends with

return state;

So here’s the whole CaptureState() in it’s entirity:

public JToken CaptureState()
{
     JObject state = new JObject();
     IDictionary<string, JToken> stateDict = state;
     foreach (ISaveable saveable in GetComponents<ISaveable>())
     {
           stateDict[saveable.GetType().ToString()] = saveable.CaptureState();
     }
    return state;
}

Take a look at RestoreState and think about what changes you would need to make on RestoreState(), using the same IDictionary concept we’ve used here. I’ll post the solution once again as a spoiler

public void RestoreState(JToken state)
{
    IDictionary<string, JToken> stateDict = state.ToObject<JObject>();
    foreach(ISaveable saveable in GetComponents<ISaveable>())
    {
          string typeString = saveable.GetType().ToString();
          if(stateDict.ContainsKey(typeString))
          {
               saveable.RestoreState(stateDict[typeString]);
           }
     }
}

As you can see, we really only had to change one line in RestoreState from the original, essentially mapping stateDict to state;

That’s all we need to do for SaveableEntity, which leaves us with the job of making changes to the SavingSystem directly, which we’ll do in the next reply.

That’s great, but what about SavingSystem.cs?

Ok, besides crawling through the remaining classes and fixing the CaptureState and RestoreState, this will probably be the most in depth refactor in the entire conversion. There’s a lot going on here, so let’s dive right into it.

Start by adding these using clauses to the start of SavingSystem:

using Newtonsoft.Json;
using Newtonsoft.Json.Bson; //we won't be using this right away until the change to BSon
using Newtonsoft.Json.Linq;

I’ve pondered the best way to go about hightlighting the changes in the methods. They are all connected in one way or another. I’ve decided I’m going to work from the bottom up… at the end, I’ll post the entire script.

So let’s start where we’ve been working, with CaptureState. In the old SavingSystem, we captured and restored a Dictionary<string, object>, but this time, we’re capturing and restoring a JObject, which in the last post, we learned, can be read as a Dictionary. The reason we passed the Dictionary to CaptureState() is that we want our state to contain all of the data for every scene, not just the scene that we’re in right now.
Like in SaveableEntity, we’re going to use

IDictionary<string, JToken> stateDict = state;

This automatically means that any changes we make to stateDict will be made to the underlying JObject state. There’s no need to return anything, just set the dictionary entries applicable to the scene.

        private void CaptureState(JObject state)
        {
            IDictionary<string, JToken> stateDict = state;
            foreach (SaveableEntity saveable in FindObjectsOfType<SaveableEntity>())
            {
                stateDict[saveable.GetUniqueIdentifier()] = saveable.CaptureState();
            }

            stateDict["lastSceneBuildIndex"] = SceneManager.GetActiveScene().buildIndex;
        }

Simply put, if you’re not super familiar with how the saving system works, we get a collection of each SaveableEntity in the scene, and have the SaveableEntities gather the information from their ISaveable components.
Lastly, at the end, we add the entry for the current scene. We’ll use that later in LoadLastScene()

We don’t need to change much in RestoreState either. We’ll be once again sending a JObject to RestoreState, and mapping an IDictionary overtop of it.

       private void RestoreState(JObject state)
        {
            IDictionary<string, JToken> stateDict = state;
            foreach (SaveableEntity saveable in FindObjectsOfType<SaveableEntity>())
            {
                string id = saveable.GetUniqueIdentifier();
                if (stateDict.ContainsKey(id))
                {
                    saveable.RestoreState(stateDict[id]);
                }
            }
        }

Think of CaptureState() and RestoreState() in the SavingSystem.cs as the adapter from the Saving System to the objects themselves. They are responsible for gathering the data that is being serialized. Next, let’s take a look at the adapter between the Saving System and the file system where we will be storing our save files. This adapter is found in the methods LoadFile and SaveFile

Let’s start with SaveFile. Much of this is similar to what we did in the previous BinaryFormatter system, only we’re going to be using the serializer provided by NewtonSoft.

        private void SaveFile(string saveFile, JObject state)
        {
            string path = GetPathFromSaveFile(saveFile);
            print("Saving to " + path);

            // using (var file = File.Open(path, FileMode.Create))
            // {
            //     using (var writer = new BsonWriter(file))
            //     {
            using (var textWriter = File.CreateText(path))
            {
                using (var writer = new JsonTextWriter(textWriter))
                {
                    writer.Formatting = Formatting.Indented;
                    state.WriteTo(writer);
                }
            }
            //using (FileStream stream = File.Open(path, FileMode.Create))
            //{
            //    BinaryFormatter formatter = new BinaryFormatter();
            //    formatter.Serialize(stream, state);
            //}
        }

You’ll notice a couple of commented sections. The last section, you should recognize from the previous saving system. The first commented section is a fragment of the changes that will be needed to save the game as a BSon file (Binary JSon). We’ll cover that in a later section.
So the important bits here hapen between the commented sections.

We’re starting by creating a Text file with File.CreateText instead of a normal Binary file with File.Open(). JSon is a purely text medium, so this is necessary to properly format the file. By enclosing all of the file writing logic within the using clause, we handle opening and closing the file automatically.

Next, we’re creating a JSonTextWriter writer.
The writer.Formatting = Formatting.Indented; is just letting the formatter know that we want each entry to be on it’s own line and readable. Without this directive, the JSon will all be crammed into one line. (The reader and writer don’t really care if the JSon is formatted pretty or not, they interpret the symbols, not the lines.) In production, you may choose to omit this line.
Finally, we tell the JObject state that was passed to SaveFile to write itself to the JSonTextWriter.

Now let’s take a look at LoadFile(). At this point, we’re going to be reading in a text file using a JSonTextReader.

        private JObject LoadFile(string saveFile)
        {
            string path = GetPathFromSaveFile(saveFile);
            if (!File.Exists(path))
            {
                return new JObject();
            }

            // using (var file = File.OpenRead(path))
            // {
            //     using (var reader = new BsonReader(file))
            //     {
            using (var textReader = File.OpenText(path))
            {
                using (var reader = new JsonTextReader(textReader))
                {
                    reader.FloatParseHandling = FloatParseHandling.Double;

                    return JObject.Load(reader);
                }
            }


            //using (FileStream stream = File.Open(path, FileMode.Open))
            //{
            //    BinaryFormatter formatter = new BinaryFormatter();
            //    return (Dictionary<string, object>)formatter.Deserialize(stream);
            //}
        }

This is very much like our Writer setup. What’s different is that we’re instructing the reader to consider floats to be a Double instead of as a Decimal value. This will save us a lot of conversion headaches along the way.

So we have our adapter into the game, and we have our adapter into the filesystem, so it’s time to put them together, into our public methods.
We’ll start with Save();, as this is the one with changes

public void Save(string saveFile)
{
      JObject state = LoadFile(saveFile);
     CaptureState(state);
     SaveFile(saveFile, state);
}

It’s not a huge change, but we’re getting a JObject from LoadFile instead of a Dictionary<string, object>. Just remember that behind the scenes, state is really an IDictionary<string, JToken> wrapped in a JObject.

Load() has no changes, because all it’s doing is calling RestoreState() with the result from LoadFile()… Nothing in Load() requires us to know the types we’re dealing with.

I saved the best for last, LoadLastScene()
It’s the first two lines here that need changing.

JObject state = LoadFile(saveFile);
IDictionary<string, JToken> stateDict = state;

We’ve seen this before, we’re simply mapping an IDictionary into state, in this case so that we can retrieve the correct lastSceneBuildIndex. Everything else remains the same.

        public IEnumerator LoadLastScene(string saveFile)
        {
            JObject state = LoadFile(saveFile);
            IDictionary<string, JToken> stateDict = state;
            int buildIndex = SceneManager.GetActiveScene().buildIndex;
            if (stateDict.ContainsKey("lastSceneBuildIndex"))
            {
                buildIndex = (int)stateDict["lastSceneBuildIndex"];
            }
            yield return SceneManager.LoadSceneAsync(buildIndex);
            RestoreState(state);
        }

As promised, here’s the complete SavingSystem file

using System.Collections;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Bson;
using Newtonsoft.Json.Linq;
using UnityEngine;
using UnityEngine.SceneManagement;

namespace GameDevTV.Saving
{
    /// <summary>
    /// This component provides the interface to the saving system. It provides
    /// methods to save and restore a scene.
    ///
    /// This component should be created once and shared between all subsequent scenes.
    /// </summary>
    public class SavingSystem : MonoBehaviour
    {
        /// <summary>
        /// Will load the last scene that was saved and restore the state. This
        /// must be run as a coroutine.
        /// </summary>
        /// <param name="saveFile">The save file to consult for loading.</param>
        public IEnumerator LoadLastScene(string saveFile)
        {
            JObject state = LoadFile(saveFile);
            IDictionary<string, JToken> stateDict = state;
            int buildIndex = SceneManager.GetActiveScene().buildIndex;
            if (stateDict.ContainsKey("lastSceneBuildIndex"))
            {
                buildIndex = (int)stateDict["lastSceneBuildIndex"];
            }
            yield return SceneManager.LoadSceneAsync(buildIndex);
            RestoreState(state);
        }

        /// <summary>
        /// Save the current scene to the provided save file.
        /// </summary>
        public void Save(string saveFile)
        {
            JObject state = LoadFile(saveFile);
            CaptureState(state);
            SaveFile(saveFile, state);
        }

        /// <summary>
        /// Delete the state in the given save file.
        /// </summary>
        public void Delete(string saveFile)
        {
            File.Delete(GetPathFromSaveFile(saveFile));
        }

        public void Load(string saveFile)
        {
            RestoreState(LoadFile(saveFile));
        }

        public IEnumerable<string> ListSaves()
        {
            foreach (string path in Directory.EnumerateFiles(Application.persistentDataPath))
            {
                if (Path.GetExtension(path) == ".json")
                {
                    yield return Path.GetFileNameWithoutExtension(path);
                }
            }
        }

        // PRIVATE

        private JObject LoadFile(string saveFile)
        {
            string path = GetPathFromSaveFile(saveFile);
            if (!File.Exists(path))
            {
                return new JObject();
            }

            // using (var file = File.OpenRead(path))
            // {
            //     using (var reader = new BsonReader(file))
            //     {
            using (var textReader = File.OpenText(path))
            {
                using (var reader = new JsonTextReader(textReader))
                {
                    reader.FloatParseHandling = FloatParseHandling.Double;

                    return JObject.Load(reader);
                }
            }


            //using (FileStream stream = File.Open(path, FileMode.Open))
            //{
            //    BinaryFormatter formatter = new BinaryFormatter();
            //    return (Dictionary<string, object>)formatter.Deserialize(stream);
            //}
        }

        private void SaveFile(string saveFile, JObject state)
        {
            string path = GetPathFromSaveFile(saveFile);
            print("Saving to " + path);

            // using (var file = File.Open(path, FileMode.Create))
            // {
            //     using (var writer = new BsonWriter(file))
            //     {
            using (var textWriter = File.CreateText(path))
            {
                using (var writer = new JsonTextWriter(textWriter))
                {
                    writer.Formatting = Formatting.Indented;
                    state.WriteTo(writer);
                }
            }
            //using (FileStream stream = File.Open(path, FileMode.Create))
            //{
            //    BinaryFormatter formatter = new BinaryFormatter();
            //    formatter.Serialize(stream, state);
            //}
        }

        private void CaptureState(JObject state)
        {
            IDictionary<string, JToken> stateDict = state;
            foreach (SaveableEntity saveable in FindObjectsOfType<SaveableEntity>())
            {
                stateDict[saveable.GetUniqueIdentifier()] = saveable.CaptureState();
            }

            stateDict["lastSceneBuildIndex"] = SceneManager.GetActiveScene().buildIndex;
        }

        private void RestoreState(JObject state)
        {
            IDictionary<string, JToken> stateDict = state;
            foreach (SaveableEntity saveable in FindObjectsOfType<SaveableEntity>())
            {
                string id = saveable.GetUniqueIdentifier();
                if (stateDict.ContainsKey(id))
                {
                    saveable.RestoreState(stateDict[id]);
                }
            }
        }

        private string GetPathFromSaveFile(string saveFile)
        {
            return Path.Combine(Application.persistentDataPath, saveFile + ".json");
        }
    }
}
1 Like