Skip to main content

Serialization

ModernUO uses a source generator-based serialization system. Decorate fields with attributes, and the generator produces Serialize() / Deserialize() methods automatically.


When to Use Which

ApproachUse For
Code GenerationItems, Mobiles, and any class inheriting ISerializable
Generic PersistenceGlobal systems, lookup tables, non-entity data
Entity PersistenceCustom types that need their own Serial and parallel serialization (like Items/Mobiles)

Before and After

Old way -- manual serialization:

public class ExampleItem : Item
{
private string _exampleText;

[CommandProperty(AccessLevel.GameMaster)]
public string ExampleText
{
get => _exampleText;
set
{
if (value != _exampleText)
{
_exampleText = value;
this.MarkDirty();
}
}
}

[Constructible]
public ExampleItem(string text) : base(0)
{
_exampleText = text;
}

public ExampleItem(Serial serial) : base(serial) { }

public override void Serialize(IGenericWriter writer)
{
base.Serialize(writer);
writer.WriteEncodedInt(0);
writer.Write(_exampleText);
}

public override void Deserialize(IGenericReader reader)
{
base.Deserialize(reader);
var version = reader.ReadEncodedInt();
_exampleText = reader.ReadString();
}
}

New way -- source-generated:

[SerializationGenerator(0)]
public partial class ExampleItem : Item
{
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private string _exampleText;

[Constructible]
public ExampleItem(string text) : base(0)
{
_exampleText = text;
}
}

Step by Step

  1. Add [SerializationGenerator(version)] and make the class partial:
    [SerializationGenerator(0)]
    public partial class ExampleItem : Item
  2. Delete the Serial constructor.
  3. Delete the Serialize and Deserialize methods.
  4. Add [SerializableField(index)] to each field you want saved:
    [SerializableField(0)]
    private string _exampleText;
  5. Run publish.cmd (or publish.sh) to generate migration files.

The generator creates Namespace.TypeName.v0.json and Namespace.TypeName.Serialization.cs for you.


Attribute Reference

Class-Level Attributes

AttributeTargetDescription
[SerializationGenerator(version)]ClassEnables code generation. version is the current serialization version number.
[Constructible]ConstructorMarks the constructor as available for the [add command.
[TypeAlias("OldName")]ClassMaps old type names for deserialization of legacy saves.

Field-Level Attributes

AttributeTargetDescription
[SerializableField(index)]Private fieldMarks field for serialization at the given index. Generates a public PascalCase property.
[InvalidateProperties]Serializable fieldAuto-calls InvalidateProperties() in the generated setter to refresh tooltips.
[SerializedCommandProperty(level)]Serializable fieldExposes the generated property to the [Props gump for in-game editing.
[DeltaDateTime]DateTime fieldStores as offset from current time. Ensures expiration dates survive restarts.
[EncodedInt]int fieldUses variable-length encoding (1 byte for 0--127, 2 bytes for 128--16383, etc.).
[InternString]string fieldDeduplicates identical strings in memory via string.Intern().
[Tidy]Collection fieldRemoves null and deleted entries after deserialization.

Method-Level Attributes

AttributeTargetDescription
[AfterDeserialization]Private methodCalled after fields are loaded. Use this to restart timers and set up derived state.
[DeserializeTimerField(index)]Method taking TimeSpanCustom deserialization for Timer fields. The timer is saved as remaining delay.

Serializable Fields in Detail

Basic Field

[SerializableField(0)]
private int _charges;

The generator creates:

public int Charges
{
get => _charges;
set { _charges = value; this.MarkDirty(); }
}

Field with Tooltip Refresh and GM Access

[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;

Private or Internal Setter

[SerializableField(0, setter: "private")]
private string _name;

Custom Property Logic

Use [SerializableProperty] when you need non-trivial getter/setter logic:

[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int MaxItems
{
get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
set
{
_maxItems = value;
InvalidateProperties();
this.MarkDirty(); // REQUIRED in custom setters
}
}

Version Migration

When you add, remove, or reorder serialized fields, bump the version number.

Adding a Field

// Version 0 had only _charges. Version 1 adds _quality.
[SerializationGenerator(1)]
public partial class MagicGem : Item
{
[SerializableField(0)]
private int _charges;

[SerializableField(1)] // New in v1
private GemQuality _quality;

[Constructible]
public MagicGem() : base(0x1EA7)
{
_charges = Utility.RandomMinMax(5, 15);
_quality = GemQuality.Rough;
}
}

After running publish, the generator creates a V0Content struct. You must provide a migration:

// In MagicGem.Migrations.cs (separate partial file)
public partial class MagicGem
{
private void MigrateFrom(V0Content content)
{
_charges = content.Charges;
// _quality gets its default value (GemQuality.Rough)
}
}

The MigrateFrom Pattern

  • Method signature: private void MigrateFrom(VXContent content) where X is the previous version.
  • VXContent is auto-generated with PascalCase properties matching the old fields.
  • New fields not present in the old version get their default values.
  • Add one MigrateFrom for each older version that needs a migration path.
tip

Since the class is partial, create a standalone MyClass.Migrations.cs file to keep migrations organized.

Migrating from Pre-Codegen

To migrate a class that previously used manual Serialize/Deserialize:

  1. Set encoded to false if the old code used reader.ReadInt() for the version:
    [SerializationGenerator(3, false)] // Old version was 2, bumped to 3
  2. Keep the old deserialization logic as a private method:
    private void Deserialize(IGenericReader reader, int version)
    {
    // Old deserialization logic here
    }

This method is called automatically for saves that predate the serialization generator.

warning

Never modify Deserialize(IGenericReader reader, int version) for post-codegen version bumps. That method only handles legacy (pre-codegen) saves. All new version transitions must use MigrateFrom.


After Deserialization

Use [AfterDeserialization] to run code after an entity's fields are loaded:

[AfterDeserialization]
private void AfterDeserialization()
{
// Restart timers, compute derived values
Timer.StartTimer(TimeSpan.FromSeconds(5), CheckExpiry, out _timerToken);
}

The attribute accepts an optional synchronous parameter:

ValueTimingUse When
true (default)Immediately after this entity loadsRestarting timers, setting up derived state from own fields
falseAfter all entities in the world are loadedLogic that depends on other entities, calls Delete(), or affects game state
[AfterDeserialization(false)]
private void AfterDeserialization()
{
if (_expireTime < Core.Now)
{
Delete(); // Safe -- all entities are loaded
}
}

Important Rules

  1. Class must be partial -- the generator adds code to your class via a separate file.
  2. TimerExecutionToken must NOT have [SerializableField] -- it is not serializable. Restart timers in [AfterDeserialization].
  3. Call this.MarkDirty() in any custom property setter to flag the entity for saving.
  4. Use [AfterDeserialization] to restart timers after world load -- never create timers inside the deserialization path directly.
  5. For new classes, omit the encoded parameter: [SerializationGenerator(0)].
  6. Field index order matters -- fields are serialized/deserialized in index order. Never reorder without bumping the version.

Complete Example

using ModernUO.Serialization;

namespace Server.Items;

public enum GemQuality { Rough, Cut, Flawless }

[SerializationGenerator(1)]
public partial class MagicGem : Item
{
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;

[SerializableField(1)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private GemQuality _quality;

private TimerExecutionToken _pulseTimer;

[Constructible]
public MagicGem() : base(0x1EA7)
{
_charges = Utility.RandomMinMax(5, 15);
_quality = GemQuality.Rough;
Weight = 1.0;
Light = LightType.Circle150;
StartPulse();
}

public override string DefaultName => "a magic gem";

private void StartPulse()
{
Timer.StartTimer(
TimeSpan.FromSeconds(3),
TimeSpan.FromSeconds(3),
Pulse,
out _pulseTimer
);
}

[AfterDeserialization]
private void AfterDeserialization() => StartPulse();

public override void OnAfterDelete()
{
_pulseTimer.Cancel();
base.OnAfterDelete();
}

private void Pulse()
{
if (_charges <= 0)
{
_pulseTimer.Cancel();
return;
}

Effects.SendLocationParticles(this, 0x376A, 9, 10, 5042);
}

public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
list.Add(1060741, $"{_charges}");
list.Add($"{"Quality: "}{_quality}");
}

public override void OnDoubleClick(Mobile from)
{
if (!IsChildOf(from.Backpack))
{
from.SendLocalizedMessage(1042001);
return;
}

if (_charges <= 0)
{
from.SendMessage("The gem is depleted.");
return;
}

_charges--;
InvalidateProperties();
this.MarkDirty();
from.SendMessage("The gem pulses with energy!");
}
}

Migration file (MagicGem.Migrations.cs):

namespace Server.Items;

public partial class MagicGem
{
private void MigrateFrom(V0Content content)
{
_charges = content.Charges;
// _quality defaults to GemQuality.Rough
}
}