349 lines
11 KiB
Markdown
349 lines
11 KiB
Markdown
---
|
|
name: modernuo-serialization
|
|
description: >
|
|
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
|
|
|
|
1. **Always use `partial` class** when applying `[SerializationGenerator]`
|
|
2. **Always add `[Constructible]`** on parameterless constructors for Items/Mobiles
|
|
3. **Never serialize `TimerExecutionToken`** -- restore timers in `[AfterDeserialization]`
|
|
4. **Call `this.MarkDirty()`** in custom property setters that modify serialized state
|
|
5. **Use `using ModernUO.Serialization;`** for serialization attributes
|
|
6. **Field order matters** -- `[SerializableField(N)]` index determines serialization order
|
|
7. **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, pass `false` if old code used `reader.ReadInt()` (not `ReadEncodedInt()`)
|
|
|
|
```csharp
|
|
// 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 public
|
|
- `saveIf`: Condition method name for conditional serialization
|
|
|
|
```csharp
|
|
[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 order
|
|
- `useField`: Backing field name if auto-detection fails
|
|
|
|
```csharp
|
|
[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).
|
|
|
|
```csharp
|
|
[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 deserialization
|
|
- `false`: 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.
|
|
|
|
```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();
|
|
}
|
|
}
|
|
```
|
|
|
|
### [DeserializeTimerField(fieldIndex)]
|
|
Custom timer deserialization. Timer is saved as remaining TimeSpan.
|
|
|
|
```csharp
|
|
[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.
|
|
|
|
```csharp
|
|
[SerializableFieldSaveFlag(0)]
|
|
private bool ShouldSerializeMaxItems() => _maxItems != -1;
|
|
|
|
[SerializableFieldDefault(0)]
|
|
private int MaxItemsDefaultValue() => -1;
|
|
```
|
|
|
|
### [TypeAlias(aliases)]
|
|
Maps old type names for backward-compatible deserialization.
|
|
|
|
```csharp
|
|
[TypeAlias("Server.Mobiles.Bear")]
|
|
[SerializationGenerator(0)]
|
|
public partial class BlackBear : BaseCreature { }
|
|
```
|
|
|
|
## Patterns
|
|
|
|
### Minimal Item (Version 0, No Custom Fields)
|
|
```csharp
|
|
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
|
|
```csharp
|
|
[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
|
|
```csharp
|
|
[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.
|
|
|
|
```csharp
|
|
// 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:
|
|
|
|
```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)
|
|
}
|
|
}
|
|
```
|
|
|
|
- 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 in the old version get their default values
|
|
- Use `.Migrations.cs` partial files for organization
|
|
|
|
## Anti-Patterns
|
|
|
|
- **Missing `partial`**: `[SerializationGenerator]` requires `partial class`
|
|
- **Serializing timers**: `TimerExecutionToken` cannot 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 call `this.MarkDirty()`
|
|
- **Wrong field prefix**: Use `_camelCase`, not `m_camelCase` for new fields
|
|
- **Forgetting `[Constructible]`**: Items/Mobiles need this for `[add` command
|
|
- **Modifying `Deserialize(reader, version)` for version bumps**: `Deserialize` exists ONLY for pre-codegen legacy saves. Use `MigrateFrom(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 documentation
|
|
- `dev-docs/claude-skills/modernuo-timers.md` - Timer token patterns
|
|
- `dev-docs/claude-skills/modernuo-content-patterns.md` - Item/Mobile templates
|
|
- `dev-docs/claude-skills/modernuo-property-lists.md` - [InvalidateProperties] usage
|