Replaces order-based linkage ([SerializableFieldSaveFlag]/[SerializableFieldDefault]) with field-side [SaveFlag(nameof(...))], [TimerDrift]/[DeserializeTimerField] with [DeserializeTimer(nameof(Method), wallClock)], and documents the [SerializableField] setter hooks (allowFieldChange/fieldChanged), the anchored-time semantics for drifting timers, [AnchoredDateTime], and the timer MigrateFrom pattern. Narrows [SerializableProperty] guidance to custom getters. Also fixes the documented [SerializableField] signature (the saveIf parameter never existed) and refreshes real-code examples that were converted in the v4 migration PRs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
707 lines
22 KiB
Markdown
707 lines
22 KiB
Markdown
# 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<T>`, `Dictionary<K,V>`, etc.)
|
|
**Effect**: Removes null and deleted entries from the collection after deserialization.
|
|
|
|
```csharp
|
|
[Tidy]
|
|
[SerializableField(0)]
|
|
private List<Mobile> _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<T>` |
|
|
| `ArrayMigrationRule` | `T[]` |
|
|
| `DictionaryMigrationRule` | `Dictionary<K,V>` |
|
|
| `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 |
|