docs: update serialization docs and skills for generator v4 (#2588)

## Summary

Brings every serialization-related doc, skill, and the CLAUDE.md rule in line with generator **v4** (adopted in #2586/#2587). No code changes.

**Updated surface, everywhere it was referenced:**
- `[SerializableFieldSaveFlag(order)]` / `[SerializableFieldDefault(order)]` → `[SaveFlag(nameof(Should), nameof(Default))]` on the field (second method optional).
- `[TimerDrift]` + `[DeserializeTimerField(order)]` → `[DeserializeTimer(nameof(Method), wallClock)]` on the field, with the anchored-time semantics spelled out: drifting by default (downtime preserves the remaining delay, idle saves byte-stable), `wallClock: true` for absolute deadlines, restart method invoked **only when a timer was running** (no sentinel), and the timer `MigrateFrom` pattern (`XxxNext`/`XxxDelay`) for wire-format changes.
- New `[SerializableField]` documentation: the real signature (the documented `saveIf` parameter never existed) plus the setter hooks — `allowFieldChange` (`bool Method(ref T value)`: coerce/veto before assignment) and `fieldChanged` (`void Method(T oldValue, T newValue)` after) — with the generated pipeline and the SG3015/SG3018 guardrails.
- `[SerializableProperty]` guidance narrowed to its remaining purpose: custom getters and setter semantics the hooks cannot express.
- `[AnchoredDateTime]` documented alongside `[DeltaDateTime]` (now marked legacy, with the version-bump warning for converting between them).

**Files:** `dev-docs/serialization.md`, `dev-docs/timers.md`, `dev-docs/claude-skills/modernuo-serialization.md`, `dev-docs/claude-skills/modernuo-timers.md`, `dev-docs/runuo-migration-docs/02-serialization.md`, `dev-docs/runuo-migration-docs/03-timers.md`, and a condensed v4 addition to CLAUDE.md rule 9.

**Example refresh:** the skill's `BagOfSending` "custom properties" example was itself converted in #2587 — it is now quoted in its real post-conversion form as the canonical hooks example; the real-examples list points at `BaseWeapon.cs` for custom getters and `BaseLight.cs` for the drifting-timer + `MigrateFrom` pattern.

Verified by grep: zero references to the removed v3 attribute names remain anywhere in `dev-docs/` or `CLAUDE.md`.
This commit is contained in:
Kamron Batman 2026-08-22 18:29:27 -07:00 committed by GitHub
parent b042edcf0b
commit b992c7b955
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 219 additions and 63 deletions

View file

@ -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()`
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
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 12 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
12. **Naming**`_camelCase` private fields, `PascalCase` properties/methods/classes; don't flag legacy `m_` but use `_` for new code

View file

@ -40,21 +40,31 @@ public partial class MyItem : 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.
- `index`: Serialization order (0+)
- `setter`: Access level -- `"private"`, `"internal"`, or omit for public
- `saveIf`: Condition method name for conditional serialization
- `getter`/`setter`: Access level -- `"private"`, `"internal"`, or omit for public
- `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
[SerializableField(0)]
[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
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)]
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
- `useField`: Backing field name if auto-detection fails
@ -63,12 +73,12 @@ Applied to properties with custom get/set logic.
[CommandProperty(AccessLevel.GameMaster)]
public int MaxItems
{
get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property
set
{
_maxItems = value;
InvalidateProperties();
this.MarkDirty();
this.MarkDirty(); // REQUIRED in custom setters
}
}
```
@ -89,8 +99,11 @@ Exposes field to `[Props` gump for in-game editing.
### [EncodedInt]
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]
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]
Interns strings to reduce memory for repeated values.
@ -128,28 +141,32 @@ private void AfterDeserialization()
}
```
### [DeserializeTimerField(fieldIndex)]
Custom timer deserialization. Timer is saved as remaining TimeSpan.
### [DeserializeTimer(nameof(Method), wallClock)]
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
[SerializableField(0, setter: "private")]
[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)]
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.
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).
### [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
[SerializableFieldSaveFlag(0)]
[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))]
private int _maxItems;
private bool ShouldSerializeMaxItems() => _maxItems != -1;
[SerializableFieldDefault(0)]
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
[SerializationGenerator(2)]
public partial class BagOfSending : Item
{
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public BagOfSendingHue BagOfSendingHue
[SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private BagOfSendingHue _bagOfSendingHue;
private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue)
{
get => _bagOfSendingHue;
set
Hue = newValue switch
{
_bagOfSendingHue = value;
Hue = value switch
{
BagOfSendingHue.Yellow => 0x8A5,
BagOfSendingHue.Blue => 0x8AD,
BagOfSendingHue.Red => 0x89B,
_ => Hue
};
this.MarkDirty();
}
BagOfSendingHue.Yellow => 0x8A5,
BagOfSendingHue.Blue => 0x8AD,
BagOfSendingHue.Red => 0x89B,
_ => Hue
};
}
[SerializableField(1, allowFieldChange: nameof(AllowChargesChange))]
[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
- 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`
- 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`
- 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`
- Conditional serialization: `Projects/Server/Items/Container.cs`
- Conditional serialization ([SaveFlag]): `Projects/Server/Items/Container.cs`
## Version Migration
Migration schemas are JSON files in `Projects/Server/Migrations/` and `Projects/UOContent/Migrations/`:

View file

@ -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
[SerializableField(0, setter: "private")]
[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)]
private Timer _evaluateTimer;
[DeserializeTimerField(0)]
private void DeserializeEvaluateTimer(TimeSpan delay)
{
_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)
```csharp
private class DecayTimer : Timer

View file

@ -457,16 +457,24 @@ set
```
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
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
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
[SerializableFieldSaveFlag(0)]
[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))]
private int _maxItems;
private bool ShouldSerializeMaxItems() => _maxItems != -1;
[SerializableFieldDefault(0)]
private int MaxItemsDefaultValue() => -1;
```
@ -479,12 +487,15 @@ private List<Mobile> _followers;
```
### 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
[DeltaDateTime]
[AnchoredDateTime]
[SerializableField(0)]
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)
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.

View file

@ -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.
### 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
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.

View file

@ -117,7 +117,7 @@ public partial class MyItem : Item { }
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`)
**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 |
|---|---|---|---|
| `index` | `int` | Required | Serialization order (0-based) |
| `setter` | `string` | `null` (public) | `"private"` or `"internal"` to restrict setter |
| `saveIf` | `string` | `null` | Method name returning bool for conditional save |
| `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
@ -144,14 +147,57 @@ The generated property for `_charges` would be:
public int 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)]
**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 |
|---|---|---|---|
@ -163,7 +209,7 @@ public int Charges
[CommandProperty(AccessLevel.GameMaster)]
public int MaxItems
{
get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property
set
{
_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]
**Target**: `[SerializableField]`-decorated field
@ -208,12 +258,32 @@ Overloads:
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.
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
[DeltaDateTime]
@ -289,40 +359,76 @@ private void AfterDeserialization()
}
```
### [DeserializeTimerField(fieldIndex)]
### [DeserializeTimer(nameof(Method), wallClock)]
**Target**: Method taking `TimeSpan` parameter
**Effect**: Custom deserialization for Timer fields. The timer is saved as remaining delay.
**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;
[DeserializeTimerField(0)]
private void DeserializeDecayTimer(TimeSpan delay)
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)
{
_decayTimer = Timer.DelayCall(delay, Delete);
_decayTimer.Start();
if (content.DecayTimerDelay != TimeSpan.MinValue)
{
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.
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(); }
}
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeMaxItems() => _maxItems != -1;
[SerializableFieldDefault(0)]
private int MaxItemsDefaultValue() => -1;
```

View file

@ -188,15 +188,19 @@ public partial class TimedItem : Item
```
### 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
[SerializableField(0, setter: "private")]
[DeserializeTimer(nameof(DeserializeDecayTimer))]
private Timer _decayTimer;
[DeserializeTimerField(0)]
private void DeserializeDecayTimer(TimeSpan delay)
{
_decayTimer = Timer.DelayCall(delay, Delete);
_decayTimer.Start();
}
public void BeginDecay(TimeSpan delay)