The ModernUO serialization source generator takes the boilerplate out of writing serialization for your classes. While it is not the most elegant solution (recommendations and contributions are welcome!), it should handle most use-cases.
Add ModernUO.Serialization.Generator and ModernUO.Serialization.Annotations as package references:
<ItemGroup>
<PackageReference Include="ModernUO.Serialization.Annotations" Version="4.0.0" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="4.0.0" PrivateAssets="all" />
</ItemGroup>All classes will at the very least have this kind of boilerplate:
public class ElegantLowTable : Item
{
[Constructible]
public ElegantLowTable() : base(0x2819) => Weight = 1.0;
public ElegantLowTable(Serial serial) : base(serial)
{
}
public override void Serialize(IGenericWriter writer)
{
writer.WriteEncodedInt(0); // version
}
public override void Deserialize(IGenericReader reader)
{
int version = reader.ReadEncodedInt();
}
}The source generator takes some of the tediousness out of writing content by abstracting out the code:
[SerializationGenerator(0)]
public partial class ElegantLowTable : Item
{
[Constructible]
public ElegantLowTable() : base(0x2819) => Weight = 1.0;
}In this example the source generator is aware that the parent class, Server.Item, is a Server.ISerializable.
By adding the SerializationGenerator attribute to the class, we are telling the source generator to write the serialization code for us.
There is an accompanying application, the Serialization Schema Generator that generates a migration file for all classes that are annotated.
ModernUO automatically runs this tool through publish.cmd. Having the migration scripts will allow the source generator to have a reference to older versions
of this object and therefore set up a migration path.
Note: We added partial to the class definition to facilitate the actual code generation process. You must use this.
The generated code looks like this:
namespace Server.Items
{
public partial class ElegantLowTable
{
private const int _version = 0;
public ElegantLowTable(Server.Serial serial) : base(serial)
{
}
public override void Serialize(Server.IGenericWriter writer)
{
base.Serialize(writer);
writer.WriteEncodedInt(_version);
}
public override void Deserialize(Server.IGenericReader reader)
{
base.Deserialize(reader);
var version = reader.ReadEncodedInt();
}
}
}In our previous example, let's say we wanted to add a property that needs to be serialized. This is how it would look:
// Bumped the version from 0 to 1 since we changing
// the serialization schema by adding a property.
[SerializationGenerator(1)]
public partial class ElegantLowTable : Item
{
[SerializableField(0)]
private string _prefix;
[Constructible]
public ElegantLowTable() : base(0x2819) => Weight = 1.0;
// Faciliates migrating a v0 object to v1
private void MigrateFrom(V0Content content)
{
}
}In this example we use the SerializableField attribute. This field tells the source generator to create a getter/setter and serialize the property.
By default the getter/setter is public, but that can be modified in the parameters. Since we are modifying the previous example and effectively creating
a new version of this object, we have to bump the SerializationGenerator field on the class as well.
Anytime the version is bumped, the project will not compile unless a MigrateFrom method is added by the developer to handle migrating from a previous version to the new version.
In this case, the V0Content struct contains any/all serialized properties from v0 of the object. The source generator knows the schema of the previous version by reading the v0
migration file generated by the Serialization Schema Generator.
WARNING: DO NOT ASSIGN A VALUE BACK TO THE ORIGINAL PROPERTY (_prefix). Always use the generated setter! There are more advanced techniques to handle edge cases if needed.
Contact us in Discord and ask for help if needed!
Similar to our previous example, we can do a less magical way of hinting to the code generator that we want to serialize a value. Here is how it would look:
// Bumped the version from 0 to 1 since we changing
// the serialization schema by adding a property.
[SerializationGenerator(1)]
public partial class ElegantLowTable : Item
{
private string _prefix;
[Constructible]
public ElegantLowTable() : base(0x2819) => Weight = 1.0;
[SerializableProperty(0, useField: nameof(_prefix))]
public string Prefix
{
get => _prefix;
set
{
if (_prefix != value)
{
_prefix = value;
// Mark the entity dirty so the world save optimziation knows to serialize.
this.MarkDirty();
}
}
}
}In this example, you will notice a useField parameter. This parameter is optional. If it is null, empty string, or whitespaces, the generator will assume it is omitted.
When the parameter is omitted, the generator will create the private field for you using the standard naming convention!
WARNING: The useField, or generated backing field is necessary for the generator to deserialize without side-effects. This means the property setter is not called.
You may need add an [AfterDeserialization] method to take care of any necessary side-effects after deserialization.
Quite often we will have code that was not source generated, but we would like to convert it. Sometimes we can cleanly do this without having to modify the version number since the serialization data would be exactly the same. Other times it isn't clean, or we aren't really sure. To handle this we have to do a conversion, for example:
Legacy:
public class DeathRobe : Robe
{
private DateTime m_DecayTime;
private Timer m_DecayTimer;
[Constructible]
public DeathRobe()
{
BeginDecay(m_DefaultDecayTime);
}
public DeathRobe(Serial serial) : base(serial)
{
}
// ... Some code
public override void Serialize(IGenericWriter writer)
{
base.Serialize(writer);
writer.Write(2); // version
writer.Write(m_DecayTimer != null);
if (m_DecayTimer != null)
{
writer.WriteDeltaTime(m_DecayTime);
}
}
public override void Deserialize(IGenericReader reader)
{
base.Deserialize(reader);
var version = reader.ReadInt();
switch (version)
{
case 2:
{
if (reader.ReadBool())
{
m_DecayTime = reader.ReadDeltaTime();
BeginDecay(m_DecayTime - Core.Now);
}
break;
}
case 1:
case 0:
{
if (Parent == null)
{
BeginDecay(m_DefaultDecayTime);
}
break;
}
}
if (version < 1 && Hue == 0)
{
Hue = 2301;
}
}The legacy serialization has several versions, it isn't really clean, it has a timer, and does some logic after the deserialization itself.
When the source generator runs, it will see that the version is higher than 0 and then check for migration files. If none exist, it will
require a fallback deserialization method with the signature private void Deserialize(IGenericReader reader, int version).
Here is a complete example of how we would convert this:
// We bumped to version 3, and we used the `false` flag to indicate that when we serialize the version property, it is not encoded.
[SerializationGenerator(3, false)]
public partial class DeathRobe : Robe
{
// The timer's next tick is written as anchored time by default, so downtime does not
// consume the remaining delay. Use wallClock: true for absolute deadlines instead.
[SerializableField(0)]
[DeserializeTimer(nameof(DeserializeDecayTimer))]
private Timer _decayTimer;
// Invoked only when a timer was running at save, with its remaining delay.
// This is a void instead of returning a Timer for flexiblity.
private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay);
[Constructible]
public DeathRobe()
{
BeginDecay(m_DefaultDecayTime);
}
// ... Some code
// Executed for versions that are older than our current version, and do not have a migration file (MigrateFrom method).
private void Deserialize(IGenericReader reader, int version)
{
switch (version)
{
case 2:
{
if (reader.ReadBool())
{
m_DecayTime = reader.ReadDeltaTime();
BeginDecay(m_DecayTime - Core.Now);
}
break;
}
case 1:
case 0:
{
if (Parent == null)
{
BeginDecay(m_DefaultDecayTime);
}
break;
}
}
}
[AfterDeserialization]
private void AfterDeserialization()
{
if (version < 1 && Hue == 0)
{
Hue = 2301;
}
}
}v4 removes order-based linkage between fields and their companion methods. Every linkage is
declared on the serializable field itself, naming its companion methods with nameof():
// Conditional serialization: the first method decides whether the value is written; the
// optional second method supplies the value at load when it was not written. When the
// second method is omitted, the field keeps its default value.
[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeCharges), nameof(ChargesDefaultValue))]
private int _charges;
private bool ShouldSerializeCharges() => _charges != 8;
private int ChargesDefaultValue() => 8;
// Setter hooks are part of [SerializableField] itself because they configure the
// generated property, like the getter/setter arguments.
//
// allowFieldChange runs before assignment (after the equality check): it may coerce the
// incoming value through the ref parameter, and returning false rejects the change. The
// field still holds the old value while it runs. fieldChanged runs after assignment.
[SerializableField(1, allowFieldChange: nameof(AllowLevelChange), fieldChanged: nameof(OnLevelChanged))]
private int _level;
private bool AllowLevelChange(ref int value)
{
value = Math.Clamp(value, 0, 100);
return true;
}
private void OnLevelChanged(int oldValue, int newValue)
{
}The generated setter pipeline is: equality check → allowFieldChange (coerce/veto) →
assignment → dirty tracking → fieldChanged. Between these hooks, most hand-written
[SerializableProperty] setters (clamps, normalization, guarded rejection, post-change side
effects) can be expressed as a plain [SerializableField]; hand-written properties remain
for custom getters and truly exotic setters.
Because the declaration lives on the field, the old failure modes cannot be written: a
default cannot exist without a save flag, a linkage cannot point at a missing field, a
field cannot be linked twice, and a change callback cannot be declared on a
[SerializableProperty] (whose setter is user-written — call your method from the setter).
The generator still verifies that each named method exists with the expected signature
(SG3015), and that a fieldChanged callback has a generated setter to fire from (SG3018).
Timers are declared on the field with [DeserializeTimer], replacing [TimerDrift] and
[DeserializeTimerField]:
[SerializableField(0)]
[DeserializeTimer(nameof(RestartDecayTimer))]
private Timer _decayTimer;
private void RestartDecayTimer(TimeSpan delay) => BeginDecay(delay);By default the timer drifts: its next tick is stored as anchored time, so downtime does not
consume the remaining delay, and the restart method is invoked only when a timer was actually
running at save. Use [DeserializeTimer(nameof(Method), wallClock: true)] for absolute
deadlines; the delay is then negative when the deadline passed during downtime.
- Replace
[SerializableFieldSaveFlag(order)]and[SerializableFieldDefault(order)]with[SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))]on the field, and[SerializableFieldChanged(order)]withfieldChanged: nameof(Method)on the field's[SerializableField]. These conversions do not change the wire format. - Replace
[TimerDrift]+[DeserializeTimerField(order)]with[DeserializeTimer(nameof(Method))]on the timer field. Drifting timers change wire format (delta time to anchored time), so bump the class's[SerializationGenerator]version and add aMigrateFromfor the previous version; old saves keep reading correctly through the migration schema. Wall-clock timers (no[TimerDrift]before) keep their format: usewallClock: trueand no version bump is needed. - Remove
delay != TimeSpan.MinValuechecks from restart methods; they are no longer called when no timer was running.