ModernUO/website/content/development/serialization.mdx
2026-08-06 21:34:53 -07:00

569 lines
15 KiB
Text

---
sidebar_position: 2
title: Serialization
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Serialization
ModernUO uses a source generator-based serialization system. Decorate fields with attributes, and the generator produces `Serialize()` / `Deserialize()` methods automatically.
---
## When to Use Which
| Approach | Use For |
|:---------|:--------|
| **Code Generation** | Items, Mobiles, and any class inheriting `ISerializable` |
| **Generic Persistence** | Global systems, lookup tables, non-entity data |
| **Entity Persistence** | Custom types that need their own `Serial` and parallel serialization (like Items/Mobiles) |
---
<Tabs>
<TabItem value="codegen" label="Code Generation" default>
### Before and After
**Old way** -- manual serialization:
```csharp
public class ExampleItem : Item
{
private string _exampleText;
[CommandProperty(AccessLevel.GameMaster)]
public string ExampleText
{
get => _exampleText;
set
{
if (value != _exampleText)
{
_exampleText = value;
this.MarkDirty();
}
}
}
[Constructible]
public ExampleItem(string text) : base(0)
{
_exampleText = text;
}
public ExampleItem(Serial serial) : base(serial) { }
public override void Serialize(IGenericWriter writer)
{
base.Serialize(writer);
writer.WriteEncodedInt(0);
writer.Write(_exampleText);
}
public override void Deserialize(IGenericReader reader)
{
base.Deserialize(reader);
var version = reader.ReadEncodedInt();
_exampleText = reader.ReadString();
}
}
```
**New way** -- source-generated:
```csharp
[SerializationGenerator(0)]
public partial class ExampleItem : Item
{
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private string _exampleText;
[Constructible]
public ExampleItem(string text) : base(0)
{
_exampleText = text;
}
}
```
### Step by Step
1. Add `[SerializationGenerator(version)]` and make the class `partial`:
```csharp
[SerializationGenerator(0)]
public partial class ExampleItem : Item
```
2. Delete the `Serial` constructor.
3. Delete the `Serialize` and `Deserialize` methods.
4. Add `[SerializableField(index)]` to each field you want saved:
```csharp
[SerializableField(0)]
private string _exampleText;
```
5. Run `publish.cmd` (or `publish.sh`) to generate migration files.
The generator creates `Namespace.TypeName.v0.json` and `Namespace.TypeName.Serialization.cs` for you.
</TabItem>
<TabItem value="generic" label="Generic Persistence">
### Global System Data
Use `GenericPersistence` for global data that doesn't belong to a specific entity. Subclass it and implement `Serialize` / `Deserialize`.
Here's a real example based on the disguise system, which tracks active disguise timers per player:
```csharp
using System;
using System.Collections.Generic;
namespace Server.Items;
public class DisguisePersistence : GenericPersistence
{
private static DisguisePersistence _instance;
public static Dictionary<Mobile, Timer> Timers { get; } = new();
public static void Configure()
{
_instance = new DisguisePersistence();
}
public DisguisePersistence() : base("Disguises", 10)
{
}
public override void Serialize(IGenericWriter writer)
{
writer.WriteEncodedInt(Timers.Count);
foreach (var (m, timer) in Timers)
{
writer.Write(m);
writer.Write(timer.Next - Core.Now);
writer.Write(m.NameMod);
}
}
public override void Deserialize(IGenericReader reader)
{
var count = reader.ReadEncodedInt();
for (var i = 0; i < count; ++i)
{
var m = reader.ReadEntity<Mobile>();
var delay = reader.ReadTimeSpan();
var nameMod = reader.ReadString();
// Restore timer and state
CreateTimer(m, delay);
m.NameMod = nameMod;
}
}
public static void CreateTimer(Mobile m, TimeSpan delay) { /* ... */ }
}
```
Key points:
- The constructor takes a **name** (used for the save file path) and a **priority** (load order).
- `Configure()` is automatically discovered and called at startup.
- Data is saved to `Saves/Disguises/Disguises.bin`.
- You are responsible for reading/writing in the exact same order.
</TabItem>
<TabItem value="entity-persistence" label="Entity Persistence">
### Custom Entity Types
Use `GenericEntityPersistence<T>` when you need custom types with their own `Serial` that are serialized in parallel -- the same way Items and Mobiles work internally. Each entity gets its own serialization thread for parallel world saves.
#### 1. Define the persistence manager
```csharp
namespace Server.Engines.BulkOrders;
public class BOBEntries : GenericEntityPersistence<IBOBEntry>
{
private static BOBEntries _instance;
public static void Configure()
{
_instance = new BOBEntries();
}
// name, priority, minSerial, maxSerial
public BOBEntries() : base("BOBEntries", 3, 0x1, 0x7FFFFFFF)
{
}
public static Serial NewBOBEntry => _instance.NewEntity;
public static void Add(IBOBEntry entity) => _instance.AddEntity(entity);
public static void Remove(IBOBEntry entity) => _instance.RemoveEntity(entity);
}
```
#### 2. Define the entity base class
The base class uses standard `[SerializationGenerator]` attributes and manages its own `Serial`:
```csharp
[SerializationGenerator(1)]
public abstract partial class BaseBOBEntry : IBOBEntry
{
[SerializableField(0, setter: "protected")]
private bool _requireExceptional;
[SerializableField(1, setter: "protected")]
private BODType _deedType;
[SerializableField(2, setter: "protected")]
private BulkMaterialType _material;
[SerializableField(3, setter: "protected")]
private int _amountMax;
[SerializableField(4)]
private int _price;
public Serial Serial { get; }
public bool Deleted { get; private set; }
public BaseBOBEntry()
{
Serial = BOBEntries.NewBOBEntry;
BOBEntries.Add(this);
}
public virtual void Delete()
{
Deleted = true;
BOBEntries.Remove(this);
}
}
```
Concrete subclasses inherit from this base and add their own serialized fields, just like how specific items inherit from `Item`.
:::caution
Each entity has approximately **32 bytes of indexing overhead** regardless of its data size. Don't use entity persistence for lightweight or high-volume data where `GenericPersistence` would suffice. Benchmark your world save sizes and times before committing to this pattern.
:::
</TabItem>
</Tabs>
---
## Attribute Reference
### Class-Level Attributes
| Attribute | Target | Description |
|:----------|:-------|:------------|
| `[SerializationGenerator(version)]` | Class | Enables code generation. `version` is the current serialization version number. |
| `[Constructible]` | Constructor | Marks the constructor as available for the `[add` command. |
| `[TypeAlias("OldName")]` | Class | Maps old type names for deserialization of legacy saves. |
### Field-Level Attributes
| Attribute | Target | Description |
|:----------|:-------|:------------|
| `[SerializableField(index)]` | Private field | Marks field for serialization at the given index. Generates a public PascalCase property. |
| `[InvalidateProperties]` | Serializable field | Auto-calls `InvalidateProperties()` in the generated setter to refresh tooltips. |
| `[SerializedCommandProperty(level)]` | Serializable field | Exposes the generated property to the `[Props` gump for in-game editing. |
| `[DeltaDateTime]` | `DateTime` field | Stores as offset from current time. Ensures expiration dates survive restarts. |
| `[EncodedInt]` | `int` field | Uses variable-length encoding (1 byte for 0--127, 2 bytes for 128--16383, etc.). |
| `[InternString]` | `string` field | Deduplicates identical strings in memory via `string.Intern()`. |
| `[Tidy]` | Collection field | Removes null and deleted entries after deserialization. |
### Method-Level Attributes
| Attribute | Target | Description |
|:----------|:-------|:------------|
| `[AfterDeserialization]` | Private method | Called after fields are loaded. Use this to restart timers and set up derived state. |
| `[DeserializeTimerField(index)]` | Method taking `TimeSpan` | Custom deserialization for `Timer` fields. The timer is saved as remaining delay. |
---
## Serializable Fields in Detail
### Basic Field
```csharp
[SerializableField(0)]
private int _charges;
```
The generator creates:
```csharp
public int Charges
{
get => _charges;
set { _charges = value; this.MarkDirty(); }
}
```
### Field with Tooltip Refresh and GM Access
```csharp
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
```
### Private or Internal Setter
```csharp
[SerializableField(0, setter: "private")]
private string _name;
```
### Custom Property Logic
Use `[SerializableProperty]` when you need non-trivial getter/setter logic:
```csharp
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int MaxItems
{
get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
set
{
_maxItems = value;
InvalidateProperties();
this.MarkDirty(); // REQUIRED in custom setters
}
}
```
---
## Version Migration
When you add, remove, or reorder serialized fields, bump the version number.
### Adding a Field
```csharp
// Version 0 had only _charges. Version 1 adds _quality.
[SerializationGenerator(1)]
public partial class MagicGem : Item
{
[SerializableField(0)]
private int _charges;
[SerializableField(1)] // New in v1
private GemQuality _quality;
[Constructible]
public MagicGem() : base(0x1EA7)
{
_charges = Utility.RandomMinMax(5, 15);
_quality = GemQuality.Rough;
}
}
```
After running `publish`, the generator creates a `V0Content` struct. You must provide a migration:
```csharp
// In MagicGem.Migrations.cs (separate partial file)
public partial class MagicGem
{
private void MigrateFrom(V0Content content)
{
_charges = content.Charges;
// _quality gets its default value (GemQuality.Rough)
}
}
```
### The MigrateFrom Pattern
- Method signature: `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.
- Add one `MigrateFrom` for each older version that needs a migration path.
:::tip
Since the class is `partial`, create a standalone `MyClass.Migrations.cs` file to keep migrations organized.
:::
### Migrating from Pre-Codegen
To migrate a class that previously used manual `Serialize`/`Deserialize`:
1. Set `encoded` to `false` if the old code used `reader.ReadInt()` for the version:
```csharp
[SerializationGenerator(3, false)] // Old version was 2, bumped to 3
```
2. Keep the old deserialization logic as a private method:
```csharp
private void Deserialize(IGenericReader reader, int version)
{
// Old deserialization logic here
}
```
This method is called automatically for saves that predate the serialization generator.
:::warning
**Never** modify `Deserialize(IGenericReader reader, int version)` for post-codegen version bumps. That method only handles legacy (pre-codegen) saves. All new version transitions must use `MigrateFrom`.
:::
---
## After Deserialization
Use `[AfterDeserialization]` to run code after an entity's fields are loaded:
```csharp
[AfterDeserialization]
private void AfterDeserialization()
{
// Restart timers, compute derived values
Timer.StartTimer(TimeSpan.FromSeconds(5), CheckExpiry, out _timerToken);
}
```
The attribute accepts an optional `synchronous` parameter:
| Value | Timing | Use When |
|:------|:-------|:---------|
| `true` (default) | Immediately after this entity loads | Restarting timers, setting up derived state from own fields |
| `false` | After **all** entities in the world are loaded | Logic that depends on other entities, calls `Delete()`, or affects game state |
```csharp
[AfterDeserialization(false)]
private void AfterDeserialization()
{
if (_expireTime < Core.Now)
{
Delete(); // Safe -- all entities are loaded
}
}
```
---
## Important Rules
1. **Class must be `partial`** -- the generator adds code to your class via a separate file.
2. **`TimerExecutionToken` must NOT have `[SerializableField]`** -- it is not serializable. Restart timers in `[AfterDeserialization]`.
3. **Call `this.MarkDirty()`** in any custom property setter to flag the entity for saving.
4. **Use `[AfterDeserialization]`** to restart timers after world load -- never create timers inside the deserialization path directly.
5. **For new classes**, omit the `encoded` parameter: `[SerializationGenerator(0)]`.
6. **Field index order matters** -- fields are serialized/deserialized in index order. Never reorder without bumping the version.
---
## Complete Example
```csharp
using ModernUO.Serialization;
namespace Server.Items;
public enum GemQuality { Rough, Cut, Flawless }
[SerializationGenerator(1)]
public partial class MagicGem : Item
{
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
[SerializableField(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}");
list.Add($"{"Quality: "}{_quality}");
}
public override void OnDoubleClick(Mobile from)
{
if (!IsChildOf(from.Backpack))
{
from.SendLocalizedMessage(1042001);
return;
}
if (_charges <= 0)
{
from.SendMessage("The gem is depleted.");
return;
}
_charges--;
InvalidateProperties();
this.MarkDirty();
from.SendMessage("The gem pulses with energy!");
}
}
```
Migration file (`MagicGem.Migrations.cs`):
```csharp
namespace Server.Items;
public partial class MagicGem
{
private void MigrateFrom(V0Content content)
{
_charges = content.Charges;
// _quality defaults to GemQuality.Rough
}
}
```