ModernUO/dev-docs/serialization.md
Kamron Batman 76bbcd88c4
docs: update serialization docs and skills for generator v4
Replaces order-based linkage ([SerializableFieldSaveFlag]/[SerializableFieldDefault])
with field-side [SaveFlag(nameof(...))], [TimerDrift]/[DeserializeTimerField] with
[DeserializeTimer(nameof(Method), wallClock)], and documents the [SerializableField]
setter hooks (allowFieldChange/fieldChanged), the anchored-time semantics for
drifting timers, [AnchoredDateTime], and the timer MigrateFrom pattern. Narrows
[SerializableProperty] guidance to custom getters. Also fixes the documented
[SerializableField] signature (the saveIf parameter never existed) and refreshes
real-code examples that were converted in the v4 migration PRs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:26:56 -07:00

22 KiB

ModernUO Serialization System

ModernUO uses a source generator-based serialization system that automatically generates Serialize() and Deserialize() methods from attribute-decorated fields and properties.

Overview

The serialization system is provided by two NuGet packages:

  • ModernUO.Serialization.Annotations - Defines attributes
  • ModernUO.Serialization.Generator - C# source generator that produces serialization code

Source: https://github.com/modernuo/SerializationGenerator

Quick Start

Minimal Serializable Item

using ModernUO.Serialization;

namespace Server.Items;

[SerializationGenerator(0)]
public partial class SimpleItem : Item
{
    [Constructible]
    public SimpleItem() : base(0x1234)
    {
        Weight = 1.0;
    }

    public override string DefaultName => "a simple item";
}

Key requirements:

  1. using ModernUO.Serialization; for the attributes
  2. [SerializationGenerator(0)] on the class
  3. partial class declaration
  4. [Constructible] on the parameterless constructor

Item with Serialized Fields

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

    [SerializableField(1)]
    [SerializedCommandProperty(AccessLevel.GameMaster)]
    private Mobile _owner;

    private TimerExecutionToken _glowTimer;  // NOT serialized

    [Constructible]
    public ChargedGem() : base(0x1EA7)
    {
        _charges = Utility.RandomMinMax(5, 15);
        Light = LightType.Circle150;
        Timer.StartTimer(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2), Glow, out _glowTimer);
    }

    [AfterDeserialization]
    private void AfterDeserialization()
    {
        Timer.StartTimer(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2), Glow, out _glowTimer);
    }

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

    private void Glow()
    {
        if (_charges > 0)
            Effects.SendLocationParticles(this, 0x376A, 9, 10, 5042);
    }

    public override void GetProperties(IPropertyList list)
    {
        base.GetProperties(list);
        list.Add(1060741, $"{_charges}");  // "charges: ~1_val~"
    }
}

Attribute Reference

[SerializationGenerator(version, encoded)]

Target: Class declaration Required: Yes, for any serializable type

Parameter Type Default Description
version int Required Current serialization version number
encoded bool true How the version was written in old saves. Only needed when migrating from pre-codegen Serialize/Deserialize — pass false if old code used reader.ReadInt()

The version number determines which Deserialize overload is called. When you add, remove, or reorder fields, increment the version.

For new classes, omit the encoded parameter:

[SerializationGenerator(0)]  // New class — omit encoded
public partial class MyItem : Item { }

When migrating from RunUO/pre-codegen classes that used reader.ReadInt() for version, pass false and bump the version:

[SerializationGenerator(3, false)]  // Old version was 2 → bump to 3; false because old saves used ReadInt()
public partial class MyItem : Item { }

See dev-docs/runuo-migration-docs/02-serialization.md for complete migration guidance.

[SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)]

Target: Private field (_camelCase) Generates: Public PascalCase property with get/set

Parameter Type Default Description
index int Required Serialization order (0-based)
getter string "public" Getter accessibility
setter string "public" "private" or "internal" to restrict setter
isVirtual bool false Generate a virtual property
fieldChanged string null nameof of a void Method(T oldValue, T newValue) invoked by the generated setter after assignment
allowFieldChange string null nameof of a bool Method(ref T value) invoked before assignment; coerce the value through the ref parameter, or return false to reject the change
[SerializableField(0)]                              // Public property
private int _charges;

[SerializableField(1, setter: "private")]            // Private setter
private string _name;

[SerializableField(2, setter: "internal")]           // Internal setter
private DateTime _created;

The generated property for _charges would be:

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

Setter hooks replace most hand-written [SerializableProperty] setters. The generated pipeline is: equality check → allowFieldChange (coerce/veto) → assignment → MarkDirtyInvalidateProperties (if declared) → fieldChanged. The gate runs before assignment, so the field itself still holds the old value inside it.

[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _charges;

private bool AllowChargesChange(ref int value)
{
    value = Math.Clamp(value, 0, MaxCharges);   // coerce, or return false to veto
    return true;
}

[SerializableField(1, fieldChanged: nameof(OnOwnerChanged))]
private Mobile _owner;

// oldValue makes unsubscribe/resubscribe patterns trivial
private void OnOwnerChanged(Mobile oldValue, Mobile newValue)
{
    oldValue?.Followers.Remove(this);
    newValue?.Followers.Add(this);
}

Both hooks require a generated setter — declaring one on a readonly field or with setter: null is a compile-time error (SG3018), and a named method that is missing or has the wrong signature is too (SG3015).

[SerializableProperty(index, useField)]

Target: Property with custom get/set logic Use when: You need a custom getter (fallback defaults, lazy or self-healing reads) or setter semantics the field hooks cannot express (work that must run on equal assignment, pre-assignment state capture). For setters that only coerce, veto, or run post-change side effects, prefer [SerializableField] with allowFieldChange/fieldChanged instead — the generated setter handles equality, MarkDirty, and ordering for you.

Parameter Type Default Description
index int Required Serialization order
useField string null Explicit backing field name
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int MaxItems
{
    get => _maxItems == -1 ? DefaultMaxItems : _maxItems;   // custom getter: the reason this is a property
    set
    {
        _maxItems = value;
        InvalidateProperties();
        this.MarkDirty();  // REQUIRED in custom setters
    }
}

Note: the fieldChanged/allowFieldChange hooks are [SerializableField] arguments and cannot be declared on a [SerializableProperty] — its setter is your own code, so call your methods from the setter directly.

[InvalidateProperties]

Target: [SerializableField]-decorated field Effect: Calls InvalidateProperties() when the property setter is invoked, refreshing the client tooltip.

[SerializableField(0)]
[InvalidateProperties]
private int _charges;
// Generated setter calls InvalidateProperties() automatically

[SerializedCommandProperty(accessLevel)]

Target: [SerializableField]-decorated field Effect: Exposes the generated property to the [Props gump for in-game editing.

[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
// GMs can view/edit via [Props command

Overloads:

  • [SerializedCommandProperty(AccessLevel.GameMaster)] - Same read/write level
  • [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - Different read/write levels

[EncodedInt]

Target: int field or property Effect: Uses variable-length encoding. 1 byte for 0-127, 2 bytes for 128-16383, etc.

Best for fields that are usually small values (counts, IDs, indexes).

[AnchoredDateTime]

Target: DateTime field Effect: Stores the absolute UTC instant; at load it is shifted forward by the downtime between the save and the load (using the save-start anchor in the save's index file), so server downtime does not consume the remaining time. DateTime.MinValue/MaxValue sentinels pass through unshifted.

Prefer this for deadlines and "elapsed while running" values. Unlike [DeltaDateTime], the stored bytes do not change on every save when the value is unchanged, keeping idle saves byte-stable.

[AnchoredDateTime]
[SerializableField(0)]
private DateTime _expireTime;

[DeltaDateTime]

Target: DateTime field Effect: Stores as offset from current time rather than absolute timestamp.

Legacy encoding for surviving restarts: it rewrites the bytes on every save even when the value has not changed. Prefer [AnchoredDateTime] for new fields; converting an existing field between the two changes the wire format and requires a version bump.

[DeltaDateTime]
[SerializableField(0)]
private DateTime _expireTime;

[InternString]

Target: string field Effect: Calls string.Intern() on deserialization to deduplicate identical strings in memory.

Best for frequently repeated strings (usernames, template names).

[Tidy]

Target: Collection field (List<T>, Dictionary<K,V>, etc.) Effect: Removes null and deleted entries from the collection after deserialization.

[Tidy]
[SerializableField(0)]
private List<Mobile> _followers;
// After loading, any deleted/null mobiles are removed

[CanBeNull]

Target: Any reference-type field Effect: Allows the field to be null during deserialization without error.

[CanBeNull]
[SerializableField(0)]
private Mobile _target;

[AfterDeserialization(synchronous)]

Target: Parameterless private method Effect: Called after fields are deserialized. The synchronous parameter controls execution timing.

Parameter Type Default Description
synchronous bool true true = runs immediately after this entity's deserialization. false = runs after ALL entities in the world are deserialized.

When to use true (default):

  • Restarting timers for this entity
  • Setting up derived values from this entity's own fields
  • Cleaning up this entity's own collections

When to use false (deferred):

  • Logic that adds or deletes entities (e.g., Delete())
  • Logic that depends on other entities being fully loaded (cross-entity references)
  • Logic that affects game state
// Sync (default) — only touches own fields
[AfterDeserialization]
private void AfterDeserialization()
{
    Timer.StartTimer(TimeSpan.FromSeconds(5), CheckExpiry, out _timerToken);
}

// Deferred — calls Delete() which affects game state
[AfterDeserialization(false)]
private void AfterDeserialization()
{
    if (_expireTimer == null)
    {
        Delete();
    }
}

[DeserializeTimer(nameof(Method), wallClock)]

Target: Timer-typed [SerializableField] or [SerializableProperty] member Effect: Declares how the timer is stored and restored. Required on every serializable timer (SG3008 otherwise).

By default the timer's next tick is stored as anchored time: server downtime does not consume the remaining delay, and idle saves are byte-stable. Pass wallClock: true to store an absolute deadline instead (the delay is then negative when the deadline passed during downtime).

The named method — void Method(TimeSpan delay) — is invoked only when a timer was actually running at save, with the remaining delay. There is no sentinel value to check.

[SerializableField(0, setter: "private")]
[DeserializeTimer(nameof(DeserializeDecayTimer))]
private Timer _decayTimer;

private void DeserializeDecayTimer(TimeSpan delay) => _decayTimer = Timer.DelayCall(delay, Delete);

Switching an existing timer between drifting and wallClock changes the wire format — bump the class version and add a MigrateFrom. The old-version content struct exposes the timer's XxxNext (DateTime) and XxxDelay (TimeSpan, TimeSpan.MinValue when no timer was running):

private void MigrateFrom(V3Content content)
{
    if (content.DecayTimerDelay != TimeSpan.MinValue)
    {
        DeserializeDecayTimer(content.DecayTimerDelay);
    }
}

[SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))]

Target: the serializable field or property itself Conditional serialization -- skip fields that have their default value.

The first method (bool Method()) decides whether the value is written. The optional second method (returning the field's type, no parameters) supplies the value at load when it was not written; when omitted, the field keeps its default value.

[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeCharges), nameof(ChargesDefaultValue))]
private int _charges;

private bool ShouldSerializeCharges() => _charges != -1;

private int ChargesDefaultValue() => -1;

Works on [SerializableProperty] members the same way:

[EncodedInt]
[SerializableProperty(0)]
[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))]
public int MaxItems
{
    get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
    set { _maxItems = value; this.MarkDirty(); }
}

private bool ShouldSerializeMaxItems() => _maxItems != -1;

private int MaxItemsDefaultValue() => -1;

[TypeAlias(params string[] aliases)]

Target: Class declaration Effect: Maps old type names to this class for deserialization of old saves.

[TypeAlias("Server.Mobiles.Bear")]
[SerializationGenerator(0)]
public partial class BlackBear : BaseCreature { }

[Constructible(accessLevel)]

Target: Constructor Effect: Marks constructor as available for the [add command.

[Constructible]                              // Any player can [add
public MyItem() : base(0x1234) { }

[Constructible(AccessLevel.Administrator)]   // Only admins can [add
public SpecialItem() : base(0x5678) { }

Version Migration

When to Increment Version

Increment the version number when you:

  • Add a new serialized field
  • Remove a serialized field
  • Reorder fields (change indexes)
  • Change a field's type

Migration Schema Files

Located in Projects/Server/Migrations/ and Projects/UOContent/Migrations/. Format: Namespace.TypeName.vN.json

These JSONs are read by the source generator at compile time to build the VXContent types referenced by MigrateFrom. They are not emitted by dotnet build — you must run the schema generator tool after every version bump to produce the new vN.json:

dotnet tool restore
dotnet tool run ModernUOSchemaGenerator -- ModernUO.slnx

Verify Namespace.TypeName.v{N+1}.json appears in the appropriate Migrations/ folder, then commit it with the code change. Without the new JSON, the next version bump won't be able to construct V{N+1}Content and will fail to compile. Equivalent shortcut via the build tool: dotnet run --project Projects/BuildTool -- --action migrate.

Example: Server.Accounting.Account.v6.json

{
  "version": 6,
  "type": "Server.Accounting.Account",
  "properties": [
    {
      "name": "Username",
      "type": "string",
      "rule": "PrimitiveTypeMigrationRule",
      "ruleArguments": ["InternString"]
    },
    {
      "name": "Mobiles",
      "type": "Server.Mobile[]",
      "rule": "ArrayMigrationRule",
      "ruleArguments": ["Server.Mobile", "SerializableInterfaceMigrationRule"]
    }
  ]
}

Migration Rule Types

Rule Description
PrimitiveTypeMigrationRule Basic types: int, string, bool, DateTime, etc.
EnumMigrationRule Enum values
ListMigrationRule List<T>
ArrayMigrationRule T[]
DictionaryMigrationRule Dictionary<K,V>
SerializableInterfaceMigrationRule Objects implementing ISerializable
SerializationMethodSignatureMigrationRule Objects with custom Deserialize methods

MigrateFrom Pattern

When you bump the [SerializationGenerator] version number, you must add a MigrateFrom method to handle the transition from the previous version. The serialization generator creates a VXContent struct (where X is the previous version number) containing PascalCase properties matching the old serialized fields.

// Version bumped from 0 to 1 (added _quality field)
[SerializationGenerator(1)]
public partial class MagicGem : Item
{
    [SerializableField(0)]
    private int _charges;

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

// In MagicGem.Migrations.cs:
public partial class MagicGem
{
    private void MigrateFrom(V0Content content)
    {
        _charges = content.Charges;
        // _quality defaults to GemQuality.Rough (default enum value)
    }
}

Key rules:

  • The method signature is 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
  • Organize migrations in a separate .Migrations.cs partial file for clarity

Deserialize vs MigrateFrom — Know the Difference

WARNING: Do NOT modify Deserialize(IGenericReader reader, int version) when bumping SerializationGenerator versions.

The two mechanisms serve different purposes:

Method When It Runs Purpose
Deserialize(IGenericReader reader, int version) Loading saves from before the class was converted to the SerializationGenerator One-time legacy migration only
MigrateFrom(VXContent content) Transitioning between SerializationGenerator versions All post-codegen version bumps

The Deserialize function exists only to handle deserialization of entities saved before they used [SerializationGenerator]. Once a class uses the generator, all future version transitions must use MigrateFrom.


Extension Methods

From Projects/Server/Serialization/ISerializableExtensions.cs:

// Mark entity as dirty (must be called in custom property setters)
entity.MarkDirty();

// Collection operations (auto-mark dirty)
entity.Add(list, value);
entity.Add(dict, key, value);
entity.Remove(list, value);
entity.Clear(list);

// Timer operations (auto-mark dirty)
entity.Stop(timer);
entity.Start(timer);
entity.Restart(timer, delay, interval);
entity.Stop(ref timer);  // Stops and nulls the reference

Complete Example: Versioned Item

using ModernUO.Serialization;
using Server.Targeting;

namespace Server.Items;

public enum GemQuality
{
    Rough,
    Cut,
    Flawless
}

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

    [SerializableField(1)]  // Added in version 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}");  // charges: ~1_val~
        list.Add($"{"Quality: "}{_quality}");
    }

    public override void OnDoubleClick(Mobile from)
    {
        if (!IsChildOf(from.Backpack))
        {
            from.SendLocalizedMessage(1042001);  // Must be in backpack
            return;
        }

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

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

Key File Locations

File Description
Projects/Server/Serialization/ISerializableExtensions.cs MarkDirty(), collection helpers
Projects/Server/Migrations/*.v*.json Server migration schemas
Projects/UOContent/Migrations/*.v*.json Content migration schemas
Projects/UOContent/Mobiles/Animals/Bears/BlackBear.cs Simple creature example
Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs Fields + timer token
Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs Custom properties
Projects/UOContent/Accounting/Account.cs Complex versioned type
Projects/UOContent/Items/Aquarium/Aquarium.cs Timer deserialization
Projects/Server/Items/Container.cs Conditional serialization