## 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`.
15 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, getter, setter, isVirtual, fieldChanged, allowFieldChange)]
Applied to _camelCase private fields. Generates PascalCase property.
index: Serialization order (0+)getter/setter: Access level --"private","internal", or omit for publicisVirtual: Generate a virtual propertyfieldChanged:nameofofvoid Method(T oldValue, T newValue), invoked by the generated setter after assignmentallowFieldChange:nameofofbool Method(ref T value), invoked before assignment -- coerce through therefparameter or returnfalseto reject
Generated setter pipeline: equality check → allowFieldChange → assignment → MarkDirty → InvalidateProperties (if declared) → fieldChanged. The field still holds the old value while the gate runs. Hooks require a generated setter (SG3018 on readonly/setterless fields); a missing or wrong-shaped named method is SG3015.
[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _charges;
private bool AllowChargesChange(ref int value)
{
value = Math.Clamp(value, 0, MaxCharges);
return true;
}
[SerializableProperty(index, useField)]
Applied to properties with custom getters (fallback defaults, lazy/self-healing reads) or setter semantics the field hooks cannot express. For setters that only coerce, veto, or run post-change side effects, use [SerializableField] with allowFieldChange/fieldChanged instead.
index: Serialization orderuseField: Backing field name if auto-detection fails
[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
}
}
[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).
[AnchoredDateTime]
Stores the absolute UTC instant; shifted by downtime at load so remaining time is preserved. Byte-stable across idle saves. Prefer for deadlines/elapsed-while-running values.
[DeltaDateTime]
Stores DateTime as offset from current time (handles server restarts). Legacy: rewrites bytes every save; prefer [AnchoredDateTime] for new fields. Converting between the two changes the wire format (version bump).
[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();
}
}
[DeserializeTimer(nameof(Method), wallClock)]
Required on every serializable Timer member (SG3008 otherwise). By default the next tick is stored as anchored time (downtime does not consume the remaining delay; idle saves byte-stable); wallClock: true stores an absolute deadline instead (delay negative if it passed during downtime). The method -- void Method(TimeSpan delay) -- is invoked only when a timer was running at save; there is no sentinel to check.
[SerializableField(0, setter: "private")]
[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)]
private Timer _evaluateTimer;
private void DeserializeEvaluateTimer(TimeSpan delay)
{
_evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate);
}
Switching a timer between drifting and wallClock changes the wire format: bump the class version and add MigrateFrom -- the old content struct exposes XxxNext (DateTime) and XxxDelay (TimeSpan, TimeSpan.MinValue when no timer ran).
[SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))]
On the serializable field/property itself. Conditional serialization -- skip fields with default values. Second method optional; when omitted, the field keeps its default at load.
[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))]
private int _maxItems;
private bool ShouldSerializeMaxItems() => _maxItems != -1;
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 Setter Hooks (coerce + side effects)
[SerializationGenerator(2)]
public partial class BagOfSending : Item
{
[SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private BagOfSendingHue _bagOfSendingHue;
private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue)
{
Hue = newValue switch
{
BagOfSendingHue.Yellow => 0x8A5,
BagOfSendingHue.Blue => 0x8AD,
BagOfSendingHue.Red => 0x89B,
_ => Hue
};
}
[SerializableField(1, allowFieldChange: nameof(AllowChargesChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _charges;
private bool AllowChargesChange(ref int value)
{
value = Math.Clamp(value, 0, MaxCharges);
return true;
}
}
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 - Setter hooks (allowFieldChange + fieldChanged):
Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs - Custom getters (era fallbacks, the [SerializableProperty] use case):
Projects/UOContent/Items/Weapons/BaseWeapon.cs - Complex with AfterDeserialization:
Projects/UOContent/Accounting/Account.cs - Timer deserialization (wall-clock):
Projects/UOContent/Items/Aquarium/Aquarium.cs - Timer deserialization (drifting/anchored + timer MigrateFrom):
Projects/UOContent/Items/Lights/BaseLight.cs - Tidy + DeltaDateTime:
Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs - Conditional serialization ([SaveFlag]):
Projects/Server/Items/Container.cs
Version Migration
Migration schemas are JSON files in Projects/Server/Migrations/ and Projects/UOContent/Migrations/:
- Format:
TypeName.vN.json - Read by the serialization generator at compile time to produce
VXContenttypes forMigrateFrom - Used for reading old save formats
Schema generator must be run after every version bump
The dotnet build does not emit migration JSON files. After bumping [SerializationGenerator(N)] to N+1, run the schema generator tool to produce TypeName.v{N+1}.json. Commit the new JSON alongside the code change.
dotnet tool restore
dotnet tool run ModernUOSchemaGenerator -- ModernUO.slnx
Verify the new TypeName.v{N+1}.json was created in the appropriate Migrations/ folder. If the JSON is missing, future version bumps that need to migrate from this version will fail to compile (the generator can't build VXContent for a version with no schema on disk).
Also available via the build tool: dotnet run --project Projects/BuildTool -- --action migrate.
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