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.
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 attributesModernUO.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:
using ModernUO.Serialization;for the attributes[SerializationGenerator(0)]on the classpartialclass declaration[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 VXContentis 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.cspartial 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 |