ModernUO/dev-docs/runuo-migration-docs/03-timers.md
Kamron Batman b992c7b955
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`.
2026-08-22 18:29:27 -07:00

8.9 KiB

Timer Migration

Overview

RunUO uses Timer subclass instances that you construct, start, and stop. ModernUO replaces most of this with fire-and-forget Timer.StartTimer() calls and lightweight TimerExecutionToken structs for cancellation. The TimerPriority enum is removed — ModernUO's timer wheel handles scheduling automatically with 8ms precision.

RunUO Pattern

// RunUO — Timer subclass pattern
public class MyItem : Item
{
    private InternalTimer m_Timer;

    [Constructable]
    public MyItem() : base(0x1234)
    {
        m_Timer = new InternalTimer(this);
        m_Timer.Start();
    }

    public MyItem(Serial serial) : base(serial) { }

    public override void OnDelete()
    {
        if (m_Timer != null)
            m_Timer.Stop();
    }

    public override void Serialize(GenericWriter writer)
    {
        base.Serialize(writer);
        writer.Write((int)0);
    }

    public override void Deserialize(GenericReader reader)
    {
        base.Deserialize(reader);
        int version = reader.ReadInt();

        m_Timer = new InternalTimer(this);
        m_Timer.Start();
    }

    private void DoWork()
    {
        // Timer callback logic
    }

    private class InternalTimer : Timer
    {
        private MyItem m_Item;

        public InternalTimer(MyItem item) : base(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5))
        {
            m_Item = item;
            Priority = TimerPriority.OneSecond;
        }

        protected override void OnTick()
        {
            m_Item.DoWork();
        }
    }
}

RunUO Timer.DelayCall

// One-shot delay
Timer.DelayCall(TimeSpan.FromSeconds(5), new TimerCallback(DoWork));
Timer.DelayCall(TimeSpan.FromSeconds(5), new TimerStateCallback(DoWork), target);

// Repeating
Timer.DelayCall(TimeSpan.Zero, TimeSpan.FromSeconds(1), new TimerCallback(DoWork));

ModernUO Equivalent

using ModernUO.Serialization;

namespace Server.Items;

[SerializationGenerator(0)]
public partial class MyItem : Item
{
    private TimerExecutionToken _timerToken;

    [Constructible]
    public MyItem() : base(0x1234)
    {
        StartTimer();
    }

    private void StartTimer()
    {
        Timer.StartTimer(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), DoWork, out _timerToken);
    }

    [AfterDeserialization]
    private void AfterDeserialization() => StartTimer();

    public override void OnAfterDelete()
    {
        _timerToken.Cancel();
        base.OnAfterDelete();
    }

    private void DoWork()
    {
        // Timer callback logic
    }
}

Migration Mapping Table

RunUO ModernUO Notes
new InternalTimer().Start() Timer.StartTimer(..., callback, out token) Fire-and-forget
Timer subclass with OnTick() Static callback method No class needed
timer.Stop() _token.Cancel() Lightweight struct
timer.Running _token.Running Same concept
TimerPriority.OneSecond (removed) Timer wheel handles scheduling
TimerPriority.FiveSeconds (removed) Timer wheel handles scheduling
Timer.DelayCall(delay, callback) Timer.StartTimer(delay, callback) Similar API
Timer.DelayCall(delay, callback, state) Timer.DelayCall(delay, callback, state) State-carrying version still exists
new TimerCallback(Method) Method Direct method reference
new TimerStateCallback(Method) Use state-carrying overload Timer.DelayCall(delay, Method, arg1, arg2)
Timer started in Deserialize() [AfterDeserialization] method Never start timers in deserialization
m_Timer != null check _token.Running check Token is a value type, always valid

Step-by-Step Conversion

Step 1: Identify the Timer Pattern

Look for:

  • Nested Timer subclass with OnTick() override
  • Timer.DelayCall() calls
  • TimerPriority usage

Step 2: Extract the Callback

Move the OnTick() logic to a regular method on the parent class:

// RunUO — nested class
private class InternalTimer : Timer
{
    private MyItem m_Item;
    public InternalTimer(MyItem item) : base(TimeSpan.FromSeconds(5)) { m_Item = item; }
    protected override void OnTick() { m_Item.DoWork(); }
}

// ModernUO — just the method
private void DoWork()
{
    // Same logic, directly on the item
}

Step 3: Replace Construction with Timer.StartTimer

// RunUO
m_Timer = new InternalTimer(this);
m_Timer.Start();

// ModernUO (one-shot)
Timer.StartTimer(TimeSpan.FromSeconds(5), DoWork);

// ModernUO (repeating, need cancellation)
Timer.StartTimer(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), DoWork, out _timerToken);

// ModernUO (repeating with count limit)
Timer.StartTimer(TimeSpan.Zero, TimeSpan.FromSeconds(1), 10, DoWork, out _timerToken);

Step 4: Add TimerExecutionToken Field (if cancellable)

private TimerExecutionToken _timerToken; // NOT serialized — no [SerializableField]

Step 5: Cancel in OnAfterDelete

public override void OnAfterDelete()
{
    _timerToken.Cancel(); // Safe to call multiple times
    base.OnAfterDelete();
}

Step 6: Restore in [AfterDeserialization]

[AfterDeserialization]
private void AfterDeserialization()
{
    Timer.StartTimer(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), DoWork, out _timerToken);
}

Step 7: Delete the Nested Timer Class

Remove the entire private class InternalTimer : Timer { ... } block.

Step 8: Remove TimerPriority

Delete any Priority = TimerPriority.xxx lines. The timer wheel handles scheduling.

Before/After Examples

Simple One-Shot Timer

RunUO:

Timer.DelayCall(TimeSpan.FromSeconds(10), new TimerCallback(Delete));

ModernUO:

Timer.StartTimer(TimeSpan.FromSeconds(10), Delete);

Repeating Timer with State

RunUO:

private class HealTimer : Timer
{
    private Mobile m_Target;

    public HealTimer(Mobile target) : base(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2))
    {
        m_Target = target;
        Priority = TimerPriority.TwoFiftyMS;
    }

    protected override void OnTick()
    {
        if (m_Target.Alive && m_Target.Hits < m_Target.HitsMax)
            m_Target.Hits += 5;
        else
            Stop();
    }
}

ModernUO:

private TimerExecutionToken _healTimer;

private void StartHeal(Mobile target)
{
    Timer.StartTimer(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2), () => HealTick(target), out _healTimer);
}

private void HealTick(Mobile target)
{
    if (target.Alive && target.Hits < target.HitsMax)
        target.Hits += 5;
    else
        _healTimer.Cancel();
}

Or for zero-allocation, use the state-carrying Timer.DelayCall:

Timer.DelayCall(TimeSpan.FromSeconds(2), HealTick, target);

Timer.DelayCall with State

RunUO:

Timer.DelayCall(TimeSpan.FromSeconds(2), new TimerStateCallback(ProcessTarget), target);

private static void ProcessTarget(object state)
{
    Mobile target = (Mobile)state;
    // ...
}

ModernUO:

Timer.DelayCall(TimeSpan.FromSeconds(2), ProcessTarget, target);

private static void ProcessTarget(Mobile target)
{
    // Type-safe — no casting needed
}

ModernUO supports up to 5 typed state parameters:

Timer.DelayCall(TimeSpan.FromSeconds(2), ProcessTarget, mobile, item);
Timer.DelayCall(TimeSpan.FromSeconds(2), DoWork, arg1, arg2, arg3);

Edge Cases & Gotchas

1. TimerExecutionToken Is NOT Serializable

Never add [SerializableField] to a TimerExecutionToken. It's a struct that tracks a pooled timer — it can't survive serialization. Always restore timers in [AfterDeserialization].

2. Don't Start Timers in Deserialization

In RunUO, timers are commonly started in Deserialize(). In ModernUO, use [AfterDeserialization] — this runs after the world is fully loaded.

3. Cancel() Is Always Safe

_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 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.

6. Avoid Lambda on Hot Paths

Lambdas allocate closures. For hot-path timers, use state-carrying Timer.DelayCall or direct method references:

// Allocates closure:
Timer.StartTimer(TimeSpan.FromSeconds(2), () => ProcessTarget(from, target));

// No allocation:
Timer.DelayCall(TimeSpan.FromSeconds(2), ProcessTarget, from, target);

See Also

  • dev-docs/timers.md — Complete ModernUO timer reference
  • 02-serialization.md — Serialization (timer fields, [AfterDeserialization])
  • 01-foundation-changes.md — Foundation changes