modernuo/dev-docs/serialization.md
Kamron Batman 29e4ecdb1b
fix: Preserve corpse notoriety across server restart (#2426)
BaseCreatures are deleted on death (Mobile.OnDeath calls Delete for non-players), so after save/restart the corpse's _owner reference resolves to null. CorpseNotoriety gated its entire creature branch on `target.Owner is BaseCreature`, falling through to player-corpse logic once the reference vanished. That made monster corpses turn red (body.IsMonster -> Murderer) and innocent NPC corpses turn grey (null is not PlayerMobile -> CanBeAttacked) on the next restart.

Snapshots the relevant owner state into CorpseFlag at corpse creation: OwnerWasBaseCreature, OwnerWasSummoned, OwnerWasAnimatedDead. Folds the standalone _murderer bool into CorpseFlag.Murderer for consistency with Criminal. CorpseNotoriety now consults the flags so the creature branch stays correct without a live mobile reference.

Bumps Corpse serialization to v16 with a MigrateFrom(V15Content) that maps the old Murderer bool onto the new flag. Pre-fix corpses already on disk decay within 7 minutes; their first post-restart color may be wrong, which is acceptable.

Also documents that the schema generator must be run after every version bump (`dotnet tool run ModernUOSchemaGenerator -- ModernUO.slnx`) since `dotnet build` does not emit migration JSON files.
2026-05-03 02:15:01 -07:00

17 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, setter, saveIf)]

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

Parameter Type Default Description
index int Required Serialization order (0-based)
setter string null (public) "private" or "internal" to restrict setter
saveIf string null Method name returning bool for conditional save
[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 { _charges = value; this.MarkDirty(); }
}

[SerializableProperty(index, useField)]

Target: Property with custom get/set logic Use when: You need non-trivial getter/setter logic

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;
    set
    {
        _maxItems = value;
        InvalidateProperties();
        this.MarkDirty();  // REQUIRED in custom setters
    }
}

[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).

[DeltaDateTime]

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

This ensures timers and expiration dates survive server restarts correctly.

[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();
    }
}

[DeserializeTimerField(fieldIndex)]

Target: Method taking TimeSpan parameter Effect: Custom deserialization for Timer fields. The timer is saved as remaining delay.

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

[DeserializeTimerField(0)]
private void DeserializeDecayTimer(TimeSpan delay)
{
    _decayTimer = Timer.DelayCall(delay, Delete);
    _decayTimer.Start();
}

[SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)]

Conditional serialization -- skip fields that have their default value.

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

[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeMaxItems() => _maxItems != -1;

[SerializableFieldDefault(0)]
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