For everything in this article: If you dont understand a certain topic, if you have suggestions for improvements, if you have any questions/inquiries at all, ping Paul#7955 (me) on discord. I'm happy to help
Each API-Surface (excluding Composition) of the SerializationManager has a generic variant, as well as a boxing variant.
Additionally, each has two more generic methods for directly invoking TypeInterfaces, one where you can provide the instance used, and another where you'll just need to provide the type and the manager will take care of fetching an instance to use.
In this section, we will touch on parameters found on all APIs. Therefore, we will not mention them again when discussing the specific APIs.
Due to reference types allowing a null-pointer, but current C#/CIL APIs making it impossible to tell if a generic reference type argument has been annotated as (not)nullable, we have added an override flag called notNullableOverride
in the form of a bool
parameter. Set this parameter to true if you do not want the method to return null values!
Correct usage of this parameter is now enforced by an analyzer. If you need to use it, or if you are using it incorrectly, your IDE should tell you.
Can be used to provide a context instance if you so wish to. See SerializationContext for more info on how to create a context.
All APIs also provide you with a bool
parameter called skipHook
, which can be used to skip invoking methods implemented using the ISerializationHook
interface. Take note however, this parameter is due to be deprecated. This parameter is not available in Write,Validate and Composition-APIs!
When reading, you will have to provide:
instanceProvider
. This will be a delegate that will provide a value to be used to read into. This can be used to for example reuse instances of an object instead of allocating a new one. If this all sounds like gibberish to you, do not worry, you will likely not have to use this at any time coding for our game.The InstanceProvider should never EVER return a null value. This will throw an exception (in debug builds).
For writing, you will, again, have to specify:
alwaysWrite
flag to force for the entire object to be written to yaml. Otherwise, the serializer will omit field values that are equal to the default value specified.Validate is, i would argue, among the more simple APIs we provide. Here, you provide:
Our Copy API is split up into two parts: CopyTo and CreateCopy.
With CopyTo, you will be able to copy values from one object to another. With CreateCopy, you will create a copy of the object you pass into it.
If CopyTo fails to copy into the target object, it will override it using a call to CreateCopy.
Here, composition is pushed across nodes using definitions associated to the type passed. This means that the type you pass determines how the datanodes you provide will be merged together. Currently, there is only a very limited amount of methods to customize this behaviour, especially on DataFields. However, we are working on it!
DataDefinitions are Structs or Classes with Field/Properties annotated to be DataFields. These DataFields are written and read to and from yaml, but are also used for copy, validation & composition operations. Going forward, i will simply refer to structs & classes as a "type".
Data definitions must have a parameterless constructor in order to be valid. (With the exception of DataRecords)
There exists no risk in declaring a DataDefinition with multiple of these options at once. The duplicate registrations will simply be reduced so a single one.
To make a class become a DataDefinition, you can add a [DataDefinition] attribute to the type like so.
[DataDefinition]
public class MyClass {}
[DataDefinition]
public struct MyStruct {}
If you have a base type or an interface of which you want all inheritors to automatically become datadefinitions, you annotate the base type or interface with [ImplicitDataDefinitionForInheritors]. All currently annotated types can be found here, where you will probably find a lot of types/interfaces you've inherited/implemented before.
[ImplicitDataDefinitionForInheritors]
public interface IContainer {}
[ImplicitDataDefinitionForInheritors]
public abstract class BaseType {}
// Container will be a DataDefinition
public class Container : IContainer {}
// SomeStruct will be a DataDefinition
public struct SomeStruct : IContainer {}
// SomeType will be a DataDefinition
public class SomeType : BaseType {}
If you instead have an attribute which you will add to all of your data definitions, add a [MeansDataDefinition] attribute to your own attribute. A prominent example of this is the PrototypeAttribute you've probably seen before:
[MeansDataDefinition]
public class PrototypeAttribute : Attribute {
...
}
// Any class tagged with [Prototype] will automatically become a data definition.
Any property or field or property on a data definition can be annotated with a [DataField] attribute. In the following, both properties and fields will simply be referred to as "field".
This attribute accepts a string key which will be used to define the key in YAML.
[DataField("color")]
protected Color Color { get; set; } = Color.White;
The examples above would translate into YAML like this:
color: White
A DataDefinition gets written into and read from a MappingDataNode. Other than the regular datafield, the Include DataField will not get a value from a key of that MappingDataNode to read/write from/to the field, but will instead use the MappingDataNode of the entire DataDefinition to perform its read/write-operation.
This has specific implications on writing specifically: IncludeDataFields get serialized last, and the produced mapping will be inserted into the mapping of the datadefinition that was already produced. If a key already exists, the new value produced by serializing the IncludeDataField will be ignored.
This behaviour might become configurable in the future.
Don't be confused by the type handlers sometimes being called type serializers. This is an artifact from the old times and will soon be removed/renamed.
A custom type handler can be specified if one doesn't exist by default or custom behavior is needed to serialize a specific type. To use one, pass it through the customTypeSerializer argument.
Both the DataField and IncludeDataField support custom type interfaces, but only the DataFieldAttribute is used in the following examples to make them a tad less bloaty.
This type does NOT need to implement ITypeSerializer. You only need to implement the interfaces you need!
Any other behaviour that wont differ from the normal one does not need to be redefined! If an interface for a specific action does not exist, the normal behaviour will just be used!
[DataField("drawdepth", customTypeSerializer: typeof(ConstantSerializer<DrawDepthTag>))]
private int DrawDepth { get; set; } = DrawDepthTag.Default;
When annotating an int field that represents a constant defined by [ConstantsForAttribute], a custom type serializer must be specified in [DataField]:
/// <summary>
/// Tag type for defining the representation of rendering draw depth in
/// terms of named constants in the content. To understand more about the
/// point of this type, see the <see cref="ConstantsForAttribute"/>.
/// </summary>
public sealed class DrawDepth
{
/// <summary>
/// The default draw depth. The content enum which represents draw depth
/// should respect this value, since it is used in the engine.
/// </summary>
public const int Default = 0;
}
public class SpriteComponent
{
[DataField("drawdepth", customTypeSerializer: typeof(ConstantSerializer<DrawDepthTag>))]
private int DrawDepth { get; set; } = DrawDepthTag.Default;
}
To define int data fields that represent a flag enum annotated with a [FlagsFor] attribute, the process is the same but the serializer used is different.
/// <summary>
/// Tag type for defining the representation of the collision layer bitmask
/// in terms of readable names in the content. To understand more about the
/// point of this type, see the <see cref="FlagsForAttribute"/>.
/// </summary>
public sealed class CollisionLayer {}
public class PhysShapeRect
{
[DataField("layer", customTypeSerializer: typeof(FlagSerializer<CollisionLayer>))]
private int CollisionLayer { get; set; }
}
Two additional attributes may be used on a datafield to define how it is inherited, [AlwaysPushInheritance] and [NeverPushInheritance]. This is again both applicable to the DataField and IncludeDataField
[AlwaysPushInheritance] is used in cases where you want field entry data to be inherited even when mapped, such as the components of an entity prototype.
[NeverPushInheritance] is used to signal that a value in for example a prototype must not be pushed to inheriting prototypes, such as the abstract property.
TODO
Don't be confused by the type handlers sometimes being called type serializers. This is an artifact from the old times and will soon be removed/renamed.
The type handler interfaces are a collection of interfaces for defining custom logic for actions on specific types. Sometimes, the expected node type will also be specified.
A class implementing at least one of these type handler interfaces is referred to as a type handler. If you want your type handler to always be used, you can annotated it with the [TypeSerializer]
attribute. Otherwise, the type can be used as a custom type handler.
The static IoCManager.Resolve should not be used as the serializer might be running on a separate thread without an initialized IoC context.
The type serialization interfaces are structured like this:
You can create a SerializationContext by implementing the ISerializationContext interface on a type. The type will the provide a SerializationProvider which it can use to register typeserializers on.
Currently used by the MapContext during map loading:
https://github.com/space-wizards/RobustToolbox/blob/025fa958549b4d63e4888a810f780c53e6fb89a9/Robust.Shared/Map/MapSerializationContext.cs#L17-L51