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>
7.8 KiB
7.8 KiB
| name | description |
|---|---|
| modernuo-timers | Trigger when creating delayed actions, recurring timers, or any time-based behavior. When using Timer.StartTimer, Timer.DelayCall, or TimerExecutionToken. |
ModernUO Timers & Scheduling
When This Activates
- Creating delayed actions or recurring timers
- Working with
Timer,TimerExecutionToken,DelayCallTimer - Implementing decay, expiration, or periodic behavior
- Restoring timers after deserialization
Key Rules
- Prefer
Timer.StartTimerwith token for cancellable timers - Prefer
Timer.StartTimerwithout token for fire-and-forget - Never serialize
TimerExecutionToken-- restore in[AfterDeserialization] - Always cancel timers in
OnDelete()/OnAfterDelete() - 8ms minimum precision -- timer wheel uses 8ms tick rate
- Timers are NOT thread-safe -- never Start/Stop timers or call
Timer.DelayCall/Timer.StartTimerfrom any thread other than the game thread. This includesSerialize()which runs on background serialization threads during world saves.
Preferred APIs (In Order)
1. Timer.StartTimer with Token (Cancellable Fire-and-Forget)
private TimerExecutionToken _timerToken;
// One-shot
Timer.StartTimer(TimeSpan.FromSeconds(5), DoSomething, out _timerToken);
// Repeating
Timer.StartTimer(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1), CheckExpiry, out _timerToken);
// Repeating with count limit
Timer.StartTimer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1), 10, Tick, out _timerToken);
// Cancel
_timerToken.Cancel(); // Safe to call multiple times
// Check state
if (_timerToken.Running) { }
2. Timer.StartTimer without Token (Fire-and-Forget, No Cancel)
Timer.StartTimer(Delete); // Immediate
Timer.StartTimer(TimeSpan.FromSeconds(5), Delete); // Delayed
Timer.StartTimer(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1), Tick); // Repeating
3. Timer.DelayCall (Returns Timer Object)
var timer = Timer.DelayCall(TimeSpan.FromSeconds(5), DoSomething);
timer.Stop(); // Cancel later if needed
4. Timer.DelayCall with State (Avoids Closures)
// Passes state without lambda allocation
Timer.DelayCall(TimeSpan.FromSeconds(2), ProcessTarget, from, target);
// Calls: ProcessTarget(Mobile from, Mobile target) after 2s
// Up to 5 parameters supported
Timer.DelayCall(TimeSpan.FromSeconds(1), DoWork, arg1, arg2, arg3);
5. Timer.Pause (Awaitable)
await Timer.Pause(TimeSpan.FromMilliseconds(100));
await Timer.Pause(500); // 500ms overload
TimerExecutionToken Properties
_timerToken.Running // bool: is timer still active?
_timerToken.Index // int: how many times OnTick has fired
_timerToken.RemainingCount // int: ticks remaining (int.MaxValue if infinite)
_timerToken.Next // DateTime: when next tick fires
_timerToken.Cancel() // Stop and return to pool (safe to call multiple times)
Timer Wheel Architecture
3-layer hierarchical wheel with 4096 slots per layer:
- Layer 0: 8ms resolution, ~33 second range
- Layer 1: ~33s resolution, ~22 minute range
- Layer 2: ~22m resolution, ~16 day range
All delays rounded up to nearest 8ms boundary.
Patterns
Cleanup in Deletion
public override void OnDelete()
{
_timerToken.Cancel(); // TimerExecutionToken
base.OnDelete();
}
public override void OnAfterDelete()
{
_timer?.Stop(); // Timer reference
_timer = null;
base.OnAfterDelete();
}
Timer Restoration After Deserialization
[SerializationGenerator(0)]
public partial class DecayingItem : Item
{
private TimerExecutionToken _decayTimer; // NOT serialized
[SerializableField(0)]
[DeltaDateTime]
private DateTime _expireTime;
[Constructible]
public DecayingItem() : base(0x1234)
{
_expireTime = Core.Now + TimeSpan.FromHours(1);
Timer.StartTimer(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), CheckDecay, out _decayTimer);
}
[AfterDeserialization]
private void AfterDeserialization()
{
Timer.StartTimer(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), CheckDecay, out _decayTimer);
}
public override void OnAfterDelete()
{
_decayTimer.Cancel();
base.OnAfterDelete();
}
private void CheckDecay()
{
if (Core.Now >= _expireTime)
Delete();
}
}
[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.
[SerializableField(0, setter: "private")]
[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)]
private Timer _evaluateTimer;
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)
private class DecayTimer : Timer
{
private readonly Corpse _corpse;
public DecayTimer(Corpse c, TimeSpan delay) : base(delay)
{
_corpse = c;
}
protected override void OnTick()
{
if (!_corpse.GetFlag(CorpseFlag.NoBones))
_corpse.TurnToBones();
else
_corpse.Delete();
}
}
// Usage:
_decayTimer = new DecayTimer(this, delay);
_decayTimer.Start();
// Cleanup:
_decayTimer?.Stop();
_decayTimer = null;
Anti-Patterns
- Serializing
TimerExecutionToken: It's a struct with internal Timer reference -- not serializable - Forgetting cleanup: Timers keep running if not cancelled on deletion
- Using
Thread.Sleep: Blocks the game loop. UseTimer.StartTimerorawait Timer.Pauseinstead - Creating timers in constructors called during deserialization: Use
[AfterDeserialization]instead - Starting/stopping timers in
Serialize():Serialize()runs on background threads during world saves -- timer APIs are game-thread-only and will corrupt state or crash
Real Examples
- Token cleanup:
Projects/UOContent/Spells/Third/WallOfStone.cs - Repeating timer:
Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs - Custom timer class:
Projects/UOContent/Items/Misc/Corpses/Corpse.cs - State-carrying delay: Various files using
Timer.DelayCall<T1,T2>(delay, callback, arg1, arg2) - Timer deserialization:
Projects/UOContent/Items/Aquarium/Aquarium.cs - Timer pool config:
Projects/Server/Timer/Timer.Pool.cs
Timer Files
Projects/Server/Timer/Timer.cs- Base classProjects/Server/Timer/Timer.DelayCall.cs- DelayCall + StartTimerProjects/Server/Timer/Timer.TimerWheel.cs- SchedulerProjects/Server/Timer/Timer.Pool.cs- Pool managementProjects/Server/Timer/Timer.DelayStateCall.cs- Generic state timersProjects/Server/Timer/TimerExecutionToken.cs- Token struct
See Also
dev-docs/timers.md- Complete timer documentationdev-docs/event-scheduler.md- Wall-clock/calendar scheduling (EventScheduler) — use for daily resets, weekly events, holiday seasons instead of Timerdev-docs/claude-skills/modernuo-event-scheduler.md- EventScheduler skill for calendar-based eventsdev-docs/claude-skills/modernuo-serialization.md- Timer fields not serializeddev-docs/claude-skills/modernuo-content-patterns.md- Deletion patternsdev-docs/claude-skills/modernuo-threading.md- Single-threaded model