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()`
|
||||
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 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
|
||||
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 { }
|
||||
```
|
||||
|
||||
### [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/`:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue