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).