11 KiB
| name | description |
|---|---|
| modernuo-serialization | Trigger when creating or modifying classes inheriting Item, Mobile, BaseCreature, or any type with [SerializationGenerator]. When adding serialized fields. When discussing migration or version bumps. |
ModernUO Serialization System
When This Activates
- Creating/modifying classes that inherit
Item,Mobile,BaseCreature, or any serializable type - Adding
[SerializableField]or[SerializableProperty]attributes - Bumping serialization versions
- Working with migration schemas
- Discussing save/load behavior
Key Rules
- Always use
partialclass when applying[SerializationGenerator] - Always add
[Constructible]on parameterless constructors for Items/Mobiles - Never serialize
TimerExecutionToken-- restore timers in[AfterDeserialization] - Call
this.MarkDirty()in custom property setters that modify serialized state - Use
using ModernUO.Serialization;for serialization attributes - Field order matters --
[SerializableField(N)]index determines serialization order - Increment version when adding, removing, or reordering fields
Core Attributes
[SerializationGenerator(version, encoded)]
Applied to class. Generates Serialize/Deserialize methods.
version: Current serialization version (0+)encoded: Omit for new classes. When migrating from pre-codegen Serialize/Deserialize, passfalseif old code usedreader.ReadInt()(notReadEncodedInt())
// New class — omit encoded
[SerializationGenerator(0)]
public partial class MyItem : Item { }
// Migration from pre-codegen — old version was 2, used ReadInt()
[SerializationGenerator(3, false)]
public partial class MigratedItem : Item { }
[SerializableField(index, setter, saveIf)]
Applied to _camelCase private fields. Generates PascalCase property.
index: Serialization order (0+)setter: Access level --"private","internal", or omit for publicsaveIf: Condition method name for conditional serialization
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
// Generates: public int Charges { get; set; }
[SerializableProperty(index, useField)]
Applied to properties with custom get/set logic.
index: Serialization orderuseField: Backing field name if auto-detection fails
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int MaxItems
{
get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
set
{
_maxItems = value;
InvalidateProperties();
this.MarkDirty();
}
}
[InvalidateProperties]
On serialized fields -- auto-calls InvalidateProperties() when field changes (refreshes client tooltip).
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _balanced;
[SerializedCommandProperty(accessLevel)]
Exposes field to [Props gump for in-game editing.
[EncodedInt]
Variable-length int encoding (saves space for small values).
[DeltaDateTime]
Stores DateTime as offset from current time (handles server restarts).
[InternString]
Interns strings to reduce memory for repeated values.
[Tidy]
Auto-removes null/deleted entries from collections after deserialization.
[CanBeNull]
Marks field as nullable during deserialization.
[AfterDeserialization(synchronous)]
Method called after fields are deserialized. The synchronous parameter controls timing:
true(default): runs immediately after this entity's deserializationfalse: runs after ALL entities in the world are deserialized
Use true (default) for: restarting timers, setting up derived values from own fields.
Use false for: logic that calls Delete(), depends on other entities, or 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)]
Custom timer deserialization. Timer is saved as remaining TimeSpan.
[SerializableField(0, setter: "private")]
private Timer _evaluateTimer;
[DeserializeTimerField(0)]
private void DeserializeEvaluateTimer(TimeSpan delay)
{
_evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate);
}
[SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)]
Conditional serialization -- skip fields with default values.
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeMaxItems() => _maxItems != -1;
[SerializableFieldDefault(0)]
private int MaxItemsDefaultValue() => -1;
[TypeAlias(aliases)]
Maps old type names for backward-compatible deserialization.
[TypeAlias("Server.Mobiles.Bear")]
[SerializationGenerator(0)]
public partial class BlackBear : BaseCreature { }
Patterns
Minimal Item (Version 0, No Custom Fields)
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0)]
public partial class MyItem : Item
{
[Constructible]
public MyItem() : base(0x1234)
{
Weight = 1.0;
}
public override string DefaultName => "a my item";
}
Item with Fields
[SerializationGenerator(0)]
public partial class ChargedItem : Item
{
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
[SerializableField(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private Mobile _owner;
private TimerExecutionToken _timerToken; // NOT serialized
[Constructible]
public ChargedItem() : base(0x1234) => _charges = 10;
[AfterDeserialization]
private void AfterDeserialization()
{
Timer.StartTimer(TimeSpan.FromSeconds(5), CheckExpiry, out _timerToken);
}
public override void OnAfterDelete()
{
_timerToken.Cancel();
base.OnAfterDelete();
}
}
Item with Custom Properties
[SerializationGenerator(2)]
public partial class BagOfSending : Item
{
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public BagOfSendingHue BagOfSendingHue
{
get => _bagOfSendingHue;
set
{
_bagOfSendingHue = value;
Hue = value switch
{
BagOfSendingHue.Yellow => 0x8A5,
BagOfSendingHue.Blue => 0x8AD,
BagOfSendingHue.Red => 0x89B,
_ => Hue
};
this.MarkDirty();
}
}
}
Custom Serialize/Deserialize (Purity Rules)
When writing custom Serialize(IGenericWriter) or Deserialize(IGenericReader) methods (e.g. for GenericPersistence subclasses), the following rules apply:
Serialize() MUST remain pure
Serialize() is called from background serialization threads during world saves (see SerializationThreadWorker). Multiple entities are serialized in parallel across threads. This means Serialize() must NOT:
- Create or destroy Items/Mobiles -- mutates shared world state
- Move, equip, or unequip Items/Mobiles -- mutates shared world state
- Start or stop timers (
Timer.StartTimer,Timer.DelayCall,_token.Cancel()) -- timers are NOT thread-safe - Send packets or modify NetState -- networking is game-thread-only
- Access or modify other entities' mutable state -- data race
- Call
Delete()on anything -- triggers deletion cascades on wrong thread
Serialize() should ONLY read fields and write them to the IGenericWriter. Treat it as a read-only snapshot.
// CORRECT -- pure reads and writes only
public override void Serialize(IGenericWriter writer)
{
writer.WriteEncodedInt(0); // version
writer.WriteEncodedInt(_records.Count);
foreach (var (key, value) in _records)
{
writer.Write(key);
writer.Write(value);
}
}
// WRONG -- side effects in Serialize
public override void Serialize(IGenericWriter writer)
{
CleanupExpiredEntries(); // BAD: mutates state
Timer.StartTimer(Recheck); // BAD: not thread-safe
writer.Write(_data);
}
Deserialize() runs on the game thread
Deserialize() runs during world load on the main thread, so it CAN create entities and start timers. However, prefer [AfterDeserialization] for timer setup to keep deserialization clean.
MigrateFrom Pattern
When bumping the [SerializationGenerator] version, you must add a MigrateFrom method:
// 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)
}
}
- Signature:
private void MigrateFrom(VXContent content)where X is the previous version VXContentis auto-generated with PascalCase properties matching the old fields- New fields not in the old version get their default values
- Use
.Migrations.cspartial files for organization
Anti-Patterns
- Missing
partial:[SerializationGenerator]requirespartial class - Serializing timers:
TimerExecutionTokencannot be serialized - Side effects in
Serialize(): Serialize runs on background threads -- must be pure (no creating/destroying entities, no timer start/stop, no packets) - Missing
MarkDirty(): Custom property setters must callthis.MarkDirty() - Wrong field prefix: Use
_camelCase, notm_camelCasefor new fields - Forgetting
[Constructible]: Items/Mobiles need this for[addcommand - Modifying
Deserialize(reader, version)for version bumps:Deserializeexists ONLY for pre-codegen legacy saves. UseMigrateFrom(VXContent)for all post-codegen version transitions.
Real Examples
- Simple creature:
Projects/UOContent/Mobiles/Animals/Bears/BlackBear.cs - Serialized fields + timer:
Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs - Custom properties:
Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs - Complex with AfterDeserialization:
Projects/UOContent/Accounting/Account.cs - Timer deserialization:
Projects/UOContent/Items/Aquarium/Aquarium.cs - Tidy + DeltaDateTime:
Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs - Conditional serialization:
Projects/Server/Items/Container.cs
Version Migration
Migration schemas are JSON files in Projects/Server/Migrations/ and Projects/UOContent/Migrations/:
- Format:
TypeName.vN.json - Generated automatically by the serialization generator
- Used for reading old save formats
External reference: https://github.com/modernuo/SerializationGenerator
See Also
dev-docs/serialization.md- Complete serialization documentationdev-docs/claude-skills/modernuo-timers.md- Timer token patternsdev-docs/claude-skills/modernuo-content-patterns.md- Item/Mobile templatesdev-docs/claude-skills/modernuo-property-lists.md- [InvalidateProperties] usage