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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue