ModernUO/dev-docs/runuo-migration-docs/08-persistence.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

Persistence Migration

Overview

RunUO systems that persist custom data use EventSink.WorldSave/EventSink.WorldLoad with manual BinaryFileWriter/BinaryFileReader. ModernUO replaces this with GenericPersistence (for system state) and GenericEntityPersistence<T> (for custom entity collections). The persistence framework handles save/load lifecycle, file management, and integration with ModernUO's multi-threaded save pipeline automatically.

RunUO Pattern

using System;
using System.IO;
using Server;

namespace Server.Custom
{
    public class JailSystem
    {
        private static Dictionary<Mobile, JailRecord> m_Records = new Dictionary<Mobile, JailRecord>();

        public static void Configure()
        {
            EventSink.WorldLoad += new WorldLoadEventHandler(Load);
            EventSink.WorldSave += new WorldSaveEventHandler(Save);
        }

        private static void Load()
        {
            string filePath = Path.Combine("Saves/Custom", "JailSystem.bin");

            if (!File.Exists(filePath))
                return;

            using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
            {
                BinaryReader reader = new BinaryReader(fs);
                int version = reader.ReadInt32();

                int count = reader.ReadInt32();
                for (int i = 0; i < count; i++)
                {
                    Mobile m = World.FindMobile(reader.ReadInt32());
                    string reason = reader.ReadString();
                    DateTime releaseDate = new DateTime(reader.ReadInt64());

                    if (m != null)
                        m_Records[m] = new JailRecord(reason, releaseDate);
                }
            }
        }

        private static void Save(WorldSaveEventArgs e)
        {
            string dirPath = "Saves/Custom";
            if (!Directory.Exists(dirPath))
                Directory.CreateDirectory(dirPath);

            string filePath = Path.Combine(dirPath, "JailSystem.bin");

            using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
            {
                BinaryWriter writer = new BinaryWriter(fs);
                writer.Write((int)0); // version
                writer.Write(m_Records.Count);

                foreach (var kvp in m_Records)
                {
                    writer.Write(kvp.Key.Serial.Value);
                    writer.Write(kvp.Value.Reason);
                    writer.Write(kvp.Value.ReleaseDate.Ticks);
                }
            }
        }
    }
}

ModernUO Equivalent (GenericPersistence)

using Server;
using Server.Serialization;

namespace Server.Custom;

public class JailSystem : GenericPersistence
{
    private static JailSystem _instance;
    private static Dictionary<Mobile, JailRecord> _records = new();

    public static void Configure()
    {
        _instance = new JailSystem();
    }

    public JailSystem() : base("JailSystem", 10) { }

    public override void Serialize(IGenericWriter writer)
    {
        writer.WriteEncodedInt(0); // version
        writer.WriteEncodedInt(_records.Count);

        foreach (var (mobile, record) in _records)
        {
            writer.Write(mobile);
            writer.Write(record.Reason);
            writer.Write(record.ReleaseDate);
        }
    }

    public override void Deserialize(IGenericReader reader)
    {
        var version = reader.ReadEncodedInt();
        var count = reader.ReadEncodedInt();

        for (var i = 0; i < count; i++)
        {
            var mobile = reader.ReadEntity<Mobile>();
            var reason = reader.ReadString();
            var releaseDate = reader.ReadDateTime();

            if (mobile != null)
                _records[mobile] = new JailRecord(reason, releaseDate);
        }
    }

    // Public API for the system
    public static void JailPlayer(Mobile m, string reason, DateTime releaseDate)
    {
        _records[m] = new JailRecord(reason, releaseDate);
        _instance.MarkDirty();
    }

    public static bool IsJailed(Mobile m) => _records.ContainsKey(m);

    public static void Release(Mobile m)
    {
        _records.Remove(m);
        _instance.MarkDirty();
    }
}

When to Use Which

Scenario Use
System state (jail records, virtue data, faction data, scores) GenericPersistence
Custom entity collections with their own serial ranges GenericEntityPersistence<T>
Item/Mobile subclasses (normal serialization) [SerializationGenerator] — not persistence

Most RunUO EventSink.WorldSave patterns should convert to GenericPersistence.

Migration Mapping Table

RunUO ModernUO Notes
EventSink.WorldSave += Save class MySystem : GenericPersistence Subclass instead
EventSink.WorldLoad += Load override Deserialize(IGenericReader) Method on class
Manual Save(WorldSaveEventArgs) override Serialize(IGenericWriter) Method on class
Manual Load() override Deserialize(IGenericReader) Method on class
new BinaryFileWriter(path, true) Handled by framework No file management
new BinaryFileReader(new FileStream(...)) Handled by framework No file management
writer.Write((int)0) version writer.WriteEncodedInt(0) Encoded preferred
reader.ReadInt() count reader.ReadEncodedInt() Encoded preferred
writer.Write(mobile.Serial.Value) writer.Write(mobile) Write entity directly
World.FindMobile(reader.ReadInt32()) reader.ReadEntity<Mobile>() Generic method
World.FindItem(reader.ReadInt32()) reader.ReadEntity<Item>() Generic method
Directory.CreateDirectory(...) Handled by framework Automatic
File.Exists(path) check Handled by framework Automatic

Step-by-Step Conversion

Step 1: Create Persistence Class

public class MySystem : GenericPersistence
{
    private static MySystem _instance;

    public static void Configure()
    {
        _instance = new MySystem();
    }

    public MySystem() : base("MySystem", 10) { }
    // "MySystem" = save file name
    // 10 = priority (lower = saved first)
}

Step 2: Move Save Logic to Serialize

public override void Serialize(IGenericWriter writer)
{
    writer.WriteEncodedInt(0); // version

    // Convert BinaryWriter calls to IGenericWriter calls
    writer.WriteEncodedInt(_data.Count);
    foreach (var (key, value) in _data)
    {
        writer.Write(key);    // Can write Mobile/Item directly
        writer.Write(value);
    }
}

Step 3: Move Load Logic to Deserialize

public override void Deserialize(IGenericReader reader)
{
    var version = reader.ReadEncodedInt();
    var count = reader.ReadEncodedInt();

    for (var i = 0; i < count; i++)
    {
        var key = reader.ReadEntity<Mobile>();  // Not World.FindMobile()
        var value = reader.ReadInt();

        if (key != null)
            _data[key] = value;
    }
}

Step 4: Remove EventSink Subscriptions

Delete the EventSink.WorldSave += ... and EventSink.WorldLoad += ... lines.

Step 5: Add MarkDirty() Calls

Whenever data changes, call _instance.MarkDirty() to flag the system for saving:

public static void AddRecord(Mobile m, string data)
{
    _records[m] = data;
    _instance.MarkDirty();  // Required!
}

Step 6: Remove File Management Code

Delete all Directory.CreateDirectory, File.Exists, FileStream, path construction. The framework handles this.

IGenericWriter vs BinaryWriter

BinaryWriter (RunUO) IGenericWriter (ModernUO)
writer.Write((int)value) writer.Write(value) or writer.WriteEncodedInt(value)
writer.Write((string)value) writer.Write(value)
writer.Write((bool)value) writer.Write(value)
writer.Write(mobile.Serial.Value) writer.Write(mobile)
writer.Write(item.Serial.Value) writer.Write(item)
writer.Write(dateTime.Ticks) writer.Write(dateTime)
No encoded int writer.WriteEncodedInt(value) — variable-length, saves space

IGenericReader vs BinaryReader

BinaryReader (RunUO) IGenericReader (ModernUO)
reader.ReadInt32() reader.ReadInt() or reader.ReadEncodedInt()
reader.ReadString() reader.ReadString()
reader.ReadBoolean() reader.ReadBool()
World.FindMobile(reader.ReadInt32()) reader.ReadEntity<Mobile>()
World.FindItem(reader.ReadInt32()) reader.ReadEntity<Item>()
new DateTime(reader.ReadInt64()) reader.ReadDateTime()

Before/After: Complete System

RunUO:

namespace Server.Custom
{
    public class VirtueSystem
    {
        private static Dictionary<Mobile, int> m_Points = new Dictionary<Mobile, int>();

        public static void Configure()
        {
            EventSink.WorldLoad += new WorldLoadEventHandler(Load);
            EventSink.WorldSave += new WorldSaveEventHandler(Save);
        }

        private static void Load()
        {
            string path = Path.Combine("Saves/Custom", "Virtue.bin");
            if (!File.Exists(path)) return;

            using var fs = new FileStream(path, FileMode.Open);
            var reader = new BinaryReader(fs);

            int version = reader.ReadInt32();
            int count = reader.ReadInt32();

            for (int i = 0; i < count; i++)
            {
                Mobile m = World.FindMobile(reader.ReadInt32());
                int pts = reader.ReadInt32();
                if (m != null)
                    m_Points[m] = pts;
            }
        }

        private static void Save(WorldSaveEventArgs e)
        {
            string dir = "Saves/Custom";
            if (!Directory.Exists(dir))
                Directory.CreateDirectory(dir);

            using var fs = new FileStream(Path.Combine(dir, "Virtue.bin"), FileMode.Create);
            var writer = new BinaryWriter(fs);

            writer.Write(0); // version
            writer.Write(m_Points.Count);
            foreach (var kvp in m_Points)
            {
                writer.Write(kvp.Key.Serial.Value);
                writer.Write(kvp.Value);
            }
        }

        public static void AddPoints(Mobile m, int points)
        {
            if (!m_Points.ContainsKey(m))
                m_Points[m] = 0;
            m_Points[m] += points;
        }
    }
}

ModernUO:

namespace Server.Custom;

public class VirtueSystem : GenericPersistence
{
    private static VirtueSystem _instance;
    private static readonly Dictionary<Mobile, int> _points = new();

    public static void Configure()
    {
        _instance = new VirtueSystem();
    }

    public VirtueSystem() : base("VirtueSystem", 10) { }

    public override void Serialize(IGenericWriter writer)
    {
        writer.WriteEncodedInt(0); // version
        writer.WriteEncodedInt(_points.Count);
        foreach (var (mobile, pts) in _points)
        {
            writer.Write(mobile);
            writer.Write(pts);
        }
    }

    public override void Deserialize(IGenericReader reader)
    {
        var version = reader.ReadEncodedInt();
        var count = reader.ReadEncodedInt();

        for (var i = 0; i < count; i++)
        {
            var mobile = reader.ReadEntity<Mobile>();
            var pts = reader.ReadInt();
            if (mobile != null)
                _points[mobile] = pts;
        }
    }

    public static void AddPoints(Mobile m, int points)
    {
        _points.TryGetValue(m, out var current);
        _points[m] = current + points;
        _instance.MarkDirty();
    }
}

Edge Cases & Gotchas

1. MarkDirty() Is Required

Without MarkDirty(), changes won't be saved. Call it whenever your persisted data changes.

2. GenericPersistence Constructor Name

The first argument to the base constructor is the save file name. It must be unique across all GenericPersistence instances.

3. Priority Argument

The second argument is save priority. Lower numbers save first. Use 10 for most systems.

4. Don't Mix EventSink.WorldSave with GenericPersistence

Don't subscribe to EventSink.WorldSave for data that GenericPersistence manages. The framework handles the lifecycle.

5. ReadEntity() Returns Null for Deleted Entities

Unlike World.FindMobile(), ReadEntity<T>() will return null if the entity was deleted. Always null-check.

See Also

  • dev-docs/serialization.md — Serialization system overview
  • 07-commands-events.md — EventSink migration
  • 01-foundation-changes.md — Foundation changes