# 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 attributes - `ModernUO.Serialization.Generator` - C# source generator that produces serialization code Source: https://github.com/modernuo/SerializationGenerator ## Quick Start ### Minimal Serializable Item ```csharp 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: 1. `using ModernUO.Serialization;` for the attributes 2. `[SerializationGenerator(0)]` on the class 3. `partial` class declaration 4. `[Constructible]` on the parameterless constructor ### Item with Serialized Fields ```csharp [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: ```csharp [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: ```csharp [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 | ```csharp [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: ```csharp 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. ```csharp [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 | ```csharp [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. ```csharp [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. ```csharp [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. ```csharp [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. ```csharp [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`, `Dictionary`, etc.) **Effect**: Removes null and deleted entries from the collection after deserialization. ```csharp [Tidy] [SerializableField(0)] private List _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. ```csharp [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 ```csharp // 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. ```csharp [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): ```csharp 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. ```csharp [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: ```csharp [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. ```csharp [TypeAlias("Server.Mobiles.Bear")] [SerializationGenerator(0)] public partial class BlackBear : BaseCreature { } ``` ### [Constructible(accessLevel)] **Target**: Constructor **Effect**: Marks constructor as available for the `[add` command. ```csharp [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`: ```sh 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` ```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` | | `ArrayMigrationRule` | `T[]` | | `DictionaryMigrationRule` | `Dictionary` | | `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. ```csharp // 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 - `VXContent` is 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.cs` partial 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`: ```csharp // 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 ```csharp 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 |