JSON and tagged binary serialization with 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 turn game state into serialized data you can store on disk or send over a network, and back again. Hand them an object and they produce either JSON, which is human-readable and easy to inspect and diff, or a compact tagged binary stream that is smaller and faster to parse and is meant to be compressed and encrypted rather than read by hand. Feed that result back in later, and the original object comes back rebuilt. Unity’s own value 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 methods carry old data forward, either one version at a time or in a single jump, 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. Object graphs where several owners share one instance, or where objects point back at each other in cycles, are written once and come back with their identity intact. 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. The binary format is little-endian on every platform, so bytes written on one machine read back correctly on another.
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 both ends need typed deserialization.
- Schema-versioned content that must keep loading correctly as the type structure evolves over patches.
- Object graphs with shared references and back-references, such as entity trees where children know their parent.
- Custom on-disk formats, via direct low-level reader and writer access for both JSON and binary.
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 JSON),CompactBinary(binary format, null and default values omitted);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 paths.
[ScyllaSerializable(version: N)]writes a$versionfield;[ScyllaMigration(fromVersion, toVersion)]marks static migration methods. On load the engine finds the shortest chain of migrations from the stored version to the current one, which can mix single steps (v1 -> v2) with direct jumps (v1 -> v3). A version gap that no chain bridges fails the load with an error instead of loading unmigrated data. - Reference tracking for cycles and shared subgraphs.
ReferenceHandling.Preserve, the default, writes each shared object once with an explicit$idand points every later occurrence at it with$ref. Because the ID is explicit, a$refresolves correctly whatever order the reader visits properties in.EmitReferenceIDsturns the$idmarker off for consumers that expect the older shape. - Type and version markers. A value whose runtime type differs from its declared type (a
Weaponin anItemfield, anything in anobjector interface field) always carries a$typemarker, and the properties of its runtime type are written. WithIncludeTypeInfoon (the default), every other reflection-serialized object carries the marker too. The marker is the type’sTypeAliaswhen it declares one, otherwise its assembly-qualified name. The marker names are public constants (REF_ID_PROPERTY_NAME,REF_PROPERTY_NAME) so external tools can recognize$idand$refwithout hardcoding the strings. - Custom type serializers.
ITypeSerializer<T>/TypeSerializerBase<T>give full control over how a specific type round-trips. Register globally viaScyllaSerialization.RegisterSerializer<T>or per-call via the settings builder. - Two output formats. JSON for human-readable on-disk and over-the-wire use; a tagged binary stream (
SerializationFormat.Binary) for save files and packets. Every binary value is preceded by a one-byteBinaryTypeTag, multi-byte values are little-endian on every platform, andNaNand the infinities are stored as their native bit patterns. Same call shape; pick the format throughSerializationSettings.Format. NullHandlingandDefaultValueHandling.IncludeandIgnoremodes let you decide whether null values and default values are written. Smaller output for “ignore” cases; exact 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 readers and writers.
IScyllaReader/IScyllaWriterexpose a token-stream model with two implementations each:ScyllaJSONReader/ScyllaJSONWriterandScyllaBinaryReader/ScyllaBinaryWriter. Reach for these inside custom serializers or when your project needs a hand-rolled format. - 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 handful of terms come up repeatedly in this section. 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 forever. Type marker is the $type field that names the type an object was written as: the type alias when one is declared, otherwise the full assembly-qualified name. Type alias is a short name set through [ScyllaSerializable(TypeAlias = "...")]; it is written as the type marker and TypeMetadataCache.ResolveAlias turns it back into a type on load.
| 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> plugs into the global serializer registry. RegisterMigration<T> and GetMigration store and look up runtime migrations, but deserialization does not consult them. |
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(). Also holds the public $id and $ref name constants. |
[ScyllaSerializable] | Type-level marker with schema version and optional type alias | Constructor version = 1 defaults to v1. TypeAlias (settable property) is written as the $type marker instead of the assembly-qualified name, and resolved back through TypeMetadataCache.ResolveAlias. Required for migration; not required for plain serialization (reflection picks up unmarked types). |
[ScyllaProperty] | Property/field marker for name, order, and required-ness | Name overrides the serialized 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 non-public 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. |
[ScyllaMigration] | Static method marker for one migration step | Constructor takes fromVersion and toVersion, which may be more than one version apart. The method must be static (public or private), take one parameter of the declaring type, and return the declaring type. Methods with any other signature are skipped. |
ITypeSerializer<T> / TypeSerializerBase<T> | Interface and base class for custom serializers | Override Write and Read to take full control of a type’s shape in either format. The reflection serializer is the default; a registered custom serializer replaces 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, Skip. Use directly inside custom serializers to control the exact output shape. |
ScyllaJSONReader / ScyllaJSONWriter | JSON reader over a string and JSON writer into a StringBuilder | JSON-specific concerns (escaping, whitespace, NaN and infinity as string sentinels) are handled internally. Configure(settings) applies pretty printing and the indent string to a writer. The facade pools and drives these for normal calls. |
ScyllaBinaryReader / ScyllaBinaryWriter | Tagged binary reader and writer | Every value is preceded by a BinaryTypeTag byte, and the reader checks the tag on every read, so reading a value with the wrong Read* method throws SerializationException. Little-endian on every platform. Reset(data, offset, count) reads a window of a larger buffer without copying it. |
BinaryTypeTag | One-byte tag enum for the binary format | Identifies the value or delimiter that follows: Null, the numeric widths, Char, String, Bytes, and the object, property name, and array delimiters. |
TypeMetadataCache | Static cache of reflection results, plus type alias registry | An alias that is not registered yet is found by a one-time scan of the loaded assemblies. 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. |
TypeMetadata | Cached description of one type | Version, alias, properties, constructor, and migration methods. TryBuildMigrationPath returns the shortest list of migration methods between two versions; results, including “no path”, are cached per version pair. |
ReferenceTracker | Per-call bookkeeping for cycle and shared-subgraph handling | Owned internally by SerializationContext; constructed with a ReferenceHandling value (Error, Preserve, Ignore). TryTrackWithID registers an object under an ID read from a $id marker. Direct construction is rarely needed. |
SerializationException | Thrown by readers and writers on malformed input or invalid state | Surfaces from the low-level reader/writer layer; the facade methods catch it and return a failed SerializationResult. Direct reader/writer usage bubbles it up to the caller. |
Recipes
The recipes below cover every public method on the facade, every attribute, every settings option, every result type, and every subsystem entry point. Each one stands on its own, so scan the titles for the problem you’re solving and copy the solution straight across. If you haven’t used the API yet, start with the first recipe anyway: it lays out the SerializationResult<T> pattern and the round-trip shape that every other recipe assumes.
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>. With the default
* settings the output also carries "$id" and "$type" markers, because reference
* preservation and type info are both on by default. */
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. Shipping a save system eventually means wanting 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 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. The attributes live in the same Scylla.Core.Util.Serialization namespace as the facade.
using Scylla.Core.Util.Serialization;
/* 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 a short name registered with TypeMetadataCache. */
[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 stored 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 is older than the current
* version. Signature: one parameter of the declaring type, returning the
* declaring type. Public and private methods both work. */
[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, so 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 the text overhead buys nothing.
Solution. ToBytes / FromBytes with SerializationFormat.Binary (or the CompactBinary preset) write a tagged binary stream through ScyllaBinaryWriter and read it back through ScyllaBinaryReader. Each value is preceded by a one-byte BinaryTypeTag, numbers are stored in their native width, little-endian, and floats keep NaN and the infinities exactly. Without settings, ToBytes and FromBytes use the default JSON format and produce UTF-8 JSON bytes. The format used to read must match the format used to write; the two are not interchangeable.
/* Default ToBytes: the UTF-8 encoding of the default JSON output. */
SerializationResult<byte[]> bytes = ScyllaSerialization.ToBytes(player);
SerializationResult<PlayerSave> back = ScyllaSerialization.FromBytes<PlayerSave>(bytes.Value);
/* Binary format: tagged values, no text, no whitespace. CompactBinary also
* omits null and default values. Read it back with the same settings. */
SerializationSettings binarySettings = SerializationSettings.CompactBinary;
SerializationResult<byte[]> packed = ScyllaSerialization.ToBytes(player, binarySettings);
SerializationResult<PlayerSave> unpacked = ScyllaSerialization.FromBytes<PlayerSave>(packed.Value, binarySettings);
/* A custom binary settings instance keeps nulls and defaults instead. */
SerializationSettings fullBinary = SerializationSettings.CreateBuilder()
.WithFormat(SerializationFormat.Binary)
.Build();
/* Compress the bytes and write them with ScyllaFileUtil. WriteBytesAtomic swaps
* the new file in only once it is fully on disk, so a crash mid-save leaves the
* previous save intact. */
byte[] payload = ScyllaSerialization.ToBytes(saveState, SerializationSettings.CompactBinary).Value;
byte[] compressed = ScyllaCompression.Compress(payload);
ScyllaFileUtil.WriteBytesAtomic(savePath, compressed);
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, references preserved */
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) /* shared objects and cycles via $id/$ref (the default) */
.WithEmitReferenceIDs(true) /* write $id on first occurrence (the default) */
.WithMaxDepth(128) /* deeper than default 64 */
.WithTypeInfo(false) /* omit $type markers (on by default) */
.WithVersionInfo(true) /* emit $version for migration */
.WithPrettyPrint(true) /* indent for readability */
.Build();
/* Derive a variant from an existing settings instance. */
SerializationSettings tweaked = SerializationSettings.ReadableJSON
.ToBuilder()
.WithNullHandling(NullHandling.Ignore)
.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. You need the serializer to write each object once and rebuild the same sharing on load.
Solution. ReferenceHandling picks the engine’s policy. Preserve, the default, writes the first occurrence of each object with an $id marker and every later occurrence as a {"$ref": id} object, so the graph round-trips exactly, including identity. Error fails as soon as any object is reached a second time, whether through a cycle or through plain sharing. Ignore writes every repeated occurrence as null.
/* 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 behavior (ReferenceHandling.Preserve): the first occurrence carries
* "$id", the child's Parent is written as {"$ref": 1}. */
SerializationResult<string> preserved = ScyllaSerialization.ToJSON(root);
SerializationResult<Entity> loaded = ScyllaSerialization.FromJSON<Entity>(preserved.Value);
/* loaded.Value.Children[0].Parent is the same instance as loaded.Value. */
/* Error: any object reached twice fails the call. Use for data that must be
* a strict tree. */
SerializationSettings strict = SerializationSettings.CreateBuilder()
.WithReferenceHandling(ReferenceHandling.Error)
.Build();
SerializationResult<string> errored = ScyllaSerialization.ToJSON(root, strict);
/* errored.IsFailure == true, errored.ErrorMessage reports a cyclic reference. */
/* Ignore: the repeated occurrence is written as null. The structure
* round-trips but the child's Parent property is null after loading. */
SerializationSettings ignore = SerializationSettings.CreateBuilder()
.WithReferenceHandling(ReferenceHandling.Ignore)
.Build();
SerializationResult<string> ignored = ScyllaSerialization.ToJSON(root, ignore);
Read references in any order
Problem. A $ref only works if the reader already knows which object the ID belongs to. If IDs are assigned by counting objects in the order they are read, anything that changes that order breaks the link: a property skipped because its value is null or default, a $type that no longer resolves, or a JSON file edited by hand. The back-reference then points at the wrong object or at nothing.
Solution. With EmitReferenceIDs on (the default), the first occurrence of every tracked object carries an explicit $id, and the reader registers the object under that ID with ReferenceTracker.TryTrackWithID. A $ref then resolves to the right object regardless of traversal order. Documents without $id markers still load, because the reader falls back to counting objects as before. Turn the marker off only when an external consumer cannot handle it.
/* Default: $id is written on every newly tracked object. A typical object
* starts with "$id", then "$type", then "$version", then its properties. */
SerializationResult<string> withIDs = ScyllaSerialization.ToJSON(root);
/* Older shape, without $id markers, for a tool that does not expect them. */
SerializationSettings noIDs = SerializationSettings.CreateBuilder()
.WithEmitReferenceIDs(false)
.Build();
SerializationResult<string> withoutIDs = ScyllaSerialization.ToJSON(root, noIDs);
/* External tooling can recognize the markers through the public constants
* instead of hardcoding the strings. */
string idMarker = SerializationSettings.REF_ID_PROPERTY_NAME; /* "$id" */
string refMarker = SerializationSettings.REF_PROPERTY_NAME; /* "$ref" */
Serialize polymorphic values with $type and type aliases
Problem. You are serializing a List<Item> where the elements are actually Weapon or Potion at runtime, and you want each element to come back as its own subclass, with the fields of that subclass.
Solution. The engine still picks a serializer by the declared type of each member or collection element, but the reflection serializer compares the declared type with the runtime type of the value. When they differ, it writes the value with the properties of its runtime type and a $type marker, whatever IncludeTypeInfo is set to. On load, the marker is resolved to a type, checked against the declared type, and that type is instantiated. This covers a derived class in a base-typed field, an abstract base, an interface-typed field, and the elements of lists and dictionaries.
The marker is the type’s TypeAlias when the type declares one, and its assembly-qualified name otherwise. An alias is short and survives renames, namespace moves and assembly moves, so polymorphic types that end up in saves should declare one.
public abstract class Item
{
public string Name { get; set; }
}
public sealed class Inventory
{
public List<Item> Items { get; set; } = new();
}
[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; }
}
var inventory = new Inventory();
inventory.Items.Add(new Weapon { Name = "Sword", Damage = 10 });
inventory.Items.Add(new Potion { Name = "Health", HealAmount = 25 });
SerializationResult<string> json = ScyllaSerialization.ToJSON(inventory);
SerializationResult<Inventory> back = ScyllaSerialization.FromJSON<Inventory>(json.Value);
/* back.Value.Items[0] is a Weapon; back.Value.Items[1] is a Potion. The JSON
* for the first element looks like:
* {"$id":2,"$type":"Weapon","$version":1,"Damage":10,"Name":"Sword"} */
/* Aliases can also be registered by hand, for example for a type the runtime
* may strip, or to keep an old alias loading. */
TypeMetadataCache.RegisterAlias("LegacySword", typeof(Weapon));
Type resolved = TypeMetadataCache.ResolveAlias("Weapon");
A value that is not serialized as an object with properties, such as an int in an object field or a List<int> behind an IList<int> field, is written as an envelope, {"$type": ..., "$value": ...}, where $value holds the payload written by the serializer of the runtime type. A Dictionary<string, object> with mixed values round-trips this way.
How a marker is handled on load:
- Alias discovery. An alias that has not been registered yet is looked up in two steps.
Type.GetTypeis tried first, for assembly-qualified names. If that misses, the loaded assemblies are scanned once for types that declare the alias, so a save can be loaded in a fresh session before any type has been serialized. A duplicate alias logs a warning and the first registration is kept. Under IL2CPP, managed code stripping can remove a type that nothing else references, and the scan cannot find it; keep such a type with[Preserve]or register its alias withRegisterAlias. - Assignability check. The resolved type must be assignable to the declared type, otherwise the load fails with an error naming both types. This also stops a save file from making the engine instantiate an arbitrary type.
- Unresolvable marker. If the declared type is concrete, the value loads as the declared type. If it is abstract or an interface, the load fails with an error that names the marker, since there is nothing to fall back to.
IncludeTypeInfo. With it off, only the marker of objects whose runtime type equals the declared type is left out. Polymorphic values keep theirs, so turning it off never loses data.- Versions. The
$versionand the migration methods are those of the runtime type, so a derived type migrates on its own schedule.
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 collects the [ScyllaMigration(fromVersion, toVersion)] methods on the type and finds the shortest path between the two versions. A v1 blob loaded into a v3 type runs v1->v2 then v2->v3, or a single v1->v3 method when one exists. If no combination of methods connects the stored version to the current one, the load fails with an error naming the type and both versions.
/* 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;
}
/* A direct jump. When it exists, a v1 save takes this one method instead of
* the two-step chain, because the engine prefers the shortest path. */
[ScyllaMigration(fromVersion: 1, toVersion: 3)]
private static CharacterSave MigrateV1ToV3(CharacterSave old)
{
old.Stamina = 100;
old.MaxStamina = 100;
return old;
}
}
/* Loading a v1 blob migrates it to v3. */
string v1Blob = "{\"$version\":1,\"Name\":\"Hero\",\"Level\":5}";
SerializationResult<CharacterSave> upgraded = ScyllaSerialization.FromJSON<CharacterSave>(v1Blob);
/* upgraded.Value.Stamina == 100; upgraded.Value.MaxStamina == 100. */
/* Check a path ahead of time, for example in a test or a save-slot browser. */
TypeMetadata meta = TypeMetadataCache.GetMetadata<CharacterSave>();
if (meta.TryBuildMigrationPath(1, 3, out IReadOnlyList<MethodInfo> path))
{
Log.Info($"v1 -> v3 takes {path.Count} migration step(s).", LogCategory.Core);
}
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 treatment of 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. Because the serializer talks to IScyllaWriter and IScyllaReader, the same class works for both JSON and binary output. 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. */
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 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(transformData, withCustom);
Hand-roll output with the low-level reader and writer
Problem. You need ultra-low-level access: parsing a format from a legacy save, writing a 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 a reader or writer directly. ScyllaJSONWriter writes into a StringBuilder and ScyllaJSONReader reads a string; ScyllaBinaryWriter writes into an internal buffer and ScyllaBinaryReader reads a byte array. All four implement IScyllaReader / IScyllaWriter, which expose typed primitive reads and writes plus a token-stream model via PeekToken and SerializationToken, so code written against the interfaces works with either format.
using Scylla.Core.Util.Serialization;
using System.Text;
/* Writing JSON: construct a writer over a StringBuilder, apply settings, write tokens. */
var sb = new StringBuilder();
using (var writer = new ScyllaJSONWriter(sb))
{
writer.Configure(SerializationSettings.ReadableJSON); /* pretty print and indent */
WriteHero(writer);
}
string json = sb.ToString();
/* Writing binary: the same calls produce a tagged byte stream. */
byte[] blob;
using (var binaryWriter = new ScyllaBinaryWriter())
{
WriteHero(binaryWriter);
blob = binaryWriter.GetByteOutput();
}
static void WriteHero(IScyllaWriter writer)
{
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();
}
/* Reading: token-stream model. PeekToken inspects the next token without
* consuming it; Read* methods consume and return the typed value. The same
* method reads both formats. */
using (var reader = new ScyllaJSONReader(json))
{
ReadHero(reader);
}
using (var binaryReader = new ScyllaBinaryReader(blob))
{
ReadHero(binaryReader);
}
static void ReadHero(IScyllaReader reader)
{
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;
default:
reader.Skip(); /* skip values this code does not understand */
break;
}
}
reader.ReadEndObject();
}
/* Twelve SerializationToken values cover every shape: None, Null, Boolean,
* Integer, Float, String, StartObject, EndObject, PropertyName, StartArray,
* EndArray, Bytes. PeekToken returns None at end of input. In the binary
* format every integer width reports Integer, and float, double and decimal
* report Float. */
/* A binary reader can read a window of a larger buffer without copying it out. */
var windowReader = new ScyllaBinaryReader();
windowReader.Reset(containerBuffer, offset: 128, count: 512);
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;
/* 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(Weapon));
/* 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");
/* Migration lookups. GetMigration finds one method by its exact version pair;
* TryBuildMigrationPath and HasMigrationPath search for a chain. */
MethodInfo migrate = meta.GetMigration(fromVersion: 1, toVersion: 2);
bool hasPath = meta.HasMigrationPath(fromVersion: 1, toVersion: 3);
bool found = meta.TryBuildMigrationPath(1, 3, out IReadOnlyList<MethodInfo> steps);
/* 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; exposed for
* custom serializers that need shared-reference behavior. */
var tracker = new ReferenceTracker(ReferenceHandling.Preserve);
/* TryTrack returns true when the object was already tracked (a repeat), and
* false when it was tracked just now under a new ID. Either way refID holds
* the object's ID. */
bool seenBefore = 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. */
}
/* Register an object under an ID read from a $id marker. Returns false when
* the ID was already registered, which usually means a corrupt or hand-edited
* document. Register does the same without the check. */
bool fresh = tracker.TryTrackWithID(deserializedObject, referenceID: 5);
tracker.Register(referenceID: 6, 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), two reader/writer pairs, one base class for custom serializers, one reference tracker, three metadata types, five enums, and one exception. Most user code touches only the facade and the attributes. Everything lives in the Scylla.Core.Util.Serialization namespace.
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); /* not consulted by deserialization */
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 const string REF_ID_PROPERTY_NAME = "$id";
public const string REF_PROPERTY_NAME = "$ref";
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 bool EmitReferenceIDs { 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 WithEmitReferenceIDs (bool enabled = true);
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 }
public enum BinaryTypeTag : byte { Null = 0, Bool, Byte, SByte, Short, UShort, Int, UInt,
Long, ULong, Float, Double, Decimal, Char, String, Bytes,
StartObject, EndObject, PropertyName, StartArray, EndArray }
/* 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 WriteRawValue(string rawValue);
void WriteStartObject(); void WriteEndObject();
void WritePropertyName(string name);
void WriteStartArray(); void WriteEndArray();
string GetStringOutput();
byte[] GetByteOutput();
void Reset();
}
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();
string ReadNumberLexeme();
void ReadStartObject(); void ReadEndObject();
string ReadPropertyName();
void ReadStartArray(); void ReadEndArray();
int GetArrayLength();
void Skip();
void Reset();
void Reset(string data);
void Reset(byte[] data);
}
public sealed class ScyllaJSONWriter : IScyllaWriter
{
public ScyllaJSONWriter();
public ScyllaJSONWriter(StringBuilder output);
public void Configure(SerializationSettings settings);
public void SetOutput(StringBuilder output);
public void SetPrettyPrint(bool prettyPrint);
}
public sealed class ScyllaJSONReader : IScyllaReader
{
public ScyllaJSONReader();
public ScyllaJSONReader(string json);
public void SetInput(string json);
}
public sealed class ScyllaBinaryWriter : IScyllaWriter
{
public ScyllaBinaryWriter();
}
public sealed class ScyllaBinaryReader : IScyllaReader
{
public ScyllaBinaryReader();
public ScyllaBinaryReader(byte[] data);
public void Reset(byte[] data, int offset, int count);
}
/* 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); /* true when already tracked */
public bool TryTrackWithID(object obj, int referenceID); /* false when the ID was taken */
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; } /* the type's full name when no alias is set; only an index key then */
public bool HasExplicitAlias { get; }
public string TypeMarkerName { get; } /* what is written as $type: the alias, else the assembly-qualified name */
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 bool TryBuildMigrationPath(int fromVersion, int toVersion, out IReadOnlyList<MethodInfo> path);
}
public sealed class PropertyMetadata
{
public string Name { get; }
public string MemberName { get; }
/* + Type, Order, IsRequired, CanRead, CanWrite, DefaultValue, MemberInfo,
GetValue, SetValue, IsDefaultValue */
}
/* Exception. */
public class SerializationException : Exception
{
public SerializationException();
public SerializationException(string message);
public SerializationException(string message, Exception innerException);
}
/* Attributes. */
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)]
public sealed class ScyllaSerializableAttribute : Attribute
{
public ScyllaSerializableAttribute(int version = 1);
public int Version { get; }
public string TypeAlias { get; set; }
}
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
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.Field | AttributeTargets.Property)]
public sealed class ScyllaIgnoreAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public sealed class ScyllaIncludeAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Method, Inherited = false)]
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 behavioral switch.
Properties
| Property | Type | Default | Effect |
|---|---|---|---|
Format | SerializationFormat | JSON | JSON for human-readable UTF-8; Binary for the tagged binary stream written by ScyllaBinaryWriter. Read with the same format that wrote the data. |
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 | Preserve | Preserve writes $id / $ref to round-trip shared objects and cycles exactly; Error fails when any object is reached a second time; Ignore writes repeated occurrences as null. |
EmitReferenceIDs | bool | true | With Preserve, writes an explicit $id on the first occurrence of each tracked object so $ref resolves in any read order. false writes the older shape without $id. Has no effect with other modes. |
MaxDepth | int | 64 | Hard limit on nested object depth. Exceeding the limit produces a failed result with a depth error message. Raise for deeply nested data; lower as a guard against malicious input. |
IncludeTypeInfo | bool | true | When true, the reflection serializer writes a $type marker (the type alias, or the assembly-qualified name) for every object. When false, the marker is left out for objects whose runtime type equals the declared type. A polymorphic value always carries its marker. |
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 for pretty-printed JSON. Applied to pretty-printed JSON by the facade, and by a ScyllaJSONWriter configured with Configure(settings). |
IncludePrivateFields | bool | false | When true, the reflection serializer also reads and writes every non-public field and every non-public property that has both a getter and a setter, including those declared on base classes. false limits it to public members and members marked [ScyllaInclude]. |
CustomSerializers | IReadOnlyDictionary<Type, ITypeSerializer> | empty | Per-settings serializer overrides, checked before the global registry. Add via Builder.RegisterSerializer<T>(serializer); preferred over the global ScyllaSerialization.RegisterSerializer<T> for code that should not mutate shared state. |
Presets
| Preset | Format | Distinguishing properties | Use case |
|---|---|---|---|
SerializationSettings.Default | JSON | All defaults | Most cases. Compact UTF-8 JSON, references preserved with $id / $ref, $type and $version markers, max depth 64. |
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 a short name registered with TypeMetadataCache. |
[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 non-public member into serialization without changing its access level. |
[ScyllaMigration(fromVersion, toVersion)] | static method | Marks a static method as a migration from one schema version to a later one, which may skip versions. Signature: static T Migrate(T oldValue) where T is the declaring type. |
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 stored keys. C# identifiers tend to drift across refactors; explicit 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.Preserve(the default) is right for genuine graphs (entity trees with back-references, multi-parent state). UseErrorfor data that must be a strict tree, so an accidental shared reference fails loudly. AvoidIgnoreunless dropping repeated references is explicitly desired. - Leave
EmitReferenceIDson. The explicit$idis what keeps$refcorrect when null or default values are skipped or a document is edited by hand. Turn it off only for an external consumer that cannot handle the marker. - 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 and faster to parse, with no text, no whitespace, and no null/default values. Read the data back with the same settings. Pair withScyllaFileUtil.SaveSecureObjectfor opaque save data, or withScyllaFileUtil.WriteBytesAtomicwhen a crash must never leave a half-written save. - Use
SerializationSettings.ReadableJSONfor configuration files. The pretty-printed output is easy to inspect, easy to hand-edit, and easy to diff between revisions. - 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 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.TypeMetadata.TryBuildMigrationPathis a cheap way to assert in a test that every old version still has a path. - Treat stored type names as a contract. The default
$typemarker is an assembly-qualified name, so a rename or a namespace move changes it. Give types that appear in polymorphic positions of a save aTypeAlias, keep it unique, and never change it once a save has been written with it. - 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
- 26 Core Utilities – Compression
- 25 Core Utilities – Crypto
- 46 Core Utilities – File IO
- 11 Core Systems – 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.ScyllaBinaryReader - API reference:
Scylla.Core.Util.Serialization.ScyllaBinaryWriter - API reference:
Scylla.Core.Util.Serialization.BinaryTypeTag - 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