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
Prev Previous commit
Next Next commit
Feat: Simplify a lot serializable type registering
  • Loading branch information
tornac1234 committed Feb 28, 2024
commit 0b97c7da9fe1f5ead069334967d0a769d0971715
76 changes: 2 additions & 74 deletions Example mod/SerializableBehaviourExample.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,10 @@ namespace Nautilus.Examples;
[BepInDependency("com.snmodding.nautilus")]
internal class SerializableBehaviourExample : BaseUnityPlugin
{
// You need to store the generated methods so they can be used in your serialize and deserialize static methods
public static Action<RealtimeCounter, int, ProtoWriter> realtimeCounterSerializeMethodInfo;
public static Func<RealtimeCounter, ProtoReader, RealtimeCounter> realtimeCounterDeserializeMethodInfo;
public static Action<StopwatchSignExample, int, ProtoWriter> stopwatchSignExampleSerializeMethodInfo;
public static Func<StopwatchSignExample, ProtoReader, StopwatchSignExample> stopwatchSignExampleDeserializeMethodInfo;

private void Awake()
{
// We first generate automatically the serialize and deserialize methods for our serializable type
var realtimeCounterMethodInfos = ProtobufSerializerHandler.GenerateSerializeAndDeserializeMethods<RealtimeCounter>();
// And we store them somewhere easily accessible
realtimeCounterSerializeMethodInfo = realtimeCounterMethodInfos.Item1;
realtimeCounterDeserializeMethodInfo = realtimeCounterMethodInfos.Item2;

// Do it for the stopwatch
var stopwatchSignMethodInfos = ProtobufSerializerHandler.GenerateSerializeAndDeserializeMethods<StopwatchSignExample>();
stopwatchSignExampleSerializeMethodInfo = stopwatchSignMethodInfos.Item1;
stopwatchSignExampleDeserializeMethodInfo = stopwatchSignMethodInfos.Item2;

// Don't forget to register your serializable type with the right serialize and deserialize method (those which you created manually)
// You can pick whatever type ID (e.g. 3141592) you want as long it's not used by another mod or by Subnautica itself
ProtobufSerializerHandler.RegisterSerializableType<RealtimeCounter>(3141592, RealtimeCounter.Serialize, RealtimeCounter.Deserialize);
ProtobufSerializerHandler.RegisterSerializableType<StopwatchSignExample>(3141593, StopwatchSignExample.Serialize, StopwatchSignExample.Deserialize);
// 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);
Expand Down Expand Up @@ -91,31 +72,6 @@ public void OnProtoSerialize(ProtobufSerializer serializer)
StartedCounting = DateTimeOffset.UtcNow;
Debug.Log($"Serialized realtime counter with {totalRealtimeMs}ms [first launch at: {DateTimeOffset.FromUnixTimeMilliseconds(firstLaunchUnixTimeMs)}]");
}

// Below are the required static methods for Protobuf serializer to serialize and deserialize this MonoBehaviour
// You can find more examples in ProtobufSerializerPrecompiled just like Serialize11492366 and Deserialize11492366
// Serialize must be a "static void" with parameters (YourType, int, ProtoWriter)
public static void Serialize(RealtimeCounter realtimeCounter, int objTypeId, ProtoWriter writer)
{
// If you only have basic types you only need to use the generated methods but you may need to adapt this with more complex types
// In that situation, please pick the right WireType for your variables and adapt the following Write[Object] call

// In this case, we only have basic types to serialize so we just invoke the generated method
SerializableBehaviourExample.realtimeCounterSerializeMethodInfo.Invoke(realtimeCounter, objTypeId, writer);
}

// Deserialize must be a "static YourType" with parameters (YourType, ProtoReader)
public static RealtimeCounter Deserialize(RealtimeCounter realtimeCounter, ProtoReader reader)
{
// Here you need to use the methods Read[Object] accordingly to what you wrote in Serialize: Write[Object]
// Also the different cases must be using the same field numbers as defined in Serialize

// In this case, we only have basic types to serialize so we just invoke the generated method
realtimeCounter = SerializableBehaviourExample.realtimeCounterDeserializeMethodInfo.Invoke(realtimeCounter, reader);

// Return the same object you've been modifying
return realtimeCounter;
}
}

[ProtoContract]
Expand Down Expand Up @@ -146,42 +102,14 @@ private void Update()
timePassed += Time.deltaTime;
}

// 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)
{
deserializations++;
}

// OnProtoSerialize happens before this MonoBehaviour's GameObject full hierarchy is serialized
public void OnProtoSerialize(ProtobufSerializer serializer)
{
version = 1;
serializations++;
}

// Below are the required static methods for Protobuf serializer to serialize and deserialize this MonoBehaviour
// You can find more examples in ProtobufSerializerPrecompiled just like Serialize11492366 and Deserialize11492366
// Serialize must be a "static void" with parameters (YourType, int, ProtoWriter)
public static void Serialize(StopwatchSignExample stopwatchSignExample, int objTypeId, ProtoWriter writer)
{
// If you only have basic types you only need to use the generated methods but you may need to adapt this with more complex types
// In that situation, please pick the right WireType for your variables and adapt the following Write[Object] call

// In this case, we only have basic types to serialize so we just invoke the generated method
SerializableBehaviourExample.stopwatchSignExampleSerializeMethodInfo.Invoke(stopwatchSignExample, objTypeId, writer);
}

// Deserialize must be a "static YourType" with parameters (YourType, ProtoReader)
public static StopwatchSignExample Deserialize(StopwatchSignExample stopwatchSignExample, ProtoReader reader)
{
// Here you need to use the methods Read[Object] accordingly to what you wrote in Serialize: Write[Object]
// Also the different cases must be using the same field numbers as defined in Serialize

// In this case, we only have basic types to serialize so we just invoke the generated method
stopwatchSignExample = SerializableBehaviourExample.stopwatchSignExampleDeserializeMethodInfo.Invoke(stopwatchSignExample, reader);

// Return the same object you've been modifying
return stopwatchSignExample;
}
}
181 changes: 90 additions & 91 deletions Nautilus/Handlers/ProtobufSerializerHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace Nautilus.Handlers;
/// </summary>
public static class ProtobufSerializerHandler
{
internal static Dictionary<int, SerializerEntry> SerializerEntries;
internal static Dictionary<Type, SerializerEntry> SerializerEntries;

static ProtobufSerializerHandler()
{
Expand All @@ -38,14 +38,12 @@ private static void InitializeSerializerEntries(MethodInfo serializeMethodInfo)
{
// 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(associatedTypeId, new(objectType, serializeMethodName, deserializeMethodName));
SerializerEntries.Add(objectType, new(objectType, serializeMethodName, deserializeMethodName));
}
}
}
Expand All @@ -56,89 +54,113 @@ private static void InitializeSerializerEntries(MethodInfo serializeMethodInfo)
/// <remarks>
/// This is the only method from <see cref="ProtobufSerializerPrecompiledPatcher"/> which should be used by mods.
/// </remarks>
/// <typeparam name="T">Type of the serializable type</typeparam>
/// <param name="typeId">
/// An arbitrary unique type id which shouldn't be already used by Subnautica nor by any other mod.
/// Look through <see cref="ProtobufSerializerPrecompiled"/> to ensure the id is not already taken.
/// </param>
/// <param name="serializeMethod">
/// A reference to the serialize method.
/// NB: Take inspiration from <see cref="ProtobufSerializerPrecompiled.Serialize11492366"/> to understand how make one.
/// </param>
/// <param name="deserializeMethod">
/// A reference to the serialize method.
/// NB: Take inspiration from <see cref="ProtobufSerializerPrecompiled.Deserialize11492366"/> to understand how make one.
/// </param>
public static void RegisterSerializableType<T>(int typeId, Action<T, int, ProtoWriter> serializeMethod, Func<T, ProtoReader, T> deserializeMethod)
/// <param name="serializableType">Type of the serializable type</param>
public static void RegisterSerializableType(Type serializableType)
{
if (SerializerEntries.ContainsKey(typeId))
{
throw new DuplicateTypeIdException(typeId, typeof(T));
}
ProtobufSerializerPrecompiled.knownTypes.Add(typeof(T), typeId);
SerializerEntries.Add(typeId, new(serializeMethod.Method, deserializeMethod.Method, typeof(T)));
ProtobufSerializerPrecompiled.knownTypes.Add(serializableType, 0);

Dictionary<int, FieldInfo> serializedFieldsByTag = GetSerializedFieldsByTag(serializableType);

SerializerEntries.Add(serializableType, new(serializableType, ((Delegate) Serialize).Method, ((Delegate) Deserialize).Method, serializedFieldsByTag));
InternalLogger.Log($"Registered serializable type {serializableType}");
}

/// <summary>
/// Automatically generates a serialize method and a deserialize method to be used in .
/// Searches for all types marked with attribute <see cref="ProtoContractAttribute"/> in the executing assembly,
/// and registers them as serializable by <see cref="RegisterSerializableType"/>.
/// </summary>
/// <typeparam name="T">Serialized type</typeparam>
public static (Action<T, int, ProtoWriter>, Func<T, ProtoReader, T>) GenerateSerializeAndDeserializeMethods<T>() where T : new()
public static void RegisterAllSerializableTypesInAssembly()
{
// Extract the fields and to be serialized (marked with SubnauticaSerialized)
Type type = typeof(T);
FieldInfo[] fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);

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

foreach (FieldInfo field in fields)
foreach (Type type in Assembly.GetCallingAssembly().GetTypes())
{
foreach (Attribute attribute in field.GetCustomAttributes())
foreach (Attribute attribute in type.GetCustomAttributes())
{
if (attribute is SubnauticaSerialized subnauticaSerialized)
if (attribute is ProtoContractAttribute)
{
serializedFieldsByTag.Add(subnauticaSerialized.tag, field);
RegisterSerializableType(type);
break;
}
}
}
}

// Generate serialized and deserialize methods using the default serializing and deserializing utilities
// provided by the internal methods TrySerializeAuxiliaryType and TryDeserializeAuxiliaryType from TypeModel
Action<T, int, ProtoWriter> serialize = new((instance, objectId, writer) =>
/// <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))
{
foreach (KeyValuePair<int, FieldInfo> pair in serializedFieldsByTag.OrderBy(entry => entry.Key))
{
int tag = pair.Key;
FieldInfo field = pair.Value;
return;
}

object value = field.GetValue(instance);
writer.model.TrySerializeAuxiliaryType(writer, field.FieldType, DataFormat.Default, tag, value, false);
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 default;
}

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;
}

Func<T, ProtoReader, T> deserialize = new((instance, reader) =>
/// <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);

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

foreach (FieldInfo field in fields)
{
object value = null;
foreach (KeyValuePair<int, FieldInfo> pair in serializedFieldsByTag.OrderBy(entry => entry.Key))
foreach (Attribute attribute in field.GetCustomAttributes())
{
int tag = pair.Key;
FieldInfo field = pair.Value;
if (reader.model.TryDeserializeAuxiliaryType(reader, DataFormat.Default, tag, field.FieldType, ref value, true, true, false, false))
if (attribute is SubnauticaSerialized subnauticaSerialized)
{
field.SetValue(instance, value);
serializedFieldsByTag.Add(subnauticaSerialized.tag, field);
break;
}
}

return instance;
});


return (serialize, deserialize);
}
return serializedFieldsByTag;
}

/// <summary>
/// Marks the fields to be detectable by <see cref="GenerateSerializeAndDeserializeMethods{T}"/>.
/// Marks the fields to be detectable by <see cref="GetSerializedFieldsByTag"/>.
/// (Encapsulates <see cref="ProtoMemberAttribute"/>)
/// </summary>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)]
Expand All @@ -153,57 +175,34 @@ public SubnauticaSerialized(int tag, bool forced = false) : base(tag, forced) {
/// </summary>
internal class SerializerEntry
{
internal Type Type;
internal MethodInfo SerializeInfo;
internal MethodInfo DeserializeInfo;
internal Type Type;
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(MethodInfo serializeInfo, MethodInfo deserializeInfo, Type type)
public SerializerEntry(Type type, MethodInfo serializeInfo, MethodInfo deserializeInfo, Dictionary<int, FieldInfo> serializedFieldsByTag)
{
if (!serializeInfo.IsStatic)
{
throw new ArgumentException($"The provided serialize method '{serializeInfo.Name}' should be static for type '{type}'");
}
if (!deserializeInfo.IsStatic)
{
throw new ArgumentException($"The provided deserialize method '{deserializeInfo.Name}' should be static for type '{type}'");
}

Type = type;
SerializeInfo = serializeInfo;
DeserializeInfo = deserializeInfo;
Type = type;
SerializedFieldsByTag = serializedFieldsByTag.OrderBy(entry => entry.Key);
}

/// <summary>
/// Constructor to be used for Subnautica's default known types only.
/// </summary>
public SerializerEntry(Type type, string serializeMethodName, string deserializeMethodName)
{
Type = type;
SerializeInfo = ReflectionHelper.GetInstanceMethod<ProtobufSerializerPrecompiled>(serializeMethodName);
DeserializeInfo = ReflectionHelper.GetInstanceMethod<ProtobufSerializerPrecompiled>(deserializeMethodName);
Type = type;
}
}

/// <summary>
/// The exception that is thrown when a <see cref="SerializerEntry"/> is attempted to be added when an existing one of the same type already exists.
/// </summary>
public class DuplicateTypeIdException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="DuplicateTypeIdException"/> class with default properties.
/// </summary>
/// <param name="typeId">Type id of the already registered serializer entry.</param>
/// <param name="type">Type of the requested</param>
public DuplicateTypeIdException(int typeId, Type type) : base
($"Cannot register serializable type '{type}' with id '{typeId}' because it is already used by either Subnautica or another instance.")
{

}
}
}
Loading
Loading