ModernUO/dev-docs/runuo-migration-docs/02-serialization.md
Kamron Batman 4f9bc1d9f6
feat: Adds AI skills to migrate from RunUO (#2366)
## Summary

Adds comprehensive RunUO → ModernUO migration documentation and Claude AI skills to help shard owners and script authors convert RunUO 2.7 code to ModernUO.

- **10 migration skills** (`dev-docs/claude-skills/migrate-from-runuo/`) — system-by-system conversion guides (foundation, serialization, timers, gumps, packets, property lists, commands/events, persistence, items/mobiles, systems/engines)
- **12 reference docs** (`dev-docs/runuo-migration-docs/`) — deep-reference with before/after examples, API mapping tables, edge cases, and gotchas
- **Updated existing skills** — `modernuo-timers`, `modernuo-serialization`, and `modernuo-threading` now document that `Serialize()` runs on background threads and timers are not thread-safe
- **Updated `CLAUDE.md`** — added migration skill lookup table

### Key migration patterns covered
- Manual `Serialize()`/`Deserialize()` → source-generated `[SerializableField]`
- `Packet` class hierarchy → static `SpanWriter`/`SpanReader` methods
- `Timer` subclasses → `TimerExecutionToken` fire-and-forget
- `Gump` → `StaticGump<T>`/`DynamicGump` with builders
- `EventSink.WorldSave` → `GenericPersistence`
- `ObjectPropertyList` → `IPropertyList` with string hole rules
- Universal changes: naming (`m_` → `_`), `[Constructable]` → `[Constructible]`, logging, spatial queries
2026-03-13 00:33:45 -07:00

12 KiB

Serialization Migration

Overview

This is the most impactful migration change. RunUO uses manual Serialize(GenericWriter)/Deserialize(GenericReader) overrides. ModernUO uses a source generator that automatically produces serialization code from attribute-decorated fields.

RunUO Pattern

using Server;

namespace Server.Items
{
    public class ChargedGem : Item
    {
        private int m_Charges;
        private Mobile m_Owner;

        [CommandProperty(AccessLevel.GameMaster)]
        public int Charges { get { return m_Charges; } set { m_Charges = value; InvalidateProperties(); } }

        [CommandProperty(AccessLevel.GameMaster)]
        public Mobile Owner { get { return m_Owner; } set { m_Owner = value; } }

        [Constructable]
        public ChargedGem() : base(0x1EA7)
        {
            m_Charges = 10;
            Weight = 1.0;
        }

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

        public override void Serialize(GenericWriter writer)
        {
            base.Serialize(writer);
            writer.Write((int)1); // version

            // Version 1
            writer.Write(m_Owner);

            // Version 0
            writer.Write(m_Charges);
        }

        public override void Deserialize(GenericReader reader)
        {
            base.Deserialize(reader);
            int version = reader.ReadInt();

            switch (version)
            {
                case 1:
                {
                    m_Owner = reader.ReadMobile();
                    goto case 0;
                }
                case 0:
                {
                    m_Charges = reader.ReadInt();
                    break;
                }
            }
        }

        public override void GetProperties(ObjectPropertyList list)
        {
            base.GetProperties(list);
            list.Add(1060741, m_Charges.ToString()); // charges: ~1_val~
        }
    }
}

ModernUO Equivalent

using ModernUO.Serialization;

namespace Server.Items;

[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;

    [Constructible]
    public ChargedGem() : base(0x1EA7)
    {
        _charges = 10;
        Weight = 1.0;
    }

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

Migration Mapping Table

RunUO ModernUO Notes
public class Foo : Item public partial class Foo : Item Must add partial
[Constructable] [Constructible] Spelling change
Foo(Serial serial) : base(serial) DELETE Generated automatically
Serialize(GenericWriter writer) DELETE Generated from [SerializableField] attributes
Deserialize(GenericReader reader) DELETE Generated from attributes
writer.Write((int)version) [SerializationGenerator(version, false)] Version in attribute
private int m_Charges [SerializableField(0)] private int _charges Attribute + rename
[CommandProperty(AccessLevel.GM)] on property [SerializedCommandProperty(AccessLevel.GM)] on field Moves to field
InvalidateProperties() in setter [InvalidateProperties] on field Attribute replaces manual call
GenericWriter IGenericWriter Interface now
GenericReader IGenericReader Interface now
reader.ReadInt() reader.ReadInt() Same for manual cases
reader.ReadMobile() reader.ReadEntity<Mobile>() Generic method
reader.ReadItem() reader.ReadEntity<Item>() Generic method
writer.Write((int)0) version [SerializationGenerator(0, false)] In attribute

Step-by-Step Conversion

Step 1: Add Required Using

using ModernUO.Serialization;

Step 2: Add Class Attributes and partial

// Change:
public class MyItem : Item
// To:
[SerializationGenerator(0, false)]
public partial class MyItem : Item

The version number should be 0 for a fresh migration (you're defining a new serialization schema). Use false as the second argument for Item/Mobile subclasses.

Step 3: Delete Serial Constructor

Remove public MyItem(Serial serial) : base(serial) { } entirely.

Step 4: Convert Fields

For each field that was serialized in Serialize():

// RunUO
private int m_Charges;
[CommandProperty(AccessLevel.GameMaster)]
public int Charges { get { return m_Charges; } set { m_Charges = value; } }

// ModernUO
[SerializableField(0)]  // Index = serialization order
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
// Property is auto-generated: public int Charges { get; set; }

Add [InvalidateProperties] if the RunUO setter called InvalidateProperties():

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

Step 5: Delete Serialize and Deserialize Methods

Remove both override methods entirely. The source generator creates them.

Step 6: Change [Constructable] to [Constructible]

[Constructible]
public MyItem() : base(0x1234) { }

Step 7: Handle Timer Fields

TimerExecutionToken MUST NOT have [SerializableField]. Restore timers in [AfterDeserialization]:

private TimerExecutionToken _timerToken; // NOT serialized

[AfterDeserialization]
private void AfterDeserialization()
{
    Timer.StartTimer(TimeSpan.FromSeconds(5), CheckExpiry, out _timerToken);
}

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

Step 8: Handle Custom Property Logic

If a property has non-trivial getter/setter logic, use [SerializableProperty] instead:

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

Step 9: Update GetProperties

Change ObjectPropertyList to IPropertyList:

// RunUO
public override void GetProperties(ObjectPropertyList list)

// ModernUO
public override void GetProperties(IPropertyList list)

Before/After Examples

Simple Item (No Custom Fields)

RunUO:

namespace Server.Items
{
    public class SimpleGem : Item
    {
        [Constructable]
        public SimpleGem() : base(0x1EA7)
        {
            Weight = 1.0;
        }

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

        public override void Serialize(GenericWriter writer)
        {
            base.Serialize(writer);
            writer.Write((int)0);
        }

        public override void Deserialize(GenericReader reader)
        {
            base.Deserialize(reader);
            int version = reader.ReadInt();
        }
    }
}

ModernUO:

using ModernUO.Serialization;

namespace Server.Items;

[SerializationGenerator(0, false)]
public partial class SimpleGem : Item
{
    [Constructible]
    public SimpleGem() : base(0x1EA7)
    {
        Weight = 1.0;
    }

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

Versioned Item

RunUO (version 2 — added Owner in v1, Quality in v2):

namespace Server.Items
{
    public class MagicGem : Item
    {
        private int m_Charges;
        private Mobile m_Owner;
        private GemQuality m_Quality;

        [Constructable]
        public MagicGem() : base(0x1EA7)
        {
            m_Charges = 10;
            m_Quality = GemQuality.Rough;
        }

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

        public override void Serialize(GenericWriter writer)
        {
            base.Serialize(writer);
            writer.Write((int)2);
            writer.Write((int)m_Quality);
            writer.Write(m_Owner);
            writer.Write(m_Charges);
        }

        public override void Deserialize(GenericReader reader)
        {
            base.Deserialize(reader);
            int version = reader.ReadInt();

            switch (version)
            {
                case 2:
                    m_Quality = (GemQuality)reader.ReadInt();
                    goto case 1;
                case 1:
                    m_Owner = reader.ReadMobile();
                    goto case 0;
                case 0:
                    m_Charges = reader.ReadInt();
                    break;
            }
        }
    }
}

ModernUO:

using ModernUO.Serialization;

namespace Server.Items;

[SerializationGenerator(0, false)]  // Version 0 — new schema
public partial class MagicGem : Item
{
    [SerializableField(0)]
    [InvalidateProperties]
    [SerializedCommandProperty(AccessLevel.GameMaster)]
    private int _charges;

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

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

    [Constructible]
    public MagicGem() : base(0x1EA7)
    {
        _charges = 10;
        _quality = GemQuality.Rough;
    }
}

Important: When migrating RunUO code, the ModernUO version starts at 0 because you're defining a new serialization schema. The old version numbers from RunUO are irrelevant — the source generator doesn't read the old format. The old saves must be re-saved or a migration schema must be created.

Edge Cases & Gotchas

1. Save Compatibility

ModernUO's serialization format is completely different from RunUO's. You CANNOT load RunUO saves directly into ModernUO with source-generated serialization. Options:

  • Use [TypeAlias("Old.Namespace.ClassName")] to map old type names
  • Start with a fresh world
  • Write a one-time migration tool

2. MarkDirty() in Custom Setters

If you use [SerializableProperty] with a custom setter, you MUST call this.MarkDirty():

set
{
    _value = value;
    this.MarkDirty();  // Required!
}

Without this, changes won't be saved.

3. Field Ordering

The [SerializableField(N)] index determines serialization order. Choose a logical order and don't change it after the first save — or increment the version.

4. Conditional Serialization

Use [SerializableFieldSaveFlag] and [SerializableFieldDefault] to skip default values:

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

[SerializableFieldDefault(0)]
private int MaxItemsDefaultValue() => -1;

5. Collection Fields

Use [Tidy] to auto-clean null/deleted entries on deserialization:

[Tidy]
[SerializableField(0)]
private List<Mobile> _followers;

6. DateTime Fields

Use [DeltaDateTime] to survive server restarts:

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

7. Keeping Manual Serialization (Rare)

Some edge cases still need manual serialization. If a type has complex conditional logic that can't be expressed with attributes, you can implement ISerializable manually. But this is rare — try attributes first.

See Also

  • dev-docs/serialization.md — Complete ModernUO serialization reference
  • 01-foundation-changes.md — Foundation changes to apply first
  • 03-timers.md — Timer migration (often coupled with serialization)