JSON serialization with a type registry, custom serializers, reference tracking, and schema migration – the layer that turns runtime game state into save files that survive format changes between patches.
Summary
Scylla’s serialization utilities transform game state to and from serialized data that can be stored on disk or sent over a network. Hand them an object and they produce either JSON, which is human-readable and easy to inspect and diff, or a compact binary blob that is smaller and faster and meant to be compressed and encrypted rather than read by hand. Hand the result back later and the original object is rebuilt. Unity’s own types, nested objects, collections, and project-specific classes all travel through the same path.
The part that earns its keep is what happens between patches. A save file written by version 1.0 of a game has to keep loading after the type it describes has gained fields, lost others, and renamed the rest. Each serializable type carries a schema version, and small upgrade steps carry old data forward one version at a time, so a save from three patches ago loads into today’s types without the player noticing. The names used on disk are decoupled from the C# identifiers, so renaming a field in code does not invalidate existing saves. The serializer also covers the cases that quietly break hand-rolled and third-party solutions alike: object graphs that reference each other in cycles are written once instead of looping forever, a mixed collection of a base type comes back with each element’s real subclass intact, and any type whose default treatment is wrong can be given a dedicated serializer that controls its exact shape.
Two things make it something a project sets up once and stops thinking about. Failures are returned rather than thrown, so a corrupt or outdated save is a branch in the calling code rather than a crash that reaches the player. And the system avoids the runtime code generation that breaks ahead-of-time compilation, so the same call produces the same result on every Unity target, from desktop down to IL2CPP mobile. One call shape, one error model, one migration chain, everywhere.
Use the serialization utilities for:
- Save game files and per-session snapshots, especially when combined with Compression and Crypto for opaque save data.
- Configuration files that round-trip cleanly between a JSON representation on disk and a typed structure in memory (see Config Files for the higher-level config layer).
- Inter-process or networked data exchange where JSON is the wire format and both ends need typed deserialization.
- Schema-versioned content that must keep loading correctly as the type structure evolves over patches.
- Polymorphic collections where the concrete type at each slot is not known at compile time (a
List<IItem>where the items are different subclasses). - Custom binary formats for performance-critical paths, via direct low-level reader and writer access.
Features
ScyllaSerializationfacade. One static class withToJSON<T>,FromJSON<T>,ToBytes<T>, andFromBytes<T>covers every typical entry point. Settings-less convenience overloads for the simple cases; settings-driven overloads for everything else.SerializationResult<T>no-throw contract. Every operation returns a result struct withIsSuccess/IsFailure, anErrorMessage, and an optionalException. Reading.Valueon a failure throws, so you choose the strict-vs-defensive shape.TryGetValue(out T)andGetValueOrDefault(d)cover the rest.- Three settings presets plus fluent builder.
SerializationSettings.Default,ReadableJSON(indented, comments allowed),CompactBinary(no whitespace, references on);CreateBuilder()andToBuilder()for custom instances. Settings are immutable afterBuild. - Five attribute-driven configuration markers.
[ScyllaSerializable(version)],[ScyllaProperty(Name, Order, Required)],[ScyllaIgnore],[ScyllaInclude],[ScyllaMigration(from, to)]. Cover every type-level customization without per-property serializer code. - Schema versioning with migration chains.
[ScyllaSerializable(version: N)]writes a$versionfield;[ScyllaMigration(fromVersion, toVersion)]marks static migration methods that the engine chains automatically (v1 -> v2 -> v3 -> … -> current). Save data keeps loading correctly as the type structure evolves. - Reference tracking for cycles and shared subgraphs.
ReferenceHandlingcontrols whether duplicate references are written once with$idand recovered with$ref. Parents that point at the same child serialize the child once; circular references stop infinite recursion. - Polymorphism via type aliases.
[ScyllaSerializable(TypeAlias = "...")]provides a short, refactor-stable$typediscriminator. AList<IItem>round-trips with each element’s concrete type intact; the alias survives type renames in the source. - Custom type serializers.
ITypeSerializer<T>/TypeSerializerBase<T>give full control over how a specific type round-trips bytes. Register globally viaScyllaSerialization.RegisterSerializer<T>or per-call via the settings builder. - Two-format output. JSON for human-readable on-disk and over-the-wire use; a compact binary format (
SerializationFormat.Binary) for performance-critical paths. Same call shape; pick the format throughSerializationSettings.Format. NullHandlingandDefaultValueHandling.Include,Ignore, andSkipmodes let you decide whether default values, nulls, and empty collections survive the round trip. Smaller output for “ignore defaults” cases; bit-perfect round trips for “include everything” cases.- Pre-warmable metadata cache.
TypeMetadataCache.PreWarm(types)removes the first-use reflection cost from a hot path. Save game writes can warm the cache during a loading screen; subsequent saves never pay the per-type reflection. - Low-level reader and writer.
IScyllaReader/IScyllaWriterwithScyllaJSONReaderandScyllaJSONWriterimplementations expose a token-stream model. Reach for these when your project needs a custom on-disk format that mostly looks like JSON but adds project-specific tokens. - IL2CPP-friendly. No
System.Reflection.Emit. The reflection serializer uses straightforward reflection that AOT compilers preserve. Works on every Unity build target withoutlink.xmlgymnastics.
Components
The public surface splits across a single facade, two result struct types, one settings class, three subsystem layers (serializers, readers/writers, metadata), and five attributes. Knowing what each one is for keeps the recipe sections short and predictable.
A few terms appear repeatedly. Schema version is an integer on [ScyllaSerializable] that the serializer writes into the output as a $version field. When the version on a stored blob is older than the type’s current version, the engine runs migration methods to upgrade the data. Reference tracking is the bookkeeping the engine does to detect cycles and shared subgraphs; without it, two parents pointing at the same child would serialize the child twice, and a parent pointing at itself would recurse infinitely. Type alias is a short identifier used as the $type discriminator for polymorphic serialization; without an alias the engine writes the full assembly-qualified name, which is verbose and brittle across refactors.
| Component | Role | Notes |
|---|---|---|
ScyllaSerialization | Static facade for the four core operations | ToJSON<T>, FromJSON<T>, ToBytes<T>, FromBytes<T>. Each has a settings-less convenience overload and a settings-driven overload. RegisterSerializer<T> and RegisterMigration<T> plug into the global registries. |
SerializationResult / SerializationResult<T> | No-throw result of every operation | IsSuccess / IsFailure, Value (only on success), ErrorMessage, Exception. GetValueOrDefault(default) and TryGetValue(out T) for safe access. Reading Value on a failed result throws InvalidOperationException. |
SerializationSettings | Immutable configuration object built via a fluent Builder | Three static presets (Default, ReadableJSON, CompactBinary); CreateBuilder() for custom instances; ToBuilder() to derive a variant from an existing settings instance. Properties are read-only after Build(). |
[ScyllaSerializable] | Type-level marker with schema version and optional type alias | Constructor version = 1 defaults to v1. TypeAlias (settable property) provides the short $type discriminator for polymorphism. Required for migration; useful but not strictly required for plain serialization (reflection picks up unmarked types). |
[ScyllaProperty] | Property/field marker for name, order, and required-ness | Name overrides the JSON key; Order controls serialization order (default int.MaxValue); Required = true causes deserialization to fail when the field is absent in the input. |
[ScyllaIgnore] | Skip this member during serialization | Useful for cached values, computed properties, and runtime-only state. Marked members are not written and are not read. |
[ScyllaInclude] | Opt a private member into serialization | By default the reflection serializer picks up only public properties and fields; this attribute lets a private member participate without changing its access level. Pair with IncludePrivateFields on settings. |
[ScyllaMigration] | Static method marker for one migration step | Constructor takes fromVersion and toVersion. The method signature is static T MigrateXtoY(T oldValue). The engine walks the migration graph from the stored version to the current version, running every method in order. |
ITypeSerializer<T> / TypeSerializerBase<T> | Interface and base class for custom serializers | Override Write and Read to take full control of a type’s binary or JSON shape. The reflection serializer is the default; the custom serializer overrides it. Register globally or per SerializationSettings. |
IScyllaReader / IScyllaWriter | Typed primitive read/write surface | One method per primitive type plus WritePropertyName, WriteStartObject/Array, ReadStartObject/Array, PeekToken. Use directly inside custom serializers to control the exact output shape. |
ScyllaJSONReader / ScyllaJSONWriter | Concrete JSON reader and writer over a string or StringBuilder | Implement the interfaces. JSON-specific concerns (escaping, BOM, whitespace) are handled internally. Construct directly only when writing custom serializers or hand-rolling output; the facade does this for normal calls. |
TypeMetadataCache | Static cache of reflection results, plus type alias registry | First call against a type runs Type.GetProperties/GetFields/GetCustomAttributes once and caches the result. PreWarm(types) removes the first-use cost from a hot path. Clear() is useful in test setups. |
ReferenceTracker | Per-call bookkeeping for cycle and shared-subgraph handling | Owned internally by SerializationContext; constructed with a ReferenceHandling value (Error, Preserve, Ignore). Direct construction is rarely needed; the facade and the engine wire it up. |
SerializationException | Thrown by readers and writers on malformed input or invalid state | Surfaces from the low-level reader/writer layer; the facade’s Try* and From*/To* methods catch and convert to SerializationResult.Failure. Direct reader/writer usage bubbles this up to the caller. |
Recipes
These recipes cover every public method on the facade, every attribute, every settings option, every result type, and every subsystem entry point. Each recipe is self-contained – scan the names, find the one that matches what you’re building, and copy the solution. If you’re new to the API, start with the first recipe: it establishes the SerializationResult<T> pattern and the basic round-trip shape that the rest of the recipes build on.
Serialize and deserialize game state
Problem. You want to save your player’s progress to disk and load it back. This is the most common path: turn a C# object into JSON, write that JSON somewhere, read it back later, and reconstruct the object. You need a pattern that does not crash on a failed load and gives you a useful error when something goes wrong.
Solution. Every operation flows through ScyllaSerialization and returns a SerializationResult<T>. The result never throws on failure; check IsSuccess (or IsFailure) and read Value only when the call succeeded. GetValueOrDefault and TryGetValue are the safe alternatives when you want to handle failure inline.
using Scylla.Core.Util.Serialization;
/* The simplest serializable shape: a plain class with public properties.
* The reflection serializer picks up public properties and fields automatically;
* no attribute is strictly required for vanilla types. */
public sealed class PlayerSave
{
public string Name { get; set; }
public int Level { get; set; }
public float HP { get; set; }
}
var player = new PlayerSave { Name = "Hero", Level = 12, HP = 75.5f };
/* Default serialization: returns SerializationResult<string>. */
SerializationResult<string> jsonResult = ScyllaSerialization.ToJSON(player);
if (jsonResult.IsSuccess)
{
string json = jsonResult.Value;
Log.Info(json, LogCategory.Core);
}
else
{
Log.Warning($"Serialization failed: {jsonResult.ErrorMessage}", LogCategory.Core);
}
/* Round-trip: serialize then deserialize. */
SerializationResult<PlayerSave> loadResult = ScyllaSerialization.FromJSON<PlayerSave>(jsonResult.Value);
if (loadResult.TryGetValue(out PlayerSave loaded))
{
Log.Info($"Loaded {loaded.Name} at level {loaded.Level}", LogCategory.Core);
}
/* GetValueOrDefault: returns a fallback when the result failed. Useful when
* the absence of valid data has a sensible default (first-run config, etc.). */
PlayerSave safe = loadResult.GetValueOrDefault(new PlayerSave { Name = "Player", Level = 1 });
/* IsFailure is the logical complement of IsSuccess. Either form is acceptable. */
if (loadResult.IsFailure)
{
/* result.Exception may carry an inner exception when the failure originated
* from a thrown exception inside the engine. */
Exception inner = loadResult.Exception;
}
/* Convert a SerializationResult<T> to a non-generic SerializationResult by
* discarding the value. Useful when only the success/failure status matters. */
SerializationResult bareResult = loadResult.ToResult();
Lock a save format with attributes
Problem. Anyone who has shipped a save system has eventually wanted explicit control over field names, ignored properties, private members, and schema versions. Your PlayerSave class works fine today, but by patch 1.1 you’ve renamed a field, added private state you want persisted, and need to skip a computed cache property. Without locked JSON keys, a rename silently breaks every existing save.
Solution. Five attributes cover the common cases. None of them are required for trivial types; all of them become important as soon as schema stability matters.
using Scylla.Core.Util.Serialization;
using Scylla.Core.Util.Serialization.Attributes;
/* Mark the type with a schema version. Required for migration; recommended
* from day one even when the version is 1 because retrofitting a version
* scheme after the fact is harder than starting with one. The optional
* TypeAlias is the short discriminator used by polymorphic serialization. */
[ScyllaSerializable(version: 2, TypeAlias = "PlayerV2")]
public sealed class PlayerSave
{
/* Override the serialized property name. Use this when the C# identifier
* is verbose or follows a different convention than the JSON shape. */
[ScyllaProperty(Name = "name", Order = 1)]
public string DisplayName { get; set; }
/* Order controls write/read order. Default is int.MaxValue, so properties
* without an explicit order appear after those with one. */
[ScyllaProperty(Name = "level", Order = 2)]
public int Level { get; set; }
/* Required = true causes deserialization to fail when the field is missing
* in the input. Useful for "must exist" fields where a default would be
* dangerous (encryption keys, version sentinels). */
[ScyllaProperty(Name = "hp", Order = 3, Required = true)]
public float HP { get; set; }
/* Skip this property entirely. The serializer does not write it, the
* deserializer does not look for it. Use for cached or computed state
* that should be recomputed on load. */
[ScyllaIgnore]
public float MaxHPCached { get; set; }
/* Opt a private member into serialization. By default the reflection
* serializer picks up only public properties and public fields; this
* attribute lets a private member participate without changing the
* access modifier. */
[ScyllaInclude]
private int _secretInventorySize;
/* Migration method. The engine looks for static methods marked with
* [ScyllaMigration] when the stored version differs from the current
* version. Signature: static T MigrateXtoY(T oldValue) where T is the
* declaring type. */
[ScyllaMigration(fromVersion: 1, toVersion: 2)]
private static PlayerSave MigrateV1ToV2(PlayerSave old)
{
/* In v1 the field was missing; in v2 it must be at least 1. */
old.Level = Math.Max(1, old.Level);
return old;
}
}
Produce a compact binary save file
Problem. Your save file ships compressed and encrypted – it does not need to be human-readable, and you want every byte trimmed. JSON is fine for configuration that someone might inspect by hand, but for a binary blob going through Compression and Crypto it is unnecessary overhead.
Solution. ToBytes / FromBytes produce a more compact representation than JSON. The default settings use the JSON writer (UTF-8 bytes), but switching SerializationFormat to Binary engages the binary writer for a faster, smaller output that is not human-readable. Use binary for save files, network packets, and any other case where size or speed matters more than human inspectability.
/* Default ToBytes: UTF-8 JSON bytes. Equivalent to Encoding.UTF8.GetBytes(ToJSON(value).Value)
* but allocates one fewer string. */
SerializationResult<byte[]> bytes = ScyllaSerialization.ToBytes(player);
SerializationResult<PlayerSave> back = ScyllaSerialization.FromBytes<PlayerSave>(bytes.Value);
/* Compact binary form: smaller and faster than JSON. */
SerializationSettings binarySettings = SerializationSettings.CompactBinary;
SerializationResult<byte[]> packed = ScyllaSerialization.ToBytes(player, binarySettings);
SerializationResult<PlayerSave> unpacked = ScyllaSerialization.FromBytes<PlayerSave>(packed.Value, binarySettings);
/* Round-trip pipe with ScyllaFileUtil.SaveCompressed for a tight save file. */
byte[] payload = ScyllaSerialization.ToBytes(saveState, SerializationSettings.CompactBinary).Value;
ScyllaFileUtil.SaveCompressed("save.dat", payload);
Choose the right settings for your use case
Problem. You need fine-grained control over the output: maybe you want indented JSON for a config file a designer will hand-edit, or you need to strip null values from a network payload to save bandwidth, or you want to derive a custom settings variant from one of the built-in presets rather than constructing everything from scratch.
Solution. Settings come in two shapes: either pick one of the named presets and move on, or construct a custom instance via the fluent builder when something specific needs changing. SerializationSettings is the immutable configuration carrier; every field is read-only after Build(), so you can safely share the same instance across threads and across calls.
/* Three presets. */
SerializationSettings def = SerializationSettings.Default; /* JSON, no pretty print */
SerializationSettings pretty = SerializationSettings.ReadableJSON; /* JSON with tabs and newlines */
SerializationSettings compact = SerializationSettings.CompactBinary; /* Binary, no null/default values */
/* Custom settings via the fluent builder. */
SerializationSettings custom = SerializationSettings.CreateBuilder()
.WithFormat(SerializationFormat.JSON)
.WithNullHandling(NullHandling.Ignore) /* omit properties whose value is null */
.WithDefaultValueHandling(DefaultValueHandling.Ignore) /* omit properties at their default value */
.WithReferenceHandling(ReferenceHandling.Preserve) /* handle cycles via $id/$ref */
.WithMaxDepth(128) /* deeper than default 64 */
.WithTypeInfo(true) /* emit $type for polymorphism */
.WithVersionInfo(true) /* emit $version for migration */
.WithPrettyPrint(true) /* indent for readability */
.WithIndent(" ") /* two-space indent (default: tab) */
.WithPrivateFields(true) /* include private fields even without [ScyllaInclude] */
.Build();
/* Derive a variant from an existing settings instance. */
SerializationSettings tweaked = SerializationSettings.ReadableJSON
.ToBuilder()
.WithIndent(" ") /* override the indent to 4 spaces */
.Build();
/* Pass the settings to any facade method. */
SerializationResult<string> json = ScyllaSerialization.ToJSON(player, custom);
The four enums used by SerializationSettings are listed in the Settings reference section below.
Serialize object graphs with back-references
Problem. Object graphs in real game state are rarely trees. Two entities can hold references to the same inventory, a parent can hold a list of children that each hold a back-reference to the parent, and a graph data structure can have arbitrary cycles. The default serializer fails on a detected cycle, which is the safe behaviour for types that should never cycle but the wrong behaviour for types that genuinely do.
Solution. ReferenceHandling picks the engine’s policy. Error (the default) fails on a detected cycle. Preserve writes $id / $ref markers to round-trip the graph exactly. Ignore writes the back-edge as null and drops the cycle silently.
/* A graph with a cycle: a parent holds children, each child knows its parent. */
public sealed class Entity
{
public string Name { get; set; }
public Entity Parent { get; set; }
public List<Entity> Children { get; set; } = new();
}
var root = new Entity { Name = "Root" };
var child = new Entity { Name = "Child", Parent = root };
root.Children.Add(child);
/* Default behaviour (ReferenceHandling.Error): cycle detected, result is a
* failed SerializationResult. */
SerializationResult<string> errored = ScyllaSerialization.ToJSON(root);
/* errored.IsFailure == true, errored.ErrorMessage describes the cycle. */
/* Preserve: the engine emits $id on the first occurrence and $ref on every
* back-edge. The same graph round-trips exactly, including identity. */
SerializationSettings preserve = SerializationSettings.CreateBuilder()
.WithReferenceHandling(ReferenceHandling.Preserve)
.Build();
SerializationResult<string> preserved = ScyllaSerialization.ToJSON(root, preserve);
SerializationResult<Entity> loaded = ScyllaSerialization.FromJSON<Entity>(preserved.Value, preserve);
/* loaded.Value.Children[0].Parent is the same instance as loaded.Value. */
/* Ignore: the back-edge is written as null. The structure round-trips but
* the child's Parent property is null on the deserialized graph. */
SerializationSettings ignore = SerializationSettings.CreateBuilder()
.WithReferenceHandling(ReferenceHandling.Ignore)
.Build();
SerializationResult<string> ignored = ScyllaSerialization.ToJSON(root, ignore);
Round-trip a polymorphic item collection
Problem. You’re serializing a List<Item> where the elements are actually Sword, Potion, or Scroll at runtime. When a property is typed as a base class or interface and the runtime value is a subclass, the deserializer needs to know which concrete type to construct. Without help, you get back a list of base Item objects with all the subclass data missing.
Solution. IncludeTypeInfo enables a $type discriminator: on serialize, the engine writes it; on deserialize, it reads the discriminator and constructs the correct subclass. By default the discriminator is the type’s full assembly-qualified name; the TypeAlias property on [ScyllaSerializable] substitutes a short, stable string instead.
/* Base type plus subclasses, each with a distinct TypeAlias. */
[ScyllaSerializable(version: 1, TypeAlias = "Item")]
public abstract class Item
{
public string Name { get; set; }
}
[ScyllaSerializable(version: 1, TypeAlias = "Weapon")]
public sealed class Weapon : Item
{
public int Damage { get; set; }
}
[ScyllaSerializable(version: 1, TypeAlias = "Potion")]
public sealed class Potion : Item
{
public int HealAmount { get; set; }
}
/* A polymorphic collection. */
public sealed class Inventory
{
public List<Item> Items { get; set; } = new();
}
var inv = new Inventory();
inv.Items.Add(new Weapon { Name = "Sword", Damage = 10 });
inv.Items.Add(new Potion { Name = "Health", HealAmount = 25 });
/* Without IncludeTypeInfo, the deserializer cannot know whether each Item is
* a Weapon or a Potion. Enable type info to round-trip the subclasses. */
SerializationSettings poly = SerializationSettings.CreateBuilder()
.WithTypeInfo(true)
.Build();
SerializationResult<string> json = ScyllaSerialization.ToJSON(inv, poly);
SerializationResult<Inventory> back = ScyllaSerialization.FromJSON<Inventory>(json.Value, poly);
/* back.Value.Items[0] is a Weapon; back.Value.Items[1] is a Potion. */
/* Register a type alias at runtime (rarely needed; the attribute does this
* automatically at first metadata access). */
TypeMetadataCache.RegisterAlias("LegacyItem", typeof(Item));
/* Look up a type by alias. */
Type resolved = TypeMetadataCache.ResolveAlias("Weapon");
Migrate save data across version bumps
Problem. There’s a moment in every shipped game’s life when the save format from yesterday no longer matches the type definition from today. You renamed a field between v1.0 and v1.1, added a new required stat in v1.2, and now an existing player’s save file from v1.0 needs to load cleanly into the v1.2 type. Without a migration system, you’re either stuck with the v1.0 shape forever or you’re telling players their save is gone.
Solution. [ScyllaSerializable(version)] writes the version into the output as a $version field. When the version on a loaded blob is older than the type’s current version, the engine looks for [ScyllaMigration(fromVersion, toVersion)]-decorated static methods on the type and chains them in order. A v1 blob being loaded into a v3 type runs v1->v2 then v2->v3 automatically.
/* The type has evolved across three versions. */
[ScyllaSerializable(version: 3)]
public sealed class CharacterSave
{
public string Name { get; set; }
public int Level { get; set; }
public int Stamina { get; set; }
public int MaxStamina { get; set; }
/* v1 had no Stamina field. v2 added Stamina; v3 added MaxStamina. */
[ScyllaMigration(fromVersion: 1, toVersion: 2)]
private static CharacterSave MigrateV1ToV2(CharacterSave old)
{
old.Stamina = 100; /* default value for the new field */
return old;
}
[ScyllaMigration(fromVersion: 2, toVersion: 3)]
private static CharacterSave MigrateV2ToV3(CharacterSave old)
{
old.MaxStamina = old.Stamina; /* derive from the existing Stamina */
return old;
}
}
/* Loading a v1 blob runs both migrations: v1 -> v2 -> v3. */
string v1Blob = "{\"$version\":1,\"Name\":\"Hero\",\"Level\":5}";
SerializationResult<CharacterSave> upgraded = ScyllaSerialization.FromJSON<CharacterSave>(v1Blob);
/* upgraded.Value.Stamina == 100; upgraded.Value.MaxStamina == 100. */
/* Register a migration at runtime instead of via attribute. Useful when the
* migration logic depends on runtime state not available at type definition. */
ScyllaSerialization.RegisterMigration<CharacterSave>(
fromVersion: 3,
toVersion: 4,
migrator: save =>
{
save.Level = Math.Max(save.Level, 1);
return save;
}
);
/* Look up the registered migration. Returns a Func<object, object> that
* applies the migration; primarily for engine internals and testing. */
Func<object, object> migrator = ScyllaSerialization.GetMigration(typeof(CharacterSave), 3, 4);
Write a custom type serializer
Problem. Sometimes the reflection serializer’s output is not what you need: a different shape, a compact encoding, a third-party type without an accessible constructor, a value type that wants to round-trip as a single integer. The default serializer for UnityEngine.Matrix4x4 would write 16 named properties (m00, m01, …); you want a flat array.
Solution. Implement ITypeSerializer<T> or extend TypeSerializerBase<T> to take full control. The base class handles the object-typed WriteObject / ReadObject adapters automatically; you only override Write and Read. Register globally for project-wide use, or per-settings to keep the effect scoped to a specific call.
/* A custom serializer for UnityEngine.Matrix4x4 that writes the 16 floats
* as a flat array. The reflection serializer would write them as 16 named
* properties (m00, m01, ...); this version is shorter and faster. */
using UnityEngine;
using Scylla.Core.Util.Serialization;
public sealed class Matrix4x4Serializer : TypeSerializerBase<Matrix4x4>
{
public override void Write(Matrix4x4 value, IScyllaWriter writer, ISerializationContext context)
{
writer.WriteStartArray();
for (int i = 0; i < 16; i++)
{
writer.WriteFloat(value[i / 4, i % 4]);
}
writer.WriteEndArray();
}
public override Matrix4x4 Read(IScyllaReader reader, ISerializationContext context)
{
var m = new Matrix4x4();
reader.ReadStartArray();
for (int i = 0; i < 16; i++)
{
m[i / 4, i % 4] = reader.ReadFloat();
}
reader.ReadEndArray();
return m;
}
}
/* Register globally: every subsequent ToJSON / FromJSON call for Matrix4x4
* uses this serializer. */
ScyllaSerialization.RegisterSerializer(new Matrix4x4Serializer());
/* Register per-call via the Builder: the custom serializer applies only to
* calls that use this settings instance. */
SerializationSettings withCustom = SerializationSettings.CreateBuilder()
.RegisterSerializer(new Matrix4x4Serializer())
.Build();
SerializationResult<string> json = ScyllaSerialization.ToJSON(transform, withCustom);
Hand-roll output with the low-level reader and writer
Problem. You need ultra-low-level access: parsing a hand-rolled binary format from a legacy save, writing a custom JSON shape that does not map to any reasonable C# type, or interoperating with an external schema that has its own opinions about field ordering. The facade does not give you enough control over the token stream.
Solution. Construct ScyllaJSONReader or ScyllaJSONWriter directly. The interfaces (IScyllaReader / IScyllaWriter) expose typed primitive reads/writes plus a token-stream model via PeekToken and SerializationToken.
using Scylla.Core.Util.Serialization;
using System.IO;
using System.Text;
/* Writing: construct a writer over a StringBuilder, write tokens, dispose. */
var sb = new StringBuilder();
using (IScyllaWriter writer = new ScyllaJSONWriter(sb, SerializationSettings.ReadableJSON))
{
writer.WriteStartObject();
writer.WritePropertyName("name");
writer.WriteString("Hero");
writer.WritePropertyName("stats");
writer.WriteStartObject();
writer.WritePropertyName("hp");
writer.WriteInt(75);
writer.WritePropertyName("mp");
writer.WriteInt(30);
writer.WriteEndObject();
writer.WritePropertyName("inventory");
writer.WriteStartArray();
writer.WriteString("sword");
writer.WriteString("potion");
writer.WriteEndArray();
writer.WriteEndObject();
}
string json = sb.ToString();
/* Reading: token-stream model. PeekToken inspects the next token without
* consuming it; Read* methods consume and return the typed value. */
using (IScyllaReader reader = new ScyllaJSONReader(json, SerializationSettings.Default))
{
reader.ReadStartObject();
while (reader.PeekToken() != SerializationToken.EndObject)
{
string propertyName = reader.ReadPropertyName();
switch (propertyName)
{
case "name":
string name = reader.ReadString();
/* ... */
break;
case "stats":
reader.ReadStartObject();
while (reader.PeekToken() != SerializationToken.EndObject)
{
string statName = reader.ReadPropertyName();
int value = reader.ReadInt();
/* ... */
}
reader.ReadEndObject();
break;
case "inventory":
reader.ReadStartArray();
while (reader.PeekToken() != SerializationToken.EndArray)
{
string itemName = reader.ReadString();
/* ... */
}
reader.ReadEndArray();
break;
}
}
reader.ReadEndObject();
}
/* Twelve SerializationToken values cover every primitive shape: None, Null,
* Boolean, Integer, Float, String, StartObject, EndObject, PropertyName,
* StartArray, EndArray, Bytes. PeekToken returns None at end of input. */
Pre-warm the metadata cache before a hot save path
Problem. Your game saves frequently and you are seeing a stall on the first save call per session. The reflection serializer walks every type’s properties, reads attribute values, and locates default constructors on first use; for a save object graph with twenty types, that is twenty separate reflection passes happening at the worst possible moment – when the player is mid-combat or mid-dialogue and least expects a hitch.
Solution. TypeMetadataCache.PreWarm(types) runs those reflection passes up front during your loading screen, so every subsequent save call is a dictionary lookup. The ReferenceTracker is owned internally by SerializationContext and rarely constructed directly, but custom serializers that need to track shared references can access it through the context.
using Scylla.Core.Util.Serialization;
using Scylla.Core.Util.Serialization.Metadata;
using Scylla.Core.Util.Serialization.Reference;
/* Pre-warm the metadata cache at application startup. The first call against
* a type runs reflection; subsequent calls are O(1) dictionary lookups. */
TypeMetadataCache.PreWarm(typeof(PlayerSave), typeof(Inventory), typeof(Item));
/* Or pre-warm a sequence (e.g., every save-game type discovered via assembly
* scanning). */
IEnumerable<Type> saveTypes = new[] { typeof(PlayerSave), typeof(CharacterSave) };
TypeMetadataCache.PreWarm(saveTypes);
/* Look up cached metadata. Useful for introspection (editor tooling, debugging,
* a custom serializer that decides on a different strategy based on the
* declared properties). */
TypeMetadata meta = TypeMetadataCache.GetMetadata<PlayerSave>();
Log.Info($"Type: {meta.Type.Name}, Version: {meta.Version}, Properties: {meta.Properties.Count}", LogCategory.Core);
foreach (PropertyMetadata prop in meta.Properties)
{
Log.Info($" {prop.Name} (member: {prop.MemberName})", LogCategory.Core);
}
/* Direct property lookup by serialized name. */
PropertyMetadata levelProp = meta.GetProperty("Level");
/* Find a migration method by version pair. Returns the MethodInfo if found. */
MethodInfo migrate = meta.GetMigration(fromVersion: 1, toVersion: 2);
bool hasPath = meta.HasMigrationPath(fromVersion: 1, toVersion: 3);
/* Cache management. Useful in tests; rare in production. */
int cachedCount = TypeMetadataCache.Count;
bool isCached = TypeMetadataCache.IsCached(typeof(PlayerSave));
TypeMetadataCache.Clear();
/* ReferenceTracker: constructed automatically by the engine; expose for
* custom serializers that need shared-reference behaviour. */
var tracker = new ReferenceTracker(ReferenceHandling.Preserve);
bool tracked = tracker.TryTrack(someObject, out int refID);
if (tracker.TryGetID(someObject, out int knownID))
{
/* Write a $ref to knownID. */
}
if (tracker.TryResolve(refID, out object resolved))
{
/* Use the resolved object. */
}
tracker.Register(refID: 5, obj: somethingPreloaded);
bool already = tracker.IsTracked(someObject);
tracker.Clear();
API Sketch
The public surface is split across one facade, two result types, one settings class plus its builder, five attributes, three interface families (serializer, context, reader/writer), one base class for custom serializers, one reference tracker, three metadata types, four enums, and one exception. Most user code touches only the facade and the attributes.
namespace Scylla.Core.Util.Serialization
{
/* Facade. */
public static class ScyllaSerialization
{
public static SerializationResult<string> ToJSON<T>(T value);
public static SerializationResult<string> ToJSON<T>(T value, SerializationSettings settings);
public static SerializationResult<byte[]> ToBytes<T>(T value);
public static SerializationResult<byte[]> ToBytes<T>(T value, SerializationSettings settings);
public static SerializationResult<T> FromJSON<T>(string json);
public static SerializationResult<T> FromJSON<T>(string json, SerializationSettings settings);
public static SerializationResult<T> FromBytes<T>(byte[] bytes);
public static SerializationResult<T> FromBytes<T>(byte[] bytes, SerializationSettings settings);
public static void RegisterSerializer<T>(ITypeSerializer<T> serializer);
public static void RegisterMigration<T>(int fromVersion, int toVersion, Func<T, T> migrator);
public static Func<object, object> GetMigration(Type type, int fromVersion, int toVersion);
}
/* Results. */
public readonly struct SerializationResult
{
public bool IsSuccess { get; }
public bool IsFailure { get; }
public string ErrorMessage { get; }
public Exception Exception { get; }
public static SerializationResult Success();
public static SerializationResult Failure(string errorMessage, Exception exception = null);
}
public readonly struct SerializationResult<T>
{
public bool IsSuccess { get; }
public bool IsFailure { get; }
public T Value { get; } /* throws InvalidOperationException on failed result */
public string ErrorMessage { get; }
public Exception Exception { get; }
public static SerializationResult<T> Success(T value);
public static SerializationResult<T> Failure(string errorMessage, Exception exception = null);
public T GetValueOrDefault(T defaultValue = default);
public bool TryGetValue(out T value);
public SerializationResult ToResult();
}
/* Settings (immutable; build via Builder). */
public sealed class SerializationSettings
{
public const int DEFAULT_MAX_DEPTH = 64;
public const string DEFAULT_INDENT = "\t";
public static SerializationSettings Default { get; }
public static SerializationSettings ReadableJSON { get; }
public static SerializationSettings CompactBinary { get; }
public SerializationFormat Format { get; }
public NullHandling NullHandling { get; }
public DefaultValueHandling DefaultValueHandling { get; }
public ReferenceHandling ReferenceHandling { get; }
public int MaxDepth { get; }
public bool IncludeTypeInfo { get; }
public bool IncludeVersionInfo { get; }
public bool PrettyPrint { get; }
public string Indent { get; }
public bool IncludePrivateFields { get; }
public IReadOnlyDictionary<Type, ITypeSerializer> CustomSerializers { get; }
public static Builder CreateBuilder();
public Builder ToBuilder();
public sealed class Builder
{
public Builder WithFormat (SerializationFormat format);
public Builder WithNullHandling (NullHandling handling);
public Builder WithDefaultValueHandling(DefaultValueHandling handling);
public Builder WithReferenceHandling (ReferenceHandling handling);
public Builder WithMaxDepth (int maxDepth);
public Builder WithTypeInfo (bool include = true);
public Builder WithVersionInfo (bool include = true);
public Builder WithPrettyPrint (bool enabled = true);
public Builder WithIndent (string indent);
public Builder WithPrivateFields (bool include = true);
public Builder RegisterSerializer<T> (ITypeSerializer<T> serializer);
public SerializationSettings Build();
}
}
/* Enums. */
public enum SerializationFormat { JSON = 0, Binary = 1 }
public enum NullHandling { Include = 0, Ignore = 1 }
public enum DefaultValueHandling { Include = 0, Ignore = 1 }
public enum ReferenceHandling { Error = 0, Preserve = 1, Ignore = 2 }
public enum SerializationToken { None = 0, Null, Boolean, Integer, Float, String,
StartObject, EndObject, PropertyName,
StartArray, EndArray, Bytes }
/* Type serializer layer. */
public interface ITypeSerializer
{
Type TargetType { get; }
void WriteObject(object value, IScyllaWriter writer, ISerializationContext context);
object ReadObject (IScyllaReader reader, ISerializationContext context);
}
public interface ITypeSerializer<T>
{
void Write(T value, IScyllaWriter writer, ISerializationContext context);
T Read (IScyllaReader reader, ISerializationContext context);
}
public abstract class TypeSerializerBase<T> : ITypeSerializer<T>, ITypeSerializer
{
public Type TargetType => typeof(T);
public abstract void Write(T value, IScyllaWriter writer, ISerializationContext context);
public abstract T Read (IScyllaReader reader, ISerializationContext context);
public void WriteObject(object value, IScyllaWriter writer, ISerializationContext context);
public object ReadObject (IScyllaReader reader, ISerializationContext context);
}
public sealed class TypeSerializerAdapter<T> : ITypeSerializer
{
public TypeSerializerAdapter(ITypeSerializer<T> inner);
}
/* Context. */
public interface ISerializationContext
{
SerializationSettings Settings { get; }
int CurrentDepth { get; }
bool IsMaxDepthReached { get; }
ReferenceTracker ReferenceTracker { get; }
void PushDepth();
void PopDepth();
ITypeSerializer GetSerializer(Type type);
ITypeSerializer<T> GetSerializer<T>();
}
public sealed class SerializationContext : ISerializationContext { /* ... */ }
/* Reader / writer. */
public interface IScyllaWriter : IDisposable
{
void WriteNull();
void WriteBool(bool value);
void WriteByte(byte value); void WriteSByte (sbyte value);
void WriteShort(short value); void WriteUShort(ushort value);
void WriteInt(int value); void WriteUInt (uint value);
void WriteLong(long value); void WriteULong (ulong value);
void WriteFloat(float value); void WriteDouble(double value);
void WriteDecimal(decimal value);
void WriteChar(char value);
void WriteString(string value);
void WriteBytes(byte[] bytes);
void WriteStartObject(); void WriteEndObject();
void WritePropertyName(string name);
void WriteStartArray(); void WriteEndArray();
}
public interface IScyllaReader : IDisposable
{
bool HasMore { get; }
SerializationToken PeekToken();
bool ReadNull();
bool ReadBool();
byte ReadByte(); sbyte ReadSByte();
short ReadShort(); ushort ReadUShort();
int ReadInt(); uint ReadUInt();
long ReadLong(); ulong ReadULong();
float ReadFloat(); double ReadDouble();
decimal ReadDecimal();
char ReadChar();
string ReadString();
byte[] ReadBytes();
void ReadStartObject(); void ReadEndObject();
string ReadPropertyName();
void ReadStartArray(); void ReadEndArray();
}
public sealed class ScyllaJSONWriter : IScyllaWriter { public ScyllaJSONWriter(StringBuilder sb, SerializationSettings settings); }
public sealed class ScyllaJSONReader : IScyllaReader { public ScyllaJSONReader(string json, SerializationSettings settings); }
/* Reference tracking. */
public sealed class ReferenceTracker
{
public ReferenceTracker(ReferenceHandling handling);
public ReferenceHandling Handling { get; }
public int Count { get; }
public bool TryTrack (object obj, out int referenceID);
public bool TryGetID (object obj, out int referenceID);
public bool TryResolve(int referenceID, out object obj);
public void Register (int referenceID, object obj);
public bool IsTracked (object obj);
public void Clear();
}
/* Metadata cache. */
public static class TypeMetadataCache
{
public static int Count { get; }
public static TypeMetadata GetMetadata(Type type);
public static TypeMetadata GetMetadata<T>();
public static Type ResolveAlias(string alias);
public static void RegisterAlias(string alias, Type type);
public static void Clear();
public static void PreWarm(IEnumerable<Type> types);
public static void PreWarm(params Type[] types);
public static bool IsCached(Type type);
}
public sealed class TypeMetadata
{
public Type Type { get; }
public int Version { get; }
public string TypeAlias { get; }
public bool IsExplicitlySerializable { get; }
public bool IsValueType { get; }
public bool IsPrimitive { get; }
public bool IsNullable { get; }
public Type UnderlyingNullableType { get; }
public IReadOnlyList<PropertyMetadata> Properties { get; }
public IReadOnlyDictionary<string, PropertyMetadata> PropertyLookup { get; }
public bool HasDefaultConstructor { get; }
public object CreateInstance();
public PropertyMetadata GetProperty(string name);
public MethodInfo GetMigration(int fromVersion, int toVersion);
public bool HasMigrationPath(int fromVersion, int toVersion);
}
public sealed class PropertyMetadata
{
public string Name { get; }
public string MemberName { get; }
/* + Type, IsField, IsReadOnly, IsRequired, Order, plus accessor delegates */
}
/* Exception. */
public class SerializationException : Exception
{
public SerializationException();
public SerializationException(string message);
public SerializationException(string message, Exception innerException);
}
}
namespace Scylla.Core.Util.Serialization.Attributes
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public sealed class ScyllaSerializableAttribute : Attribute
{
public ScyllaSerializableAttribute(int version = 1);
public int Version { get; }
public string TypeAlias { get; set; }
}
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class ScyllaPropertyAttribute : Attribute
{
public string Name { get; set; }
public int Order { get; set; } /* default int.MaxValue */
public bool Required { get; set; } /* default false */
}
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class ScyllaIgnoreAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class ScyllaIncludeAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Method)]
public sealed class ScyllaMigrationAttribute : Attribute
{
public ScyllaMigrationAttribute(int fromVersion, int toVersion);
public int FromVersion { get; }
public int ToVersion { get; }
}
}
See the API reference for full signatures, remarks, and exception contracts on every member.
Settings reference
SerializationSettings is the immutable configuration carrier. Build a custom instance via CreateBuilder() and the fluent With* methods; pass the result to any facade call. The four enums below cover every behavioural switch.
Properties
| Property | Type | Default | Effect |
|---|---|---|---|
Format | SerializationFormat | JSON | JSON for human-readable UTF-8; Binary for the compact byte format used by CompactBinary. |
NullHandling | NullHandling | Include | Include writes properties whose value is null; Ignore omits them from the output. |
DefaultValueHandling | DefaultValueHandling | Include | Include writes properties at their default value (0, false, null, etc.); Ignore omits them. |
ReferenceHandling | ReferenceHandling | Error | Error fails on a detected cycle; Preserve writes $id / $ref to round-trip cycles exactly; Ignore writes back-edges as null. |
MaxDepth | int | 64 | Hard limit on nested object depth. Exceeding the limit produces a failed result with a stack-depth error message. Raise for deeply nested data; lower as a guard against malicious input. |
IncludeTypeInfo | bool | false | When true, the engine writes a $type discriminator for every typed value. Required for polymorphic deserialization; adds output bytes for every value. |
IncludeVersionInfo | bool | true | When true, the engine writes a $version field for every [ScyllaSerializable]-marked type. Required for migration; safe to keep on by default. |
PrettyPrint | bool | false | When true, the JSON output is indented with newlines for readability. Adds significant whitespace; disable for production save files. |
Indent | string | "\t" (one tab) | Indentation string used when PrettyPrint is true. Use " " for two spaces, " " for four spaces. |
IncludePrivateFields | bool | false | When true, the reflection serializer picks up private fields even when not marked with [ScyllaInclude]. Use sparingly; per-member opt-in via [ScyllaInclude] is more explicit. |
CustomSerializers | IReadOnlyDictionary<Type, ITypeSerializer> | empty | Per-settings serializer overrides. Add via Builder.RegisterSerializer<T>(serializer); preferred over the global ScyllaSerialization.RegisterSerializer<T> for code that should not mutate state. |
Presets
| Preset | Format | Distinguishing properties | Use case |
|---|---|---|---|
SerializationSettings.Default | JSON | All defaults | Most cases. UTF-8 JSON, max depth 64, include nulls and defaults. |
SerializationSettings.ReadableJSON | JSON | PrettyPrint = true | Configuration files, debug output, anything inspected by hand. |
SerializationSettings.CompactBinary | Binary | NullHandling = Ignore, DefaultValueHandling = Ignore | Save files, network packets, anything where size or speed matters more than human inspectability. |
Attribute summary
| Attribute | Target | Effect |
|---|---|---|
[ScyllaSerializable(version = 1)] | class, struct | Marks a type as participating in versioning. TypeAlias (property) sets the short $type discriminator for polymorphism. |
[ScyllaProperty(Name, Order, Required)] | property, field | Overrides the serialized property name, sets the sort order in output, or marks the property as required on deserialization. |
[ScyllaIgnore] | property, field | Skips this member from serialization. Use for cached, computed, or runtime-only state. |
[ScyllaInclude] | property, field | Opts a private member into serialization without changing its access level. |
[ScyllaMigration(fromVersion, toVersion)] | static method | Marks a static method as the migration step from one schema version to the next. Signature: static T MigrateXtoY(T oldValue). |
Best Practices
- Use the facade for the vast majority of work.
ScyllaSerialization.ToJSON<T>/FromJSON<T>handles every common case correctly. Reach for direct reader/writer usage only when the standard shape is wrong for a specific scenario. - Always check
IsSuccessbefore readingValue. The result types follow the no-throw pattern; readingValueon a failed result throwsInvalidOperationExceptionwith the error message baked in. UseTryGetValue/GetValueOrDefaultfor safe access. - Mark every persistable type with
[ScyllaSerializable(version: 1)]from day one. Adding versioning later requires careful migration of existing stored data. Starting with a version field costs nothing and unlocks the migration system for free. - Use
[ScyllaProperty(Name = "...")]to lock the JSON keys. C# identifiers tend to drift across refactors; explicit JSON names survive renames. Once a save file is written with a given name, that name is part of the on-disk contract. - Pick the right
ReferenceHandlingper call. UseError(the default) for trees that should never have cycles. UsePreservefor genuine graphs (entity trees with back-references, multi-parent state). AvoidIgnoreunless dropping back-edges is explicitly desired. - Use
[ScyllaIgnore]for cached or derived state. A computed property that depends on other fields, a cached lookup table, a runtime-only handle – every one of these should be skipped on serialize and recomputed on load. - Set
[ScyllaProperty(Required = true)]sparingly. A missing field on an old save fails the load; the surface should be small enough to migrate cleanly. Use it for “must exist” fields like format-version sentinels and cryptographic salts. - Use
SerializationSettings.CompactBinaryfor save files and network packets. Smaller, faster, no whitespace, no null/default values. Pair withScyllaFileUtil.SaveSecureObjectfor opaque save data. - Use
SerializationSettings.ReadableJSONfor configuration files. The pretty-printed output is easy to inspect, easy to hand-edit, and easy to diff between revisions. The framework’s Configuration layer uses this preset by default. - Pre-warm the metadata cache for hot paths.
TypeMetadataCache.PreWarm(types)runs the reflection pass for a set of types at startup. Subsequent serialization calls become dictionary lookups. For a save with twenty types, pre-warming converts twenty first-call stalls into one startup pass. - Register custom serializers per-settings, not globally, in libraries. Per-settings registration keeps the framework’s global state untouched and avoids surprise interactions when two libraries register competing serializers. Use global registration only at the application entry point.
- Test migration paths end-to-end. A
v1->v2->v3chain that works on each step in isolation can still fail on the combined path if any intermediate step depends on context that disappears. Round-trip a v1 fixture through the current type and assert the resulting object is equivalent to a freshly-constructed current-version object. - Treat
TypeAliasas a contract. Once a save file references a type by alias, renaming the alias breaks future loads. Pick aliases deliberately and document them in the type’s XML doc comment. - Wrap direct reader/writer usage in
try/catchoverSerializationException. The reader/writer layer surfaces malformed input as a thrown exception; only the facade catches it. Custom serializers that want to expose a result-pattern API must do the wrapping themselves.
Pitfalls
Related
- Compression Utils
- Crypto Utils
- File IO Utils
- Configuration
- API reference:
Scylla.Core.Util.Serialization.ScyllaSerialization - API reference:
Scylla.Core.Util.Serialization.SerializationSettings - API reference:
Scylla.Core.Util.Serialization.SerializationResult - API reference:
Scylla.Core.Util.Serialization.SerializationResult`1 - API reference:
Scylla.Core.Util.Serialization.SerializationFormat - API reference:
Scylla.Core.Util.Serialization.NullHandling - API reference:
Scylla.Core.Util.Serialization.DefaultValueHandling - API reference:
Scylla.Core.Util.Serialization.ReferenceHandling - API reference:
Scylla.Core.Util.Serialization.SerializationException - API reference:
Scylla.Core.Util.Serialization.ITypeSerializer - API reference:
Scylla.Core.Util.Serialization.ITypeSerializer`1 - API reference:
Scylla.Core.Util.Serialization.TypeSerializerAdapter`1 - API reference:
Scylla.Core.Util.Serialization.TypeSerializerBase`1 - API reference:
Scylla.Core.Util.Serialization.ISerializationContext - API reference:
Scylla.Core.Util.Serialization.SerializationContext - API reference:
Scylla.Core.Util.Serialization.IScyllaReader - API reference:
Scylla.Core.Util.Serialization.IScyllaWriter - API reference:
Scylla.Core.Util.Serialization.SerializationToken - API reference:
Scylla.Core.Util.Serialization.ScyllaJSONReader - API reference:
Scylla.Core.Util.Serialization.ScyllaJSONWriter - API reference:
Scylla.Core.Util.Serialization.ReferenceTracker - API reference:
Scylla.Core.Util.Serialization.TypeMetadata - API reference:
Scylla.Core.Util.Serialization.TypeMetadataCache - API reference:
Scylla.Core.Util.Serialization.PropertyMetadata - API reference:
Scylla.Core.Util.Serialization.ScyllaSerializableAttribute - API reference:
Scylla.Core.Util.Serialization.ScyllaPropertyAttribute - API reference:
Scylla.Core.Util.Serialization.ScyllaIgnoreAttribute - API reference:
Scylla.Core.Util.Serialization.ScyllaIncludeAttribute - API reference:
Scylla.Core.Util.Serialization.ScyllaMigrationAttribute