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:
parent
b042edcf0b
commit
b992c7b955
7 changed files with 219 additions and 63 deletions
|
|
@ -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;
|
||||
```
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue