This package contains utilities to serialize and deserialize content in a particular byte array representation.
This package is not available in any UPM server. You must install it in your project like this:
-
In Unity, with your project open, open the Package Manager.
-
Either refer this GitHub project: https://github.com/AlephVault/unity-binary.git or clone it locally and refer it from disk.
-
Also, the following packages are dependencies you need to install accordingly (in the same way and also ensuring all the recursive dependencies are satisfied):
The main purpose of this package is to be able to convert primitive or arbitrary types to an array of bits. There are many purpose for this conversion to a byte array (one of them involves sending the data through streams, e.g. network streams) and, ideally, this conversion must be a bidirectional process.
Disclaimer: The main parts of this implementation were taken from the original Unity's NetCode / MLAPI package, so most of the elements will look extremely familiar to developers used to it.
For this, everything starts with choosing whether it's needed to read or write at a given moment. and knowing the AlephVault.Unity.Binary.ISerializable-implementing type to use for this purpose. So let's say we have MyType defined like this:
using AlephVault.Unity.Binary;
class MyType : ISerializable {
public void Serialize(Serializer serializer) {
// We'll dive into this later.
}
}
That object may have members and custom data that it's useful to dump as an array (to persist it or transmit it), so let's read data from this object:
using System.IO;
using AlephVault.Unity.Binary;
Stream someStream = ...get some writable stream...;
// Let's get or create a MyType object.
MyType myObj = new MyType(...);
// Let's make a Write-Serializer.
Serializer serializer = new Serializer(new Writer(stream));
// Serialize the object. It'll write into the stream.
myObj.Serialize(serializer);
And, if we want to write data into the object (this time from a readable stream):
using System.IO;
using AlephVault.Unity.Binary;
Stream someStream = ...get some readable stream...;
// Let's get or create a MyType object, perhaps empty this time.
MyType myObj = new MyType(...);
// Let's make a Read-Serializer.
Serializer serializer = new Serializer(new Reader(stream));
// Serialize the object. It'll read data from the stream and fill its fields.
myObj.Serialize(serializer);
It's important to understand that myObj.Serialize(serializer) is called in both the writing case or the reading case. The implementation will typically work for both cases simultaneously.
Let's say we have this particular structure:
using AlephVault.Unity.Binary;
public class MyStructure : ISerializable {
public int A;
public float B;
public string C;
public void Serialize(Serializer serializer) {
// Both for read and write purposes this will work.
serializer.Serialize(ref A); // First, reads or writes an int (32 bits).
serializer.Serialize(ref B); // Then, reads or writes a float.
serializer.Serialize(ref C); // Finally, reads or write a dynamic-length string.
}
}
Under the hoods, when a MyStructure is written from an input stream, the process will involve these steps in order:
- First, read 4 bytes and make an integer.
- Then, read 4 bytes and make a float.
- Then read a dynamic amount of bytes (following a specific implementation) and make a string.
then filling those values accordingly / given by explicit code calls.
But also when a MyStructure is dumped into an output stream the same order of operations will be preserved with the exact matching sizes:
- First, write 4 bytes from an integer value.
- Then, write 4 bytes from a float value.
- Then, write a dynamic amount of bytes (following a specific implementation) from a string value.
This implementation is the easiest in this case, since primitive values (and others like Unity's Vector3 or Color32) are supported out of the box.
Let's see what happens with more complex cases:
Let's say you have more than primitive objects. Instead, some members are custom classes or structs. How do you serialize them? By being explicit, member by member:
using AlephVault.Unity.Binary;
// Notice how this class is NOT ISerializable.
class Position
{
public int X;
public int Y;
}
// But this class IS serializable.
class BuildingPosition : ISerializable {
public int Story;
public Position Position;
public void Serialize(Serializer serializer) {
serializer.Serialize(ref Story);
// The only thing to ensure, since in this case
// the Position is a class, is that the field is
// never null for this example:
Position ??= new Position();
serializer.Serialize(ref Position.X);
serializer.Serialize(ref Position.Y);
}
}
So the process is essentially the same, so far. The problem is, however, that this approach would be annoying if several ISerializable types had to serialize -in this case- the Position class like this case. Also, this approach only works if all the involved members are fields, and not properties (with get/set accessors).
An alternative to the former case is to ensure the nested class is also Serializable and consistently implements this simultaneous pattern as well. The implementation (now also making Position an ISerializable type) would be like this:
using AlephVault.Unity.Binary;
// Notice how this class is NOT ISerializable.
class Position : ISerializable
{
public int X;
public int Y;
public void Serialize(Serializer serializer) {
serializer.Serialize(ref X);
serializer.Serialize(ref Y);
}
}
// But this class IS serializable.
class BuildingPosition : ISerializable {
public int Story;
public Position Position;
public void Serialize(Serializer serializer) {
serializer.Serialize(ref Story);
serializer.Serialize(ref Position);
}
}
Now, serializer.Serialize(ref Position) works by delegating the serialization via calls to .Serialize(serializer) in nested classes makes things easier for all the implementors. Still, there's a gotcha here: again, this works with standard fields, not properties (with get/set accessors).
Let's say that our class is somewhat different this time: some of the members are properties (having both get/set accessors). How would we deal with them? Using ref will NOT work in this case.
Fortunately, we have the ability to write custom code, knowing whether the context involves reading from or writing into a serializer. Still, we must now guarantee that we're doing the things properly in terms of consistency of reading/writing data, both in order and size.
In this approach, we can still use the ref approach for non-property fields, but we can also use the new approach for these other cases. So let's assume that the Story is a get/set property instead of a field. We'd implement like this:
using AlephVault.Unity.Binary;
// Notice how this class is NOT ISerializable.
class Position : ISerializable
{
public int X;
public int Y;
public void Serialize(Serializer serializer) {
serializer.Serialize(ref X);
serializer.Serialize(ref Y);
}
}
// But this class IS serializable.
class BuildingPosition : ISerializable {
public int Story {
get => ...complex logic...,
set => ...complex logic in terms of `value`...
}
public Position Position;
// For good practice we create a new method to do
// the serialization. This helps to retain order
// of the read/write operations on both cases.
private void SerializeStory(Serializer serializer) {
int story_;
// We detect whether it's reading or writing
// and act accordingly. First, the writing
// case of the data, and then the reading
// case of the data.
if (serializer.IsWriting) {
// Populate a variable from the property,
// when writing.
story_ = Story;
}
// Read or write the variable.
serializer.Serialize(ref story_);
if (serializer.IsReading) {
// Populate the property from the variable,
// when reading.
Story = story_;
}
}
public void Serialize(Serializer serializer) {
SerializeStory(serializer);
// The only thing to ensure, since in this case
// the Position is a class, is that the field is
// never null for this example:
Position ??= new Position();
Position.Serialize(serializer);
}
}
Notice how there's some sort of unconditional code "in the middle" which is always executed. Then it is sandwiched by the write-case first and the read-case later.
Another case could be if the property were, instead, a complex object. Be that object serializable or not, the code would be similar. Let's make another example where also the Position is a get/set property:
using AlephVault.Unity.Binary;
// Notice how this class is NOT ISerializable.
class Position : ISerializable
{
public int X;
public int Y;
public void Serialize(Serializer serializer) {
serializer.Serialize(ref X);
serializer.Serialize(ref Y);
}
}
// But this class IS serializable.
class BuildingPosition : ISerializable {
public int Story {
get => ...complex logic...,
set => ...complex logic in terms of `value`...
}
public Position Position {
get => ...complex logic...,
set => ...complex logic in terms of `value`...
}
// For good practice we create a new method to do
// the serialization. This helps to retain order
// of the read/write operations on both cases.
private void SerializeStory(Serializer serializer) {
int story_;
// We detect whether it's reading or writing
// and act accordingly. First, the writing
// case of the data, and then the reading
// case of the data.
if (serializer.IsWriting) {
// Populate a variable from the property,
// when writing.
story_ = Story;
}
// Read or write the variable.
serializer.Serialize(ref story_);
if (serializer.IsReading) {
// Populate the property from the variable,
// when reading.
Story = story_;
}
}
// We also create a custom method for the position.
private void SerializePosition(Serializer serializer) {
Position position_;
if (serializer.IsWriting) {
// Populate a variable from the property,
// when writing.
position_ = Position ?? new Position();
}
serializer.Serialize(ref position_);
if (serializer.IsReading) {
// Populate the property from the position,
// when reading.
Position = position_;
}
}
public void Serialize(Serializer serializer) {
SerializeStory(serializer);
SerializePosition(serializer);
}
}
There are more complex cases that are possible here. For example, one could read / write objects which can be null. In this case, the writing case might involve:
- If the object is null, write
false. - If not null, instead, write
trueand then serialize the object.
while the reading case might involve:
- Read a boolean.
- If
false, stop and setnullto the member containing an object. - Otherwise, set a new object to the member and read/fill it from the serializer.
When you want to read or write individual data elements, you call:
serializer.Serialize(ref someVariable);
But: what types are supported? These:
boolandbool?.charandchar?. In this case, a second argumentbool packed = trueis allowed. It tells whether the value will be compressed (with a special algorithm) or not.byte,sbyte,byte?andsbyte?.short,ushort,short?andushort?. In these cases, a second argumentbool packed = trueis also allowed with the same meaning as in thecharcase.int,uint,int?anduint?. In these cases, a second argumentbool packed = trueis also allowed with the same meaning as in thecharcase.long,ulong,long?andulong?. In these cases, a second argumentbool packed = trueis also allowed with the same meaning as in thecharcase.float,double,float?anddouble?. In these cases, a second argumentbool packed = trueis also allowed with the same meaning as in thecharcase.string. In these cases, a second argumentbool packed = trueis also allowed with the same meaning as in thecharcase.Color,Color32,Color?andColor32?.Vector2,Vector3,Vector2?andVector3?. In these cases, a second argumentbool packed = trueis also allowed with the same meaning as in thecharcase.Vector4,Quaternion,Vector4?andQuaternion?. In these cases, a second argumentbool packed = trueis also allowed with the same meaning as in thecharcase.Ray2D,Ray,Ray2D?andRay?. In these cases, a second argumentbool packed = trueis also allowed with the same meaning as in thecharcase.- For every
Tamong the previous non-nullable types,T[]is allowed. All the cases wherebool packed = trueis an argument for the override of that type is also allowed in this override for typeT[](e.g. arrays of types other thanbool,byte,sbyte,ColorandColor32accept this second argument). - Any
TorT?variable whereTis anenumof any size. In these cases, a second argumentbool packed = trueis also allowed with the same meaning as in thecharcase.- There's no
T[]override for enum types yet.
- There's no
- Any
ISerializable-implementing type. In this case, it's read/written first the presence of the value (i.e. the value not being null/default) and then, if non-default, it's read/written by invoking the instance's.Serialize(serializer).
Let's think a scenario. What would happen if, instead of serializing a complex object, the user wants to serialize a single, primitive, value? Example: a string.
By default, strings do not implement ISerializable and cannot be encoded like this. However, this package provides a way to workaround for primitive values: Wrapper objects.
They're all located into the AlephVault.Unity.Binary.Wrappers namespace. All of the satisfy these properties:
- They can be directly assigned via implicit cast, e.g.
bool value = myBoolWrapper. - They must be explicitly cast the other way, e.g.
Bool myBoolWrapper = (Bool)value.
Otherwise, they're standard ISerializable objects, fully implementing Serialize.
The available wrapper types, in the said namespace, are:
Boolforbool.Byte,SByte,Charforbyte,sbyteandcharrespectively.- In the same line,
IntandUIntforintanduint. - In the same line,
ShortandUShortforshortandushort. - In the same line,
LongandULongforlongandulong. - There's also for fractional numbers:
FloatandDoubleforfloatanddouble. Color,Color32,Vector2,Vector3,Vector4,Quaternion,RayandRay2Dfor Unity'sColor,Color32,Vector2,Vector3,Vector4,Quaternion,RayandRay2Drespectively.Stringforstring.Enum<T>for a typeTbeing anenum.- There's the ability to create a custom
Wrapper<T>subclass, for any primitive type not included in this list.
If the users want to work with intermediate data, rather than sending the data to a stream directly, users can make use of Buffer instances. Buffers are streams and thus can be directly used when instantiating Reader and Writer objects.
Buffers can be instantiated like this:
using AlephVault.Unity.Binary;
Buffer b1 = new Buffer(); // Default initial capacity and grow factor.
Buffer b2 = new Buffer(5); // Given initial capacity, and default grow factor.
Buffer b3 = new Buffer(1.0); // Default initial capacity, and given grow factor.
Buffer b4 = new Buffer(5, 1.0); // Given initial capacity and grow factor.
Buffer b5 = new Buffer(new byte[100]); // An existing array (in this case, instantiated on the fly). The buffer will NOT grow. This last case is useful if we want to easily retrieve the buffer data, later, as a byte array.