Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support protobuf serializer for custom types #523

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
115 changes: 115 additions & 0 deletions Example mod/SerializableBehaviourExample.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System;
using BepInEx;
using Nautilus.Assets;
using Nautilus.Assets.PrefabTemplates;
using Nautilus.Handlers;
using ProtoBuf;
using UnityEngine;

namespace Nautilus.Examples;

[BepInPlugin("com.snmodding.nautilus.serializablebehaviour", "Nautilus Serializable Behaviour Example Mod", PluginInfo.PLUGIN_VERSION)]
[BepInDependency("com.snmodding.nautilus")]
internal class SerializableBehaviourExample : BaseUnityPlugin
{
private void Awake()
{
// Don't forget to register your serializable types
ProtobufSerializerHandler.RegisterAllSerializableTypesInAssembly();

var stopwatchSign = new CustomPrefab(PrefabInfo.WithTechType("StopwatchSign"));
var stopwatchSignTemplate = new CloneTemplate(stopwatchSign.Info, TechType.Sign);
stopwatchSignTemplate.ModifyPrefab += go => go.AddComponent<StopwatchSignExample>();
stopwatchSign.SetGameObject(stopwatchSignTemplate);
stopwatchSign.Register();
}
}

[ProtoContract]
internal class RealtimeCounter : MonoBehaviour, IProtoEventListener
{
public DateTimeOffset StartedCounting { get; private set; } = DateTimeOffset.UtcNow;

// If you plan on updating your mod and in particular, updating the serialized MonoBehaviours' fields,
// you will need to update the version number and adapt your data in the OnProtoDeserialize method.
// If you're just making a new serializable MonoBehaviour, set version to 1
// If you don't care about updating your mod, you can just ignore everything concerning the version variable
[ProtobufSerializerHandler.SubnauticaSerialized(1)]
public int version = 2;

// Variable to be serialized
[ProtobufSerializerHandler.SubnauticaSerialized(2)]
public double totalRealtimeMs;

// This is an example of how you can give a default value to your serialized variables
[ProtobufSerializerHandler.SubnauticaSerialized(3)]
public long firstLaunchUnixTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

// Below are two listeners from the optional interface IProtoEventListener:
// OnProtoDeserialize happens after this MonoBehaviour's GameObject full hierarchy is deserialized
public void OnProtoDeserialize(ProtobufSerializer serializer)
{
// This happens when this MonoBehaviour is loaded for the first time after being updated (serialized version was 1 but it should be 2)
// You can find another example in Subnautica's code, like WaterParkCreature.OnProtoDeserialize
if (version == 1)
{
// This has no particular meaning but stands as an example of how you can adapt your data in case of updating your serialized variables
firstLaunchUnixTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - 1000;
}

StartedCounting = DateTimeOffset.UtcNow;
Debug.Log($"Deserialized realtime counter with {totalRealtimeMs}ms [first launch at: {DateTimeOffset.FromUnixTimeMilliseconds(firstLaunchUnixTimeMs)}]");
}

// OnProtoSerialize happens before this MonoBehaviour's GameObject full hierarchy is serialized
public void OnProtoSerialize(ProtobufSerializer serializer)
{
// Ensure the serialized version is the newest one
version = 2;

// Doing some stuff according to your needs
totalRealtimeMs += (DateTimeOffset.UtcNow - StartedCounting).TotalMilliseconds;
StartedCounting = DateTimeOffset.UtcNow;
Debug.Log($"Serialized realtime counter with {totalRealtimeMs}ms [first launch at: {DateTimeOffset.FromUnixTimeMilliseconds(firstLaunchUnixTimeMs)}]");
}
}

[ProtoContract]
internal class StopwatchSignExample : MonoBehaviour, IProtoEventListener
{
[ProtobufSerializerHandler.SubnauticaSerialized(1)]
public int version = 1;

[ProtobufSerializerHandler.SubnauticaSerialized(2)]
public float timePassed;

[ProtobufSerializerHandler.SubnauticaSerialized(3)]
public int serializations;

[ProtobufSerializerHandler.SubnauticaSerialized(4)]
public int deserializations;

private Sign _sign;

private void Start()
{
_sign = GetComponent<Sign>();
}

private void Update()
{
if (_sign) _sign.signInput.inputField.text = timePassed.ToString("#.0") + $"\nSerializations: {serializations}" + $"\nDeserializations: {deserializations}";
timePassed += Time.deltaTime;
}

public void OnProtoDeserialize(ProtobufSerializer serializer)
{
deserializations++;
}

public void OnProtoSerialize(ProtobufSerializer serializer)
{
version = 1;
serializations++;
}
}
268 changes: 268 additions & 0 deletions Nautilus/Handlers/ProtobufSerializerHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using HarmonyLib;
using Nautilus.Patchers;
using Nautilus.Utility;
using ProtoBuf;
using UnityEngine;

namespace Nautilus.Handlers;

/// <summary>
/// A handler class responsible for implementing Protobuf serialization to new custom types.
/// </summary>
public static class ProtobufSerializerHandler
{
internal static Dictionary<Type, SerializerEntry> SerializerEntries;

static ProtobufSerializerHandler()
{
InitializeSerializerEntries(ProtobufSerializerPrecompiledPatcher.serializeMethodInfo);
}

/// <summary>
/// Initialize <see cref="SerializerEntries"/> with Subnautica's known types
/// </summary>
private static void InitializeSerializerEntries(MethodInfo serializeMethodInfo)
{
SerializerEntries = new();

// We loop through the IL code instructions to find the exact match between a typeId and the method it calls
List<KeyValuePair<OpCode, object>> serializeMethodInstructions = PatchProcessor.ReadMethodBody(serializeMethodInfo).ToList();
for (int i = 0; i < serializeMethodInstructions.Count; i++)
{
KeyValuePair<OpCode, object> instruction = serializeMethodInstructions[i];
if (instruction.Key == OpCodes.Call)
{
// The castclass instruction is always 3 steps before the call instruction
Type objectType = (Type) serializeMethodInstructions[i - 3].Value;
// The ldc.i4 instruction is always 2 steps before the call instruction
int associatedTypeId = (int) serializeMethodInstructions[i - 2].Value;
// To get the methodName we remove the 5 first letters and we cut before the opening parenthese
string serializeMethodName = instruction.Value.ToString()[5..].Split('(')[0];
// The deserialize method name will be the same but with Deserialize[...] instead of Serialize[...]
string deserializeMethodName = serializeMethodName.Replace("S", "Des");

SerializerEntries.Add(objectType, new(objectType, associatedTypeId, serializeMethodName, deserializeMethodName));
}
}
}

/// <summary>
/// Registers a type as serializable for Protobuf.
/// </summary>
/// <remarks>
/// This is the only method from <see cref="ProtobufSerializerPrecompiledPatcher"/> which should be used by mods.
/// </remarks>
/// <param name="serializableType">Type of the serializable type</param>
private static SerializerEntry RegisterSerializableType(Type serializableType)
{
ProtobufSerializerPrecompiled.knownTypes.Add(serializableType, 0);

Dictionary<int, FieldInfo> serializedFieldsByTag = GetSerializedFieldsByTag(serializableType);
SerializerEntry serializerEntry = new(serializableType, ((Delegate) Serialize).Method, ((Delegate) Deserialize).Method, serializedFieldsByTag);
SerializerEntries.Add(serializableType, serializerEntry);

InternalLogger.Info($"Registered serializable type {serializableType}");
return serializerEntry;
}

/// <summary>
/// Searches for all types marked with attribute <see cref="ProtoContractAttribute"/> in the executing assembly,
/// and registers them as serializable by <see cref="RegisterSerializableType"/>.
/// </summary>
public static void RegisterAllSerializableTypesInAssembly()
{
List<SerializerEntry> serializerEntries = new();
foreach (Type type in Assembly.GetCallingAssembly().GetTypes())
{
foreach (Attribute attribute in type.GetCustomAttributes())
{
if (attribute is ProtoContractAttribute)
{
serializerEntries.Add(RegisterSerializableType(type));
break;
}
}
}

foreach (SerializerEntry serializerEntry in serializerEntries)
{
// If the type inherits (whatever at which depth) a serializable class, make sure to notice it
Type derivedType = serializerEntry.Type.BaseType;
while (derivedType != null && derivedType != typeof(MonoBehaviour))
{
if (SerializerEntries.TryGetValue(derivedType, out SerializerEntry inheritedSerializerEntry))
{
InternalLogger.Log($"Found derived type for {serializerEntry.Type}: {derivedType}");
serializerEntry.FirstInheritedSerializerEntry = inheritedSerializerEntry;
break;
}
derivedType = derivedType.BaseType;
}
}
}

/// <summary>
/// Automatically serializes a registered type from <see cref="SerializerEntries"/> based on its type <typeparamref name="T"/>.
/// </summary>
/// <remarks>
/// Should only be called by <see cref="ProtobufSerializerPrecompiledPatcher.OptimizedSerializePrefix"/>
/// </remarks>
public static void Serialize(object instance, int objId, ProtoWriter writer)
{
if (SerializerEntries.TryGetValue(instance.GetType(), out SerializerEntry serializerEntry))
{
SerializeEntry(serializerEntry, instance, writer);
}
}

private static void SerializeEntry(SerializerEntry serializerEntry, object instance, ProtoWriter writer)
{
if (serializerEntry.FirstInheritedSerializerEntry is SerializerEntry inheritedEntry)
{
if (inheritedEntry.SerializedFieldsByTag != null)
{
SerializeEntry(inheritedEntry, instance, writer);
}
else
{
inheritedEntry.SerializeInfo.Invoke(writer.model, new object[] { instance, inheritedEntry.TypeId, writer });
}
}

foreach (KeyValuePair<int, FieldInfo> pair in serializerEntry.SerializedFieldsByTag)
{
int tag = pair.Key;
FieldInfo field = pair.Value;

object value = field.GetValue(instance);
writer.model.TrySerializeAuxiliaryType(writer, field.FieldType, DataFormat.Default, tag, value, false);
}
}

/// <summary>
/// Automatically deserializes a registered type from <see cref="SerializerEntries"/> based on its type <typeparamref name="T"/>.
/// </summary>
/// <remarks>
/// Should only be called by <see cref="ProtobufSerializerPrecompiledPatcher.OptimizedDeserializePrefix"/>
/// </remarks>
public static object Deserialize(object instance, ProtoReader reader)
{
if (SerializerEntries.TryGetValue(instance.GetType(), out SerializerEntry serializerEntry))
{
return DeserializeEntry(serializerEntry, instance, reader);
}
return null;
}

private static object DeserializeEntry(SerializerEntry serializerEntry, object instance, ProtoReader reader)
{
if (serializerEntry.FirstInheritedSerializerEntry is SerializerEntry inheritedEntry)
{
if (inheritedEntry.SerializedFieldsByTag != null)
{
DeserializeEntry(inheritedEntry, instance, reader);
}
else
{
inheritedEntry.DeserializeInfo.Invoke(reader.model, new object[] { instance, reader });
}
}

object value = null;
foreach (KeyValuePair<int, FieldInfo> pair in serializerEntry.SerializedFieldsByTag)
{
int tag = pair.Key;
FieldInfo field = pair.Value;
if (reader.model.TryDeserializeAuxiliaryType(reader, DataFormat.Default, tag, field.FieldType, ref value, true, true, false, false))
{
field.SetValue(instance, value);
}
}

return instance;
}

/// <summary>
/// Automatically generates an ordered list of fields to serialize
/// </summary>
/// <param name="serializableType">Serialized type</param>
private static Dictionary<int, FieldInfo> GetSerializedFieldsByTag(Type serializableType)
{
// Extract the fields and to be serialized (marked with SubnauticaSerialized)
FieldInfo[] fields = serializableType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);

Dictionary<int, FieldInfo> serializedFieldsByTag = new();

foreach (FieldInfo field in fields)
{
foreach (Attribute attribute in field.GetCustomAttributes())
{
if (attribute is SubnauticaSerialized subnauticaSerialized)
{
serializedFieldsByTag.Add(subnauticaSerialized.tag, field);
break;
}
}
}
return serializedFieldsByTag;
}

/// <summary>
/// Marks the fields to be detectable by <see cref="GetSerializedFieldsByTag"/>.
/// (Encapsulates <see cref="ProtoMemberAttribute"/>)
/// </summary>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)]
public class SubnauticaSerialized : ProtoMemberAttribute
{
/// <inheritdoc cref="ProtoMemberAttribute(int, bool)" />
public SubnauticaSerialized(int tag, bool forced = false) : base(tag, forced) { }
}

/// <summary>
/// Data structure which holds the method data for serialization and deserialization.
/// </summary>
internal class SerializerEntry
{
internal Type Type;
internal int TypeId;
internal MethodInfo SerializeInfo;
internal MethodInfo DeserializeInfo;
/// <summary>
/// <see cref="SerializerEntry"/> corresponding to the first inherited type which is registered to be serialized.
/// Can be null if there's none.
/// </summary>
internal SerializerEntry FirstInheritedSerializerEntry;
internal IOrderedEnumerable<KeyValuePair<int, FieldInfo>> SerializedFieldsByTag;

/// <summary>
/// Constructor to be commonly used when adding a new serializable type.
/// </summary>
/// <param name="serializeInfo">A static method ran when Subnautica serializes the <see cref="Type"/></param>
/// <param name="deserializeInfo">A static method ran when Subnautica deserializes the <see cref="Type"/></param>
/// <param name="serializedFieldsByTag">A dictionary containing all fields to be serialized, linked to their tag</param>
/// <param name="type">The type to be serializable</param>
public SerializerEntry(Type type, MethodInfo serializeInfo, MethodInfo deserializeInfo, Dictionary<int, FieldInfo> serializedFieldsByTag)
{
Type = type;
SerializeInfo = serializeInfo;
DeserializeInfo = deserializeInfo;
SerializedFieldsByTag = serializedFieldsByTag.OrderBy(entry => entry.Key);
}

/// <summary>
/// Constructor to be used for Subnautica's default known types only.
/// </summary>
public SerializerEntry(Type type, int typeId, string serializeMethodName, string deserializeMethodName)
{
Type = type;
TypeId = typeId;
SerializeInfo = ReflectionHelper.GetInstanceMethod<ProtobufSerializerPrecompiled>(serializeMethodName);
DeserializeInfo = ReflectionHelper.GetInstanceMethod<ProtobufSerializerPrecompiled>(deserializeMethodName);
}
}
}
1 change: 1 addition & 0 deletions Nautilus/Initializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,6 @@ static Initializer()
WaterParkPatcher.Patch(_harmony);
ModMessageSystem.Patch();
BiomePatcher.Patch(_harmony);
ProtobufSerializerPrecompiledPatcher.Patch(_harmony);
}
}
Loading
Loading