docs: update serialization docs and skills for generator v4
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>
This commit is contained in:
parent
b042edcf0b
commit
76bbcd88c4
7 changed files with 219 additions and 63 deletions
|
|
@ -18,7 +18,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`.
|
||||||
6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()`
|
6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()`
|
||||||
7. **`STArrayPool<T>.Shared`** not `ArrayPool<T>.Shared` — single-threaded optimized, no locks
|
7. **`STArrayPool<T>.Shared`** not `ArrayPool<T>.Shared` — single-threaded optimized, no locks
|
||||||
8. **`PooledRefList<T>`** not `new List<T>()` on hot paths — zero GC pressure, stack-allocated ref struct
|
8. **`PooledRefList<T>`** not `new List<T>()` on hot paths — zero GC pressure, stack-allocated ref struct
|
||||||
9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/runuo-migration-docs/02-serialization.md`
|
9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). Setters that coerce/veto/run side effects: use `[SerializableField]` args `allowFieldChange: nameof(BoolRefMethod)` / `fieldChanged: nameof(OldNewMethod)` — reserve `[SerializableProperty]` for custom getters. Serializable `Timer` members declare `[DeserializeTimer(nameof(Method))]` on the field (anchored by default — downtime preserves remaining delay; `wallClock: true` = absolute; method runs only when a timer was running at save). Conditional writes: `[SaveFlag(nameof(Should), nameof(Default))]` on the field. When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/serialization.md`, `dev-docs/runuo-migration-docs/02-serialization.md`
|
||||||
10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). **Prove the need before adding a thread**: measure **on-loop** time, not wall-clock (frozen world is the cost, player latency is not), and gate on `Environment.ProcessorCount` — off-loading creates no CPU and buys nothing on 1–2 cores. New workers go in the vetted table in `dev-docs/threading-model.md` with their measurement. When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`, re-validating in the continuation whatever may have changed while it ran. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md`
|
10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). **Prove the need before adding a thread**: measure **on-loop** time, not wall-clock (frozen world is the cost, player latency is not), and gate on `Environment.ProcessorCount` — off-loading creates no CPU and buys nothing on 1–2 cores. New workers go in the vetted table in `dev-docs/threading-model.md` with their measurement. When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`, re-validating in the continuation whatever may have changed while it ran. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md`
|
||||||
11. **Never assume era** — if code uses `Core.AOS`/`Core.SE`/etc., ask which expansion to target
|
11. **Never assume era** — if code uses `Core.AOS`/`Core.SE`/etc., ask which expansion to target
|
||||||
12. **Naming** — `_camelCase` private fields, `PascalCase` properties/methods/classes; don't flag legacy `m_` but use `_` for new code
|
12. **Naming** — `_camelCase` private fields, `PascalCase` properties/methods/classes; don't flag legacy `m_` but use `_` for new code
|
||||||
|
|
|
||||||
|
|
@ -40,21 +40,31 @@ public partial class MyItem : Item { }
|
||||||
public partial class MigratedItem : Item { }
|
public partial class MigratedItem : Item { }
|
||||||
```
|
```
|
||||||
|
|
||||||
### [SerializableField(index, setter, saveIf)]
|
### [SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)]
|
||||||
Applied to `_camelCase` private fields. Generates `PascalCase` property.
|
Applied to `_camelCase` private fields. Generates `PascalCase` property.
|
||||||
- `index`: Serialization order (0+)
|
- `index`: Serialization order (0+)
|
||||||
- `setter`: Access level -- `"private"`, `"internal"`, or omit for public
|
- `getter`/`setter`: Access level -- `"private"`, `"internal"`, or omit for public
|
||||||
- `saveIf`: Condition method name for conditional serialization
|
- `isVirtual`: Generate a virtual property
|
||||||
|
- `fieldChanged`: `nameof` of `void Method(T oldValue, T newValue)`, invoked by the generated setter after assignment
|
||||||
|
- `allowFieldChange`: `nameof` of `bool Method(ref T value)`, invoked before assignment -- coerce through the `ref` parameter or return `false` to 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.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
[SerializableField(0)]
|
[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
|
[InvalidateProperties]
|
||||||
private int _charges;
|
private int _charges;
|
||||||
// Generates: public int Charges { get; set; }
|
|
||||||
|
private bool AllowChargesChange(ref int value)
|
||||||
|
{
|
||||||
|
value = Math.Clamp(value, 0, MaxCharges);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### [SerializableProperty(index, useField)]
|
### [SerializableProperty(index, useField)]
|
||||||
Applied to properties with custom get/set logic.
|
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 order
|
- `index`: Serialization order
|
||||||
- `useField`: Backing field name if auto-detection fails
|
- `useField`: Backing field name if auto-detection fails
|
||||||
|
|
||||||
|
|
@ -63,12 +73,12 @@ Applied to properties with custom get/set logic.
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
public int MaxItems
|
public int MaxItems
|
||||||
{
|
{
|
||||||
get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
|
get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_maxItems = value;
|
_maxItems = value;
|
||||||
InvalidateProperties();
|
InvalidateProperties();
|
||||||
this.MarkDirty();
|
this.MarkDirty(); // REQUIRED in custom setters
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -89,8 +99,11 @@ Exposes field to `[Props` gump for in-game editing.
|
||||||
### [EncodedInt]
|
### [EncodedInt]
|
||||||
Variable-length int encoding (saves space for small values).
|
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]
|
### [DeltaDateTime]
|
||||||
Stores DateTime as offset from current time (handles server restarts).
|
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]
|
### [InternString]
|
||||||
Interns strings to reduce memory for repeated values.
|
Interns strings to reduce memory for repeated values.
|
||||||
|
|
@ -128,28 +141,32 @@ private void AfterDeserialization()
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### [DeserializeTimerField(fieldIndex)]
|
### [DeserializeTimer(nameof(Method), wallClock)]
|
||||||
Custom timer deserialization. Timer is saved as remaining TimeSpan.
|
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.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
[SerializableField(0, setter: "private")]
|
[SerializableField(0, setter: "private")]
|
||||||
|
[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)]
|
||||||
private Timer _evaluateTimer;
|
private Timer _evaluateTimer;
|
||||||
|
|
||||||
[DeserializeTimerField(0)]
|
|
||||||
private void DeserializeEvaluateTimer(TimeSpan delay)
|
private void DeserializeEvaluateTimer(TimeSpan delay)
|
||||||
{
|
{
|
||||||
_evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate);
|
_evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)]
|
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).
|
||||||
Conditional serialization -- skip fields with default values.
|
|
||||||
|
### [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.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
[SerializableFieldSaveFlag(0)]
|
[SerializableField(0)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))]
|
||||||
|
private int _maxItems;
|
||||||
|
|
||||||
private bool ShouldSerializeMaxItems() => _maxItems != -1;
|
private bool ShouldSerializeMaxItems() => _maxItems != -1;
|
||||||
|
|
||||||
[SerializableFieldDefault(0)]
|
|
||||||
private int MaxItemsDefaultValue() => -1;
|
private int MaxItemsDefaultValue() => -1;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -216,28 +233,35 @@ public partial class ChargedItem : Item
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Item with Custom Properties
|
### Item with Setter Hooks (coerce + side effects)
|
||||||
```csharp
|
```csharp
|
||||||
[SerializationGenerator(2)]
|
[SerializationGenerator(2)]
|
||||||
public partial class BagOfSending : Item
|
public partial class BagOfSending : Item
|
||||||
{
|
{
|
||||||
[SerializableProperty(0)]
|
[SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
public BagOfSendingHue BagOfSendingHue
|
private BagOfSendingHue _bagOfSendingHue;
|
||||||
|
|
||||||
|
private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue)
|
||||||
{
|
{
|
||||||
get => _bagOfSendingHue;
|
Hue = newValue switch
|
||||||
set
|
|
||||||
{
|
{
|
||||||
_bagOfSendingHue = value;
|
BagOfSendingHue.Yellow => 0x8A5,
|
||||||
Hue = value switch
|
BagOfSendingHue.Blue => 0x8AD,
|
||||||
{
|
BagOfSendingHue.Red => 0x89B,
|
||||||
BagOfSendingHue.Yellow => 0x8A5,
|
_ => Hue
|
||||||
BagOfSendingHue.Blue => 0x8AD,
|
};
|
||||||
BagOfSendingHue.Red => 0x89B,
|
}
|
||||||
_ => Hue
|
|
||||||
};
|
[SerializableField(1, allowFieldChange: nameof(AllowChargesChange))]
|
||||||
this.MarkDirty();
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
}
|
[InvalidateProperties]
|
||||||
|
private int _charges;
|
||||||
|
|
||||||
|
private bool AllowChargesChange(ref int value)
|
||||||
|
{
|
||||||
|
value = Math.Clamp(value, 0, MaxCharges);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -328,11 +352,13 @@ public partial class MagicGem
|
||||||
## Real Examples
|
## Real Examples
|
||||||
- Simple creature: `Projects/UOContent/Mobiles/Animals/Bears/BlackBear.cs`
|
- Simple creature: `Projects/UOContent/Mobiles/Animals/Bears/BlackBear.cs`
|
||||||
- Serialized fields + timer: `Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs`
|
- Serialized fields + timer: `Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs`
|
||||||
- Custom properties: `Projects/UOContent/Items/Special/Solen Items/BagOfSending.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`
|
- Complex with AfterDeserialization: `Projects/UOContent/Accounting/Account.cs`
|
||||||
- Timer deserialization: `Projects/UOContent/Items/Aquarium/Aquarium.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`
|
- Tidy + DeltaDateTime: `Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs`
|
||||||
- Conditional serialization: `Projects/Server/Items/Container.cs`
|
- Conditional serialization ([SaveFlag]): `Projects/Server/Items/Container.cs`
|
||||||
|
|
||||||
## Version Migration
|
## Version Migration
|
||||||
Migration schemas are JSON files in `Projects/Server/Migrations/` and `Projects/UOContent/Migrations/`:
|
Migration schemas are JSON files in `Projects/Server/Migrations/` and `Projects/UOContent/Migrations/`:
|
||||||
|
|
|
||||||
|
|
@ -147,18 +147,27 @@ public partial class DecayingItem : Item
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### [DeserializeTimerField] Pattern (for Timer fields)
|
### [DeserializeTimer] Pattern (for Timer fields)
|
||||||
|
Required on every serializable `Timer` member. Drifting by default: the next tick is stored
|
||||||
|
as anchored time, so server downtime does not consume the remaining delay. Use
|
||||||
|
`wallClock: true` for absolute deadlines (delay is negative if it passed during downtime).
|
||||||
|
The method is invoked **only when a timer was running at save** — no sentinel to check.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
[SerializableField(0, setter: "private")]
|
[SerializableField(0, setter: "private")]
|
||||||
|
[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)]
|
||||||
private Timer _evaluateTimer;
|
private Timer _evaluateTimer;
|
||||||
|
|
||||||
[DeserializeTimerField(0)]
|
|
||||||
private void DeserializeEvaluateTimer(TimeSpan delay)
|
private void DeserializeEvaluateTimer(TimeSpan delay)
|
||||||
{
|
{
|
||||||
_evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate);
|
_evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Switching an existing timer between drifting and `wallClock` changes the wire format — bump
|
||||||
|
the class's `[SerializationGenerator]` version and add a `MigrateFrom` (the old content
|
||||||
|
struct exposes `XxxDelay`, `TimeSpan.MinValue` when no timer ran).
|
||||||
|
|
||||||
### Custom Timer Class (When You Need Complex Logic)
|
### Custom Timer Class (When You Need Complex Logic)
|
||||||
```csharp
|
```csharp
|
||||||
private class DecayTimer : Timer
|
private class DecayTimer : Timer
|
||||||
|
|
|
||||||
|
|
@ -457,16 +457,24 @@ set
|
||||||
```
|
```
|
||||||
Without this, changes won't be saved.
|
Without this, changes won't be saved.
|
||||||
|
|
||||||
|
Most RunUO custom setters only clamp the value or run side effects after assignment. Those
|
||||||
|
convert to a plain `[SerializableField]` with the `allowFieldChange`/`fieldChanged` hooks,
|
||||||
|
which handle the equality check and `MarkDirty()` for you -- reserve `[SerializableProperty]`
|
||||||
|
for custom getters (see `dev-docs/serialization.md`).
|
||||||
|
|
||||||
### 3. Field Ordering
|
### 3. Field Ordering
|
||||||
The `[SerializableField(N)]` index determines serialization order. Choose a logical order and don't change it after the first save — or increment the version.
|
The `[SerializableField(N)]` index determines serialization order. Choose a logical order and don't change it after the first save — or increment the version.
|
||||||
|
|
||||||
### 4. Conditional Serialization
|
### 4. Conditional Serialization
|
||||||
Use `[SerializableFieldSaveFlag]` and `[SerializableFieldDefault]` to skip default values:
|
Use `[SaveFlag]` on the serializable field to skip default values (the second method is
|
||||||
|
optional -- omit it and the field keeps its default at load):
|
||||||
```csharp
|
```csharp
|
||||||
[SerializableFieldSaveFlag(0)]
|
[SerializableField(0)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))]
|
||||||
|
private int _maxItems;
|
||||||
|
|
||||||
private bool ShouldSerializeMaxItems() => _maxItems != -1;
|
private bool ShouldSerializeMaxItems() => _maxItems != -1;
|
||||||
|
|
||||||
[SerializableFieldDefault(0)]
|
|
||||||
private int MaxItemsDefaultValue() => -1;
|
private int MaxItemsDefaultValue() => -1;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -479,12 +487,15 @@ private List<Mobile> _followers;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. DateTime Fields
|
### 6. DateTime Fields
|
||||||
Use `[DeltaDateTime]` to survive server restarts:
|
Use `[AnchoredDateTime]` to survive server restarts -- the value is shifted by downtime at
|
||||||
|
load, so the remaining time is preserved and idle saves stay byte-stable:
|
||||||
```csharp
|
```csharp
|
||||||
[DeltaDateTime]
|
[AnchoredDateTime]
|
||||||
[SerializableField(0)]
|
[SerializableField(0)]
|
||||||
private DateTime _expireTime;
|
private DateTime _expireTime;
|
||||||
```
|
```
|
||||||
|
(`[DeltaDateTime]` is the legacy equivalent; it rewrites bytes on every save. Converting an
|
||||||
|
existing field between the two changes the wire format and requires a version bump.)
|
||||||
|
|
||||||
### 7. Keeping Manual Serialization (Rare)
|
### 7. Keeping Manual Serialization (Rare)
|
||||||
Some edge cases still need manual serialization. If a type has complex conditional logic that can't be expressed with attributes, you can implement `ISerializable` manually. But this is rare — try attributes first.
|
Some edge cases still need manual serialization. If a type has complex conditional logic that can't be expressed with attributes, you can implement `ISerializable` manually. But this is rare — try attributes first.
|
||||||
|
|
|
||||||
|
|
@ -305,7 +305,7 @@ In RunUO, timers are commonly started in `Deserialize()`. In ModernUO, use `[Aft
|
||||||
`_token.Cancel()` can be called on a default token, a stopped token, or an already-cancelled token. No null checks needed.
|
`_token.Cancel()` can be called on a default token, a stopped token, or an already-cancelled token. No null checks needed.
|
||||||
|
|
||||||
### 4. Timer.DelayCall Still Exists
|
### 4. Timer.DelayCall Still Exists
|
||||||
`Timer.DelayCall()` is still available and returns a `Timer` object. Use it when you need the `Timer` reference (e.g., for `[DeserializeTimerField]`) or state-carrying overloads.
|
`Timer.DelayCall()` is still available and returns a `Timer` object. Use it when you need the `Timer` reference (e.g., for a serialized timer field with `[DeserializeTimer]`) or state-carrying overloads.
|
||||||
|
|
||||||
### 5. Custom Timer Classes Are Still Possible
|
### 5. Custom Timer Classes Are Still Possible
|
||||||
For complex timer logic (e.g., `Corpse.DecayTimer`), you can still subclass `Timer` with `OnTick()`. But prefer the fire-and-forget pattern for simple cases.
|
For complex timer logic (e.g., `Corpse.DecayTimer`), you can still subclass `Timer` with `OnTick()`. But prefer the fire-and-forget pattern for simple cases.
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ public partial class MyItem : Item { }
|
||||||
|
|
||||||
See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration guidance.
|
See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration guidance.
|
||||||
|
|
||||||
### [SerializableField(index, setter, saveIf)]
|
### [SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)]
|
||||||
|
|
||||||
**Target**: Private field (`_camelCase`)
|
**Target**: Private field (`_camelCase`)
|
||||||
**Generates**: Public `PascalCase` property with get/set
|
**Generates**: Public `PascalCase` property with get/set
|
||||||
|
|
@ -125,8 +125,11 @@ See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration g
|
||||||
| Parameter | Type | Default | Description |
|
| Parameter | Type | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `index` | `int` | Required | Serialization order (0-based) |
|
| `index` | `int` | Required | Serialization order (0-based) |
|
||||||
| `setter` | `string` | `null` (public) | `"private"` or `"internal"` to restrict setter |
|
| `getter` | `string` | `"public"` | Getter accessibility |
|
||||||
| `saveIf` | `string` | `null` | Method name returning bool for conditional save |
|
| `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
|
```csharp
|
||||||
[SerializableField(0)] // Public property
|
[SerializableField(0)] // Public property
|
||||||
|
|
@ -144,14 +147,57 @@ The generated property for `_charges` would be:
|
||||||
public int Charges
|
public int Charges
|
||||||
{
|
{
|
||||||
get => _charges;
|
get => _charges;
|
||||||
set { _charges = value; this.MarkDirty(); }
|
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)]
|
### [SerializableProperty(index, useField)]
|
||||||
|
|
||||||
**Target**: Property with custom get/set logic
|
**Target**: Property with custom get/set logic
|
||||||
**Use when**: You need non-trivial getter/setter 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 |
|
| Parameter | Type | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
|
@ -163,7 +209,7 @@ public int Charges
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
public int MaxItems
|
public int MaxItems
|
||||||
{
|
{
|
||||||
get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
|
get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_maxItems = value;
|
_maxItems = value;
|
||||||
|
|
@ -173,6 +219,10 @@ public int MaxItems
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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]
|
### [InvalidateProperties]
|
||||||
|
|
||||||
**Target**: `[SerializableField]`-decorated field
|
**Target**: `[SerializableField]`-decorated field
|
||||||
|
|
@ -208,12 +258,32 @@ Overloads:
|
||||||
|
|
||||||
Best for fields that are usually small values (counts, IDs, indexes).
|
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]
|
### [DeltaDateTime]
|
||||||
|
|
||||||
**Target**: `DateTime` field
|
**Target**: `DateTime` field
|
||||||
**Effect**: Stores as offset from current time rather than absolute timestamp.
|
**Effect**: Stores as offset from current time rather than absolute timestamp.
|
||||||
|
|
||||||
This ensures timers and expiration dates survive server restarts correctly.
|
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
|
```csharp
|
||||||
[DeltaDateTime]
|
[DeltaDateTime]
|
||||||
|
|
@ -289,40 +359,76 @@ private void AfterDeserialization()
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### [DeserializeTimerField(fieldIndex)]
|
### [DeserializeTimer(nameof(Method), wallClock)]
|
||||||
|
|
||||||
**Target**: Method taking `TimeSpan` parameter
|
**Target**: `Timer`-typed `[SerializableField]` or `[SerializableProperty]` member
|
||||||
**Effect**: Custom deserialization for Timer fields. The timer is saved as remaining delay.
|
**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
|
```csharp
|
||||||
[SerializableField(0, setter: "private")]
|
[SerializableField(0, setter: "private")]
|
||||||
|
[DeserializeTimer(nameof(DeserializeDecayTimer))]
|
||||||
private Timer _decayTimer;
|
private Timer _decayTimer;
|
||||||
|
|
||||||
[DeserializeTimerField(0)]
|
private void DeserializeDecayTimer(TimeSpan delay) => _decayTimer = Timer.DelayCall(delay, Delete);
|
||||||
private void DeserializeDecayTimer(TimeSpan delay)
|
```
|
||||||
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
_decayTimer = Timer.DelayCall(delay, Delete);
|
if (content.DecayTimerDelay != TimeSpan.MinValue)
|
||||||
_decayTimer.Start();
|
{
|
||||||
|
DeserializeDecayTimer(content.DecayTimerDelay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)]
|
### [SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))]
|
||||||
|
|
||||||
|
**Target**: the serializable field or property itself
|
||||||
**Conditional serialization** -- skip fields that have their default value.
|
**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
|
```csharp
|
||||||
[EncodedInt]
|
[EncodedInt]
|
||||||
[SerializableProperty(0)]
|
[SerializableProperty(0)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))]
|
||||||
public int MaxItems
|
public int MaxItems
|
||||||
{
|
{
|
||||||
get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
|
get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
|
||||||
set { _maxItems = value; this.MarkDirty(); }
|
set { _maxItems = value; this.MarkDirty(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(0)]
|
|
||||||
private bool ShouldSerializeMaxItems() => _maxItems != -1;
|
private bool ShouldSerializeMaxItems() => _maxItems != -1;
|
||||||
|
|
||||||
[SerializableFieldDefault(0)]
|
|
||||||
private int MaxItemsDefaultValue() => -1;
|
private int MaxItemsDefaultValue() => -1;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,15 +188,19 @@ public partial class TimedItem : Item
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pattern 4: Serializable Timer Field
|
### Pattern 4: Serializable Timer Field
|
||||||
|
Every serializable `Timer` member declares `[DeserializeTimer(nameof(Method))]` on the
|
||||||
|
field. By default the next tick is stored as anchored time (downtime does not consume the
|
||||||
|
remaining delay); pass `wallClock: true` for absolute deadlines. The method runs **only when
|
||||||
|
a timer was running at save**, with the remaining delay.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
[SerializableField(0, setter: "private")]
|
[SerializableField(0, setter: "private")]
|
||||||
|
[DeserializeTimer(nameof(DeserializeDecayTimer))]
|
||||||
private Timer _decayTimer;
|
private Timer _decayTimer;
|
||||||
|
|
||||||
[DeserializeTimerField(0)]
|
|
||||||
private void DeserializeDecayTimer(TimeSpan delay)
|
private void DeserializeDecayTimer(TimeSpan delay)
|
||||||
{
|
{
|
||||||
_decayTimer = Timer.DelayCall(delay, Delete);
|
_decayTimer = Timer.DelayCall(delay, Delete);
|
||||||
_decayTimer.Start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void BeginDecay(TimeSpan delay)
|
public void BeginDecay(TimeSpan delay)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue