## 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
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 overview07-commands-events.md— EventSink migration01-foundation-changes.md— Foundation changes