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
| Approach | Use For |
|---|---|
| Code Generation | Items, Mobiles, and any class inheriting ISerializable |
| Generic Persistence | Global systems, lookup tables, non-entity data |
| Entity Persistence | Custom types that need their own Serial and parallel serialization (like Items/Mobiles) |
- Code Generation
- Generic Persistence
- Entity Persistence
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
- Add
[SerializationGenerator(version)]and make the classpartial:[SerializationGenerator(0)]
public partial class ExampleItem : Item - Delete the
Serialconstructor. - Delete the
SerializeandDeserializemethods. - Add
[SerializableField(index)]to each field you want saved:[SerializableField(0)]
private string _exampleText; - Run
publish.cmd(orpublish.sh) to generate migration files.
The generator creates Namespace.TypeName.v0.json and Namespace.TypeName.Serialization.cs for you.
Global System Data
Use GenericPersistence for global data that doesn't belong to a specific entity. Subclass it and implement Serialize / Deserialize.
Here's a real example based on the disguise system, which tracks active disguise timers per player:
using System;
using System.Collections.Generic;
namespace Server.Items;
public class DisguisePersistence : GenericPersistence
{
private static DisguisePersistence _instance;
public static Dictionary<Mobile, Timer> Timers { get; } = new();
public static void Configure()
{
_instance = new DisguisePersistence();
}
public DisguisePersistence() : base("Disguises", 10)
{
}
public override void Serialize(IGenericWriter writer)
{
writer.WriteEncodedInt(Timers.Count);
foreach (var (m, timer) in Timers)
{
writer.Write(m);
writer.Write(timer.Next - Core.Now);
writer.Write(m.NameMod);
}
}
public override void Deserialize(IGenericReader reader)
{
var count = reader.ReadEncodedInt();
for (var i = 0; i < count; ++i)
{
var m = reader.ReadEntity<Mobile>();
var delay = reader.ReadTimeSpan();
var nameMod = reader.ReadString();
// Restore timer and state
CreateTimer(m, delay);
m.NameMod = nameMod;
}
}
public static void CreateTimer(Mobile m, TimeSpan delay) { /* ... */ }
}
Key points:
- The constructor takes a name (used for the save file path) and a priority (load order).
Configure()is automatically discovered and called at startup.- Data is saved to
Saves/Disguises/Disguises.bin. - You are responsible for reading/writing in the exact same order.
Custom Entity Types
Use GenericEntityPersistence<T> when you need custom types with their own Serial that are serialized in parallel -- the same way Items and Mobiles work internally. Each entity gets its own serialization thread for parallel world saves.
1. Define the persistence manager
namespace Server.Engines.BulkOrders;
public class BOBEntries : GenericEntityPersistence<IBOBEntry>
{
private static BOBEntries _instance;
public static void Configure()
{
_instance = new BOBEntries();
}
// name, priority, minSerial, maxSerial
public BOBEntries() : base("BOBEntries", 3, 0x1, 0x7FFFFFFF)
{
}
public static Serial NewBOBEntry => _instance.NewEntity;
public static void Add(IBOBEntry entity) => _instance.AddEntity(entity);
public static void Remove(IBOBEntry entity) => _instance.RemoveEntity(entity);
}
2. Define the entity base class
The base class uses standard [SerializationGenerator] attributes and manages its own Serial:
[SerializationGenerator(1)]
public abstract partial class BaseBOBEntry : IBOBEntry
{
[SerializableField(0, setter: "protected")]
private bool _requireExceptional;
[SerializableField(1, setter: "protected")]
private BODType _deedType;
[SerializableField(2, setter: "protected")]
private BulkMaterialType _material;
[SerializableField(3, setter: "protected")]
private int _amountMax;
[SerializableField(4)]
private int _price;
public Serial Serial { get; }
public bool Deleted { get; private set; }
public BaseBOBEntry()
{
Serial = BOBEntries.NewBOBEntry;
BOBEntries.Add(this);
}
public virtual void Delete()
{
Deleted = true;
BOBEntries.Remove(this);
}
}
Concrete subclasses inherit from this base and add their own serialized fields, just like how specific items inherit from Item.
Each entity has approximately 32 bytes of indexing overhead regardless of its data size. Don't use entity persistence for lightweight or high-volume data where GenericPersistence would suffice. Benchmark your world save sizes and times before committing to this pattern.
Attribute Reference
Class-Level Attributes
| Attribute | Target | Description |
|---|---|---|
[SerializationGenerator(version)] | Class | Enables code generation. version is the current serialization version number. |
[Constructible] | Constructor | Marks the constructor as available for the [add command. |
[TypeAlias("OldName")] | Class | Maps old type names for deserialization of legacy saves. |
Field-Level Attributes
| Attribute | Target | Description |
|---|---|---|
[SerializableField(index)] | Private field | Marks field for serialization at the given index. Generates a public PascalCase property. |
[InvalidateProperties] | Serializable field | Auto-calls InvalidateProperties() in the generated setter to refresh tooltips. |
[SerializedCommandProperty(level)] | Serializable field | Exposes the generated property to the [Props gump for in-game editing. |
[DeltaDateTime] | DateTime field | Stores as offset from current time. Ensures expiration dates survive restarts. |
[EncodedInt] | int field | Uses variable-length encoding (1 byte for 0--127, 2 bytes for 128--16383, etc.). |
[InternString] | string field | Deduplicates identical strings in memory via string.Intern(). |
[Tidy] | Collection field | Removes null and deleted entries after deserialization. |
Method-Level Attributes
| Attribute | Target | Description |
|---|---|---|
[AfterDeserialization] | Private method | Called after fields are loaded. Use this to restart timers and set up derived state. |
[DeserializeTimerField(index)] | Method taking TimeSpan | Custom 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)whereXis the previous version. VXContentis auto-generated with PascalCase properties matching the old fields.- New fields not present in the old version get their default values.
- Add one
MigrateFromfor each older version that needs a migration path.
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:
- Set
encodedtofalseif the old code usedreader.ReadInt()for the version:[SerializationGenerator(3, false)] // Old version was 2, bumped to 3 - 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.
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:
| Value | Timing | Use When |
|---|---|---|
true (default) | Immediately after this entity loads | Restarting timers, setting up derived state from own fields |
false | After all entities in the world are loaded | Logic 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
- Class must be
partial-- the generator adds code to your class via a separate file. TimerExecutionTokenmust NOT have[SerializableField]-- it is not serializable. Restart timers in[AfterDeserialization].- Call
this.MarkDirty()in any custom property setter to flag the entity for saving. - Use
[AfterDeserialization]to restart timers after world load -- never create timers inside the deserialization path directly. - For new classes, omit the
encodedparameter:[SerializationGenerator(0)]. - 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
}
}