ModernUO/dev-docs/serialization.md
Kamron Batman 1391c563fe
chore: Adds AI instructions and SKILLs for ModernUO codebase (#2347)
Summary

  - Adds CLAUDE.md at repo root with 14 terse code audit rules (always loaded, low token cost)
  - Adds pointer files for other AI tools: AGENTS.md (Codex), GEMINI.md, .github/COPILOT-INSTRUCTIONS.md (Copilot), .cursorrules (Cursor) — all redirect to CLAUDE.md as single source of truth
  - Gitignores /.claude so personal AI config isn't distributed
  - Moves Claude skills to dev-docs/claude-skills/ (opt-in, not auto-loaded)
  - Adds 14 dev-docs covering codebase conventions

  Code Audit Rules (in CLAUDE.md)

  1. LINQ tiered rules (Tier 1 free, Tier 2 warm, Tier 3 forbidden)
  2. No Console.WriteLine — use LogFactory.GetLogger()
  3. No concurrency primitives in game code
  4. No World.Mobiles/World.Items iteration
  5. Clean up refs in OnDelete()/OnAfterDelete()
  6. Cancel timers in OnDelete()/OnAfterDelete()
  7. STArrayPool<T>.Shared not ArrayPool<T>.Shared
  8. PooledRefList<T> not new List<T>() on hot paths
  9. Serialization: partial class, [Constructible], no serialized TimerExecutionToken
  10. No Task.Run/new Thread() in game code
  11. Never assume era — ask which expansion
  12. _camelCase fields, PascalCase properties/methods
  13. No empty gumps — use DisplayTo() pattern
  14. PropertyList string literals must be {} holes, cliloc-as-argument uses :#
2026-03-01 11:42:19 -08:00

14 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, false)]
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, false)] on the class
  3. partial class declaration
  4. [Constructible] on the parameterless constructor

Item with Serialized Fields

[SerializationGenerator(0, false)]
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, encodedVersion)]

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

Parameter Type Default Description
version int Required Current serialization version number
encodedVersion bool true false for Item/Mobile subclasses; true for standalone serializable types

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

[SerializationGenerator(3, false)]  // Version 3, Item/Mobile subclass
public partial class MyItem : Item { }

[SerializationGenerator(0)]  // Version 0, standalone type (encodedVersion=true)
public partial class MyData { }

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

Target: Parameterless private method Effect: Called after all fields are deserialized.

Parameter Type Default Description
skipOnDelete bool true Skip if entity is being deleted

Common uses:

  • Restart timers
  • Set up object relationships
  • Calculate derived values
  • Clean up empty collections
[AfterDeserialization]
private void AfterDeserialization()
{
    // Restart timers
    Timer.StartTimer(TimeSpan.FromMinutes(1), CheckExpiry, out _timerToken);

    // Set up relationships
    if (_owner != null)
        _owner.OwnedItems.Add(this);

    // Clean up
    if (_entries?.Count == 0)
        _entries = null;
}

[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, false)]
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

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

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, false)]  // 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