## Summary Brings every serialization-related doc, skill, and the CLAUDE.md rule in line with generator **v4** (adopted in #2586/#2587). No code changes. **Updated surface, everywhere it was referenced:** - `[SerializableFieldSaveFlag(order)]` / `[SerializableFieldDefault(order)]` → `[SaveFlag(nameof(Should), nameof(Default))]` on the field (second method optional). - `[TimerDrift]` + `[DeserializeTimerField(order)]` → `[DeserializeTimer(nameof(Method), wallClock)]` on the field, with the anchored-time semantics spelled out: drifting by default (downtime preserves the remaining delay, idle saves byte-stable), `wallClock: true` for absolute deadlines, restart method invoked **only when a timer was running** (no sentinel), and the timer `MigrateFrom` pattern (`XxxNext`/`XxxDelay`) for wire-format changes. - New `[SerializableField]` documentation: the real signature (the documented `saveIf` parameter never existed) plus the setter hooks — `allowFieldChange` (`bool Method(ref T value)`: coerce/veto before assignment) and `fieldChanged` (`void Method(T oldValue, T newValue)` after) — with the generated pipeline and the SG3015/SG3018 guardrails. - `[SerializableProperty]` guidance narrowed to its remaining purpose: custom getters and setter semantics the hooks cannot express. - `[AnchoredDateTime]` documented alongside `[DeltaDateTime]` (now marked legacy, with the version-bump warning for converting between them). **Files:** `dev-docs/serialization.md`, `dev-docs/timers.md`, `dev-docs/claude-skills/modernuo-serialization.md`, `dev-docs/claude-skills/modernuo-timers.md`, `dev-docs/runuo-migration-docs/02-serialization.md`, `dev-docs/runuo-migration-docs/03-timers.md`, and a condensed v4 addition to CLAUDE.md rule 9. **Example refresh:** the skill's `BagOfSending` "custom properties" example was itself converted in #2587 — it is now quoted in its real post-conversion form as the canonical hooks example; the real-examples list points at `BaseWeapon.cs` for custom getters and `BaseLight.cs` for the drifting-timer + `MigrateFrom` pattern. Verified by grep: zero references to the removed v3 attribute names remain anywhere in `dev-docs/` or `CLAUDE.md`.
22 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, getter, setter, isVirtual, fieldChanged, allowFieldChange)]
Target: Private field (_camelCase)
Generates: Public PascalCase property with get/set
| Parameter | Type | Default | Description |
|---|---|---|---|
index |
int |
Required | Serialization order (0-based) |
getter |
string |
"public" |
Getter accessibility |
setter |
string |
"public" |
"private" or "internal" to restrict setter |
isVirtual |
bool |
false |
Generate a virtual property |
fieldChanged |
string |
null |
nameof of a void Method(T oldValue, T newValue) invoked by the generated setter after assignment |
allowFieldChange |
string |
null |
nameof of a bool Method(ref T value) invoked before assignment; coerce the value through the ref parameter, or return false to reject the change |
[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
{
if (value != _charges)
{
_charges = value;
this.MarkDirty();
}
}
}
Setter hooks replace most hand-written [SerializableProperty] setters. The generated
pipeline is: equality check → allowFieldChange (coerce/veto) → assignment → MarkDirty →
InvalidateProperties (if declared) → fieldChanged. The gate runs before assignment, so
the field itself still holds the old value inside it.
[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _charges;
private bool AllowChargesChange(ref int value)
{
value = Math.Clamp(value, 0, MaxCharges); // coerce, or return false to veto
return true;
}
[SerializableField(1, fieldChanged: nameof(OnOwnerChanged))]
private Mobile _owner;
// oldValue makes unsubscribe/resubscribe patterns trivial
private void OnOwnerChanged(Mobile oldValue, Mobile newValue)
{
oldValue?.Followers.Remove(this);
newValue?.Followers.Add(this);
}
Both hooks require a generated setter — declaring one on a readonly field or with
setter: null is a compile-time error (SG3018), and a named method that is missing or has
the wrong signature is too (SG3015).
[SerializableProperty(index, useField)]
Target: Property with custom get/set logic
Use when: You need a custom getter (fallback defaults, lazy or self-healing reads)
or setter semantics the field hooks cannot express (work that must run on equal
assignment, pre-assignment state capture). For setters that only coerce, veto, or run
post-change side effects, prefer [SerializableField] with allowFieldChange/fieldChanged
instead — the generated setter handles equality, MarkDirty, and ordering for you.
| 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; // custom getter: the reason this is a property
set
{
_maxItems = value;
InvalidateProperties();
this.MarkDirty(); // REQUIRED in custom setters
}
}
Note: the fieldChanged/allowFieldChange hooks are [SerializableField] arguments and
cannot be declared on a [SerializableProperty] — its setter is your own code, so call your
methods from the setter directly.
[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).
[AnchoredDateTime]
Target: DateTime field
Effect: Stores the absolute UTC instant; at load it is shifted forward by the downtime
between the save and the load (using the save-start anchor in the save's index file), so
server downtime does not consume the remaining time. DateTime.MinValue/MaxValue
sentinels pass through unshifted.
Prefer this for deadlines and "elapsed while running" values. Unlike [DeltaDateTime], the
stored bytes do not change on every save when the value is unchanged, keeping idle saves
byte-stable.
[AnchoredDateTime]
[SerializableField(0)]
private DateTime _expireTime;
[DeltaDateTime]
Target: DateTime field
Effect: Stores as offset from current time rather than absolute timestamp.
Legacy encoding for surviving restarts: it rewrites the bytes on every save even when the
value has not changed. Prefer [AnchoredDateTime] for new fields; converting an existing
field between the two changes the wire format and requires a version bump.
[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();
}
}
[DeserializeTimer(nameof(Method), wallClock)]
Target: Timer-typed [SerializableField] or [SerializableProperty] member
Effect: Declares how the timer is stored and restored. Required on every serializable
timer (SG3008 otherwise).
By default the timer's next tick is stored as anchored time: server downtime does not
consume the remaining delay, and idle saves are byte-stable. Pass wallClock: true to store
an absolute deadline instead (the delay is then negative when the deadline passed during
downtime).
The named method — void Method(TimeSpan delay) — is invoked only when a timer was
actually running at save, with the remaining delay. There is no sentinel value to check.
[SerializableField(0, setter: "private")]
[DeserializeTimer(nameof(DeserializeDecayTimer))]
private Timer _decayTimer;
private void DeserializeDecayTimer(TimeSpan delay) => _decayTimer = Timer.DelayCall(delay, Delete);
Switching an existing timer between drifting and wallClock changes the wire format — bump
the class version and add a MigrateFrom. The old-version content struct exposes the
timer's XxxNext (DateTime) and XxxDelay (TimeSpan, TimeSpan.MinValue when no timer
was running):
private void MigrateFrom(V3Content content)
{
if (content.DecayTimerDelay != TimeSpan.MinValue)
{
DeserializeDecayTimer(content.DecayTimerDelay);
}
}
[SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))]
Target: the serializable field or property itself Conditional serialization -- skip fields that have their default value.
The first method (bool Method()) decides whether the value is written. The optional second
method (returning the field's type, no parameters) supplies the value at load when it was
not written; when omitted, the field keeps its default value.
[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeCharges), nameof(ChargesDefaultValue))]
private int _charges;
private bool ShouldSerializeCharges() => _charges != -1;
private int ChargesDefaultValue() => -1;
Works on [SerializableProperty] members the same way:
[EncodedInt]
[SerializableProperty(0)]
[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))]
public int MaxItems
{
get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
set { _maxItems = value; this.MarkDirty(); }
}
private bool ShouldSerializeMaxItems() => _maxItems != -1;
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 |