chore: Adds AI instructions and SKILLs for ModernUO codebase (#2347)

Summary

  - Adds CLAUDE.md at repo root with 14 terse code audit rules (always loaded, low token cost)
  - Adds pointer files for other AI tools: AGENTS.md (Codex), GEMINI.md, .github/COPILOT-INSTRUCTIONS.md (Copilot), .cursorrules (Cursor) — all redirect to CLAUDE.md as single source of truth
  - Gitignores /.claude so personal AI config isn't distributed
  - Moves Claude skills to dev-docs/claude-skills/ (opt-in, not auto-loaded)
  - Adds 14 dev-docs covering codebase conventions

  Code Audit Rules (in CLAUDE.md)

  1. LINQ tiered rules (Tier 1 free, Tier 2 warm, Tier 3 forbidden)
  2. No Console.WriteLine — use LogFactory.GetLogger()
  3. No concurrency primitives in game code
  4. No World.Mobiles/World.Items iteration
  5. Clean up refs in OnDelete()/OnAfterDelete()
  6. Cancel timers in OnDelete()/OnAfterDelete()
  7. STArrayPool<T>.Shared not ArrayPool<T>.Shared
  8. PooledRefList<T> not new List<T>() on hot paths
  9. Serialization: partial class, [Constructible], no serialized TimerExecutionToken
  10. No Task.Run/new Thread() in game code
  11. Never assume era — ask which expansion
  12. _camelCase fields, PascalCase properties/methods
  13. No empty gumps — use DisplayTo() pattern
  14. PropertyList string literals must be {} holes, cliloc-as-argument uses :#
This commit is contained in:
Kamron Batman 2026-03-01 11:42:19 -08:00 committed by GitHub
parent e77a566f32
commit 1391c563fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 8549 additions and 0 deletions

1
.cursorrules Normal file
View file

@ -0,0 +1 @@
Read and follow all instructions in CLAUDE.md in this repository's root.

1
.github/COPILOT-INSTRUCTIONS.md vendored Normal file
View file

@ -0,0 +1 @@
Read and follow all instructions in CLAUDE.md in this repository's root.

1
.gitignore vendored
View file

@ -39,6 +39,7 @@
/.idea
/.vs
/.vscode
/.claude
.DS_Store

1
AGENTS.md Normal file
View file

@ -0,0 +1 @@
Read and follow all instructions in CLAUDE.md in this repository's root.

77
CLAUDE.md Normal file
View file

@ -0,0 +1,77 @@
# ModernUO
.NET 10 Ultima Online server emulator. Single-threaded game loop. All game logic runs on one thread.
- **Server engine**: `Projects/Server/` — do NOT modify without explicit request
- **Game content**: `Projects/UOContent/` — primary editing target
- **Build**: `dotnet build` from repo root
## Code Audit Rules
Apply these when writing or reviewing `.cs` files under `Projects/`.
1. **LINQ** — Tier 1 (zero-cost patterns) free on hot paths; Tier 2 (low overhead) OK on warm paths; Tier 3 (allocating) forbidden on hot paths → `dev-docs/code-standards.md`
2. **No `Console.WriteLine`** — use `LogFactory.GetLogger(typeof(MyClass))``logger.Information(...)` (requires `using Server.Logging;`)
3. **No concurrency primitives** — no `lock`, `volatile`, `ConcurrentDictionary`, `Mutex`, etc. Server is single-threaded.
4. **No `World.Mobiles`/`World.Items` iteration** — use spatial queries: `map.GetMobilesInRange<T>()`, `map.GetItemsInRange<T>()`
5. **Clean up refs in `OnDelete()`/`OnAfterDelete()`** — null out `Item`/`Mobile` references
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]`
10. **No `Task.Run`/`new Thread()`** in game code — game logic is single-threaded event loop
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
13. **No empty gumps** — every gump must produce visual elements. An empty gump leaks on client+server (no way to close it). Use static `DisplayTo()` to validate before constructing → `dev-docs/gump-system.md`
14. **PropertyList string literals must be holes**`$"{"Map"}\t{value}"` not `$"Map\t{value}"`. The handler treats bare text as delimiters, `{}` holes as arguments. Only `\t` should be a bare literal → `dev-docs/property-lists.md`
## Dev-Docs Reference
| Topic | File |
|---|---|
| Code standards & LINQ tiers | `dev-docs/code-standards.md` |
| Serialization system | `dev-docs/serialization.md` |
| Content patterns (Items, Mobiles, Creatures) | `dev-docs/content-patterns.md` |
| Era & expansion handling | `dev-docs/era-expansion.md` |
| Timer system | `dev-docs/timers.md` |
| Event scheduler (wall-clock/calendar) | `dev-docs/event-scheduler.md` |
| Object property lists (tooltips) | `dev-docs/property-lists.md` |
| Gump (UI dialog) system | `dev-docs/gump-system.md` |
| Commands & targeting | `dev-docs/commands-targeting.md` |
| Event system | `dev-docs/events.md` |
| Threading model | `dev-docs/threading-model.md` |
| Configuration system | `dev-docs/configuration.md` |
| Networking & packets | `dev-docs/networking-packets.md` |
| Region system | `dev-docs/regions.md` |
## Claude Skills (Opt-In)
Detailed Claude Code skills live in `dev-docs/claude-skills/`. They are **not auto-loaded** — they must be copied to `.claude/skills/` to activate.
**When to offer**: If the user is building complex content (new items, creatures, spells, gumps, quests, packets, serialization work, etc.), ask:
> I have detailed Claude Code skills for this kind of work. Want me to enable them?
> I'll copy the relevant files from `dev-docs/claude-skills/` to `.claude/skills/`.
Then copy only the relevant skill files based on the task:
| Task | Skills to enable |
|---|---|
| New Item or Mobile | `modernuo-content-patterns`, `modernuo-serialization`, `modernuo-property-lists` |
| Creature / spawn | `modernuo-content-patterns`, `modernuo-serialization`, `modernuo-timers` |
| Spell or ability | `modernuo-content-patterns`, `modernuo-serialization`, `modernuo-timers`, `modernuo-era-expansion` |
| Gump / UI dialog | `modernuo-gump-system`, `modernuo-commands-targeting` |
| Quest or event system | `modernuo-events`, `modernuo-content-patterns`, `modernuo-configuration` |
| Scheduled / seasonal / holiday events | `modernuo-event-scheduler`, `modernuo-timers` |
| Custom regions / dynamic areas | `modernuo-regions`, `modernuo-content-patterns` |
| Packet / networking | `modernuo-networking`, `modernuo-threading` |
| Commands | `modernuo-commands-targeting` |
| Timer work | `modernuo-timers`, `modernuo-serialization` |
| Config system | `modernuo-configuration` |
| Era-conditional code | `modernuo-era-expansion` |
| Code review / audit | `modernuo-code-audit` |
| Any `.cs` file edit | `modernuo-code-audit` (always offer for code changes) |
To enable a skill: `cp dev-docs/claude-skills/<name>.md .claude/skills/`
The `modernuo-code-audit` skill auto-triggers on `.cs` file edits and flags convention violations (warnings only, asks before fixing).

1
GEMINI.md Normal file
View file

@ -0,0 +1 @@
Read and follow all instructions in CLAUDE.md in this repository's root.

View file

@ -0,0 +1,151 @@
---
name: modernuo-code-audit
description: >
Auto-trigger whenever writing or modifying .cs files under Projects/. Audits code for ModernUO convention violations. Warnings only - flag issues and ask before fixing.
---
# ModernUO Code Audit
## When This Activates
- Any time you write, edit, or modify a `.cs` file under `Projects/`
- After generating code snippets for the user
- During code review
## Audit Rules (Warnings Only)
Flag these issues but do NOT auto-fix. Ask the user before making changes.
### 1. LINQ: Know What's Optimized (.NET 10)
Not all LINQ is banned. .NET 10 JIT/PGO eliminates overhead for specific patterns. Anything not listed below is still forbidden on hot paths.
**Tier 1 — Zero-cost (use freely on hot paths):**
- `foreach` over `IEnumerable<T>` backed by `T[]`, `List<T>`, `Stack<T>`, `Queue<T>` — PGO devirtualizes the enumerator, zero heap allocation
- `.Contains()` after a preceding LINQ operator (`.Distinct()`, `.OrderBy()`, `.Reverse()`, `.Union()`, `.Intersect()`, `.Except()`, `.Concat()`, `.SelectMany()`, `.Where().Select()`, `.Skip()`, `.Take()`, `.OfType()`, `.Cast()`, `.Shuffle()`) — LINQ has ~30 specialized overrides that skip the intermediate work (no sort, no HashSet, no buffering)
- `.Count()` on sized collections (`ICollection<T>`, or after `Range`/`Repeat`/`Skip`/`Take`/`Append`) — O(1) property access, no enumeration
- `.OrderBy().First()` / `.OrderByDescending().First()` / `.OrderBy().Last()` — O(N) min/max scan, no sort performed
- `.Shuffle().Take(n)` — reservoir sampling, single pass, O(n) memory
- `Enumerable.Range()` / `Enumerable.Sequence()` followed by `.Count()`, `.Contains()`, `.ToArray()`, `.ToList()`, `.ElementAt()`, `.Last()` — arithmetic, not enumeration
**Tier 2 — Low overhead (acceptable on warm paths, benchmark if critical):**
- `.Skip(n).Take(m).ToArray()` on `T[]`/`List<T>` — vectorized `Span<T>.CopyTo` (still allocates output)
- `.LeftJoin()` / `.RightJoin()` — ~2x faster than manual `GroupJoin`+`SelectMany`+`DefaultIfEmpty`
- `.Where(predicate)` on `T[]`/`List<T>``WhereIterator` still heap-allocates, but enumeration is PGO-optimized. Manual `foreach`+`if` is still faster for true hot paths.
**Tier 3 — Still forbidden on hot paths (write manual code):**
- `.Select(f).Where(p)` (this order — each intermediate iterator allocates)
- `.GroupBy()`, `.ToDictionary()`, `.ToHashSet()`, `.ToLookup()` (always allocate internal structures)
- `.Aggregate()` (delegate overhead per element)
- `.Sum()` / `.Min()` / `.Max()` on `float`/`double` (no SIMD in LINQ on ARM)
- `.SelectMany()` when iterating results (not `.Contains()`) — multiple enumerator allocations
- `.Zip()` when iterating — enumerator allocations
- Any LINQ over `IAsyncEnumerable<T>` — no PGO/escape analysis
- Long chains like `.Where().Select().OrderBy().Take()` — each step allocates an iterator
**Prerequisites**: .NET 10, tiered compilation + Dynamic PGO enabled (default). Tier 1 optimizations require ~30+ calls for JIT warmup.
**Quick decision**: If the exact pattern is in Tier 1 → use it. If it's in Tier 2 → acceptable unless profiling shows it's a bottleneck. If it's anything else → manual `for`/`foreach` + `PooledRefList<T>`.
### 2. No Console.WriteLine
**Bad**: `Console.WriteLine(...)`, `Console.Write(...)`
**Good**: `private static readonly ILogger logger = LogFactory.GetLogger(typeof(MyClass));` then `logger.Information(...)`, `logger.Warning(...)`, `logger.Error(...)`
**Requires**: `using Server.Logging;`
### 3. No Concurrency Primitives in Game Code
**Bad**: `ConcurrentDictionary`, `ConcurrentQueue`, `ConcurrentBag`, `volatile`, `lock(...)`, `Mutex`, `Semaphore`, `Monitor`, `Interlocked`, `ReaderWriterLock`
**Why**: Server is single-threaded. These add overhead for no benefit.
**Instead**: Use regular `Dictionary<K,V>`, `List<T>`, plain fields.
### 4. Never Iterate World.Mobiles or World.Items Directly
**Bad**: `foreach (var m in World.Mobiles.Values)`, `World.Items.Values.Where(...)`
**Good**: `map.GetMobilesInBounds<T>(bounds)`, `map.GetMobilesInRange<T>(point, range)`, `map.GetItemsInRange<T>(point, range)`
**Why**: Full world iteration is O(n) over all entities. Spatial queries use sector indexing.
### 5. Clean Up References in OnDelete/OnAfterDelete
**Check**: Classes with `Item` or `Mobile` references should clean them in `OnDelete()` or `OnAfterDelete()`.
**Pattern**:
```csharp
public override void OnAfterDelete()
{
_someReference = null;
base.OnAfterDelete();
}
```
### 6. Cancel Timers in OnDelete/OnAfterDelete
**Check**: Any class with `TimerExecutionToken` or `Timer` fields must cancel them on deletion.
**Pattern**:
```csharp
public override void OnAfterDelete()
{
_timerToken.Cancel(); // For TimerExecutionToken
_timer?.Stop(); // For Timer references
_timer = null;
base.OnAfterDelete();
}
```
### 7. Use STArrayPool, Not ArrayPool
**Bad**: `ArrayPool<T>.Shared.Rent(...)` in game logic
**Good**: `STArrayPool<T>.Shared.Rent(...)` in game logic
**Why**: STArrayPool is single-threaded optimized (no locks). Use ArrayPool only in explicitly multi-threaded code.
**Also**: Always return rented arrays in a `finally` block.
### 8. No new List in Hot Paths
**Bad**: `var list = new List<Mobile>();` in frequently-called methods
**Good**: `using var list = PooledRefList<Mobile>.Create();`
**Why**: PooledRefList uses pooled arrays, zero GC pressure. It's a ref struct (stack-allocated).
### 9. Serialization Class Requirements
**Check**: Classes with `[SerializationGenerator]` MUST be `partial`.
**Check**: `[Constructible]` on parameterless constructors for items/mobiles.
**Check**: `TimerExecutionToken` fields must NOT have `[SerializableField]`.
**Check**: Use `using ModernUO.Serialization;` when using serialization attributes.
### 10. No Task.Run or new Thread
**Bad**: `Task.Run(...)`, `new Thread(...)`, `ThreadPool.QueueUserWorkItem(...)` in game code
**Why**: Game logic runs on the single-threaded event loop. Background threads cause race conditions.
**Exception**: Server infrastructure code (Projects/Server/Main.cs, World saves) may use threading.
### 11. Never Assume Era
**Check**: If code uses era-conditional logic (`Core.AOS`, `Core.SE`, etc.) and the user hasn't specified a target era, ASK which expansion to target.
**Why**: Different eras have dramatically different mechanics.
### 12. Naming Conventions
**Check**: `_camelCase` for private fields, `PascalCase` for properties/methods/classes.
**Note**: Legacy code may use `m_` prefix -- don't flag existing `m_` fields but use `_` for new code.
### 13. No Empty Gumps
**Check**: Any gump (legacy `Gump` constructor, or `BuildLayout`) must not have a code path that produces zero visual elements (no `AddBackground`, no `AddPage` with content, etc.).
**Why**: The client has no way to close an empty gump — no close button, no right-click dismiss. This leaks a gump slot on both client and server until relog.
**Common cause**: Early `return` in a constructor or `BuildLayout` when prerequisites aren't met.
**Fix**: Use a static `DisplayTo(Mobile from)` method that validates prerequisites **before** constructing the gump. Make the constructor `private`. See `Projects/UOContent/Gumps/Go/GoGump.cs` for the canonical pattern.
### 14. PropertyList String Literals Must Be Holes
**Check**: In any `IPropertyList.Add()` interpolated string, string constants must be wrapped as holes `{"text"}`, not bare literals.
**Bad**: `list.Add(1060658, $"Chances\t{_charges}");` — "Chances" becomes a delimiter, not an argument.
**Good**: `list.Add(1060658, $"{"Chances"}\t{_charges}");` — "Chances" is an argument.
**Why**: The handler treats bare text as delimiters and `{}` contents as arguments. The property list system is used beyond the game client (e.g., web rendering) which must distinguish arguments from delimiters. Only `\t` should be a bare literal.
**Also**: If you don't know the text for a cliloc number, see `Projects/Server/Localization/Localization.cs` `LoadClilocs()` to learn the binary format, and ask the user where their `cliloc.enu` file is.
## Severity Levels
- **ERROR**: Rules 3, 9, 10, 13 (will cause bugs, build failures, or client-side leaks)
- **WARNING**: Rules 1 (Tier 3 LINQ), 2, 4, 5, 6, 7, 8, 12, 14 (performance/convention issues)
- **INFO**: Rule 1 (Tier 2 LINQ on warm paths — note it but don't flag as violation)
- **ASK**: Rule 11 (need user input)
## How to Report
When you find violations, report them as:
```
[AUDIT] {SEVERITY}: {Description}
File: {path}:{line}
Suggestion: {fix}
```
Do NOT silently fix issues. Always flag and ask.
## See Also
- `dev-docs/code-standards.md` - Full coding standards documentation
- `dev-docs/claude-skills/modernuo-serialization.md` - Serialization rules
- `dev-docs/claude-skills/modernuo-timers.md` - Timer cleanup rules
- `dev-docs/claude-skills/modernuo-threading.md` - Threading model details
- `dev-docs/claude-skills/modernuo-property-lists.md` - PropertyList interpolation rules

View file

@ -0,0 +1,223 @@
---
name: modernuo-commands-targeting
description: >
Trigger when creating in-game commands, targeting mechanics, or working with CommandSystem/Target. When implementing [commands or player interactions.
---
# ModernUO Commands & Targeting
## When This Activates
- Creating new in-game `[` commands
- Implementing targeting mechanics
- Working with `CommandSystem.Register()` or `Target` class
- Adding GM/admin tools
## Key Rules
1. **Register commands in `Configure()`** static method (called at startup)
2. **Use `[Usage]` and `[Description]`** attributes on handler methods
3. **Prefix is `[`** by default (e.g., `[mycommand`)
4. **Target inherits from `Target`** class, override `OnTarget()`
5. **Always validate inputs** in command handlers
## Command Registration
```csharp
using Server.Commands;
namespace Server.Custom;
public static class MyCommands
{
public static void Configure()
{
CommandSystem.Register("MyCommand", AccessLevel.GameMaster, MyCommand_OnCommand);
CommandSystem.Register("MyOtherCmd", AccessLevel.Player, MyOtherCmd_OnCommand);
}
[Usage("MyCommand <name> [count]")]
[Description("Does something with a name and optional count")]
public static void MyCommand_OnCommand(CommandEventArgs e)
{
var from = e.Mobile;
if (e.Length < 1)
{
from.SendMessage("Usage: [MyCommand <name> [count]");
return;
}
var name = e.GetString(0);
var count = e.Length > 1 ? e.GetInt32(1) : 1;
from.SendMessage($"Processing {name} x{count}");
}
}
```
## CommandEventArgs
```csharp
e.Mobile // Mobile who issued the command
e.Command // Command name string
e.ArgString // Raw argument string
e.Arguments // string[] split arguments
e.Length // Number of arguments
// Typed argument accessors:
e.GetString(0) // Get string at index (empty string if missing)
e.GetInt32(1) // Get int at index (0 if missing/invalid)
e.GetUInt32(0) // Get uint at index
e.GetBoolean(0) // Get bool at index
e.GetDouble(0) // Get double at index
e.GetTimeSpan(0) // Get TimeSpan at index
```
## Access Levels
```csharp
AccessLevel.Player // Regular players
AccessLevel.Counselor // Support staff
AccessLevel.GameMaster // GMs
AccessLevel.Seer // Event coordinators
AccessLevel.Administrator // Server admins
AccessLevel.Developer // Developers
AccessLevel.Owner // Server owner
```
## Target System
### Basic Target
```csharp
using Server.Targeting;
private class MyTarget : Target
{
public MyTarget() : base(
12, // Range (-1 for unlimited)
false, // AllowGround
TargetFlags.None // None, Harmful, or Beneficial
)
{
// Optional settings:
// CheckLOS = false; // Skip line-of-sight check
// DisallowMultis = true; // Don't allow targeting multi objects
}
protected override void OnTarget(Mobile from, object targeted)
{
if (targeted is Mobile m)
{
from.SendMessage($"You targeted {m.Name}");
}
else if (targeted is Item item)
{
from.SendMessage($"You targeted item {item.Name}");
}
else if (targeted is LandTarget land)
{
from.SendMessage($"You targeted land at {land.Location}");
}
else if (targeted is StaticTarget st)
{
from.SendMessage($"You targeted static {st.ItemID}");
}
}
protected override void OnTargetCancel(Mobile from, TargetCancelType cancelType)
{
from.SendMessage("Targeting cancelled.");
}
protected override void OnTargetFinish(Mobile from)
{
// Always called after targeting completes (success or cancel)
}
}
// Usage:
from.Target = new MyTarget();
```
### TargetFlags
```csharp
TargetFlags.None // Neutral targeting
TargetFlags.Harmful // Criminal check, combat targeting
TargetFlags.Beneficial // Healing, buffing
```
### Target Object Types
- `Mobile` -- a player or creature
- `Item` -- an item in the world or container
- `LandTarget` -- ground tile (`Location`, `TileID`, `Name`)
- `StaticTarget` -- static map object (`Location`, `ItemID`, `Name`, `Hue`)
### Command + Target Pattern
```csharp
public static void Configure()
{
CommandSystem.Register("Tame", AccessLevel.GameMaster, Tame_OnCommand);
}
[Usage("Tame")]
[Description("Force-tames a creature")]
public static void Tame_OnCommand(CommandEventArgs e)
{
e.Mobile.SendMessage("Select a creature to tame.");
e.Mobile.Target = new TameTarget();
}
private class TameTarget : Target
{
public TameTarget() : base(-1, false, TargetFlags.None) { }
protected override void OnTarget(Mobile from, object targeted)
{
if (targeted is BaseCreature { Tamable: true } creature)
{
creature.SetControlMaster(from);
from.SendMessage($"You have tamed {creature.Name}.");
}
else
{
from.SendMessage("That cannot be tamed.");
}
}
}
```
### Target Validation Overrides
```csharp
// Override these for custom validation:
protected override bool CanTarget(Mobile from, Mobile mobile, ref Point3D loc, ref Map map)
protected override bool CanTarget(Mobile from, Item item, ref Point3D loc, ref Map map)
protected override bool CanTarget(Mobile from, LandTarget land, ref Point3D loc, ref Map map)
protected override bool CanTarget(Mobile from, StaticTarget st, ref Point3D loc, ref Map map)
// Error handlers:
protected override void OnTargetOutOfRange(Mobile from, object targeted)
protected override void OnTargetOutOfLOS(Mobile from, object targeted)
protected override void OnTargetNotAccessible(Mobile from, object targeted)
protected override void OnTargetDeleted(Mobile from, object targeted)
protected override void OnTargetUntargetable(Mobile from, object targeted)
```
## Anti-Patterns
- **Registering commands outside `Configure()`**: Won't be called during startup
- **Missing access level validation**: Always set appropriate `AccessLevel`
- **Not checking `e.Length`**: Accessing missing arguments returns defaults silently
- **Hardcoded range in targeting**: Use -1 for unlimited, appropriate range for game mechanics
## Real Examples
- Command registration: `Projects/UOContent/Commands/StaffAccess.cs`
- Target from item: `Projects/UOContent/Items/Misc/InteriorDecorator.cs`
- Spell targeting: `Projects/UOContent/Spells/First/MagicArrow.cs`
- Command system: `Projects/Server/Commands.cs`
- Target base: `Projects/Server/Targeting/Target.cs`
- Attributes: `Projects/Server/Attributes.cs` (Usage, Description, Aliases)
## See Also
- `dev-docs/commands-targeting.md` - Complete documentation
- `dev-docs/claude-skills/modernuo-gump-system.md` - Commands that open gumps
- `dev-docs/claude-skills/modernuo-content-patterns.md` - Content creation

View file

@ -0,0 +1,177 @@
---
name: modernuo-configuration
description: >
Trigger when adding server settings, reading config values, or working with modernuo.json or JsonConfig.
---
# ModernUO Configuration System
## When This Activates
- Adding new server settings
- Reading config values with `ServerConfiguration`
- Using `JsonConfig.Serialize/Deserialize<T>`
- Working with `modernuo.json` or custom config files
## Key Rules
1. **Read settings in `Configure()`** static method
2. **Use `GetOrUpdateSetting()`** when you want the setting created with a default if missing
3. **Use `GetSetting()`** for read-only access (won't write default to file)
4. **Custom config files use `JsonConfig.Serialize/Deserialize<T>()`**
5. **Config path base**: `Distribution/Configuration/`
## ServerConfiguration
### GetOrUpdateSetting (Creates Default If Missing)
```csharp
// In Configure() method:
var maxAccounts = ServerConfiguration.GetOrUpdateSetting("accountHandler.maxAccountsPerIP", 1);
var saveDelay = ServerConfiguration.GetOrUpdateSetting("autosave.saveDelay", TimeSpan.FromMinutes(5));
var enabled = ServerConfiguration.GetOrUpdateSetting("mySystem.enabled", true);
```
If the key doesn't exist in `modernuo.json`, it writes the default value and returns it.
### GetSetting (Read-Only)
```csharp
var statMax = ServerConfiguration.GetSetting("stats.statMax", 100);
var usePub45 = ServerConfiguration.GetSetting("stats.usePub45StatGain", false);
```
Returns the default if key is missing but does NOT write to the config file.
### Supported Types
```csharp
ServerConfiguration.GetSetting(string key, int defaultValue)
ServerConfiguration.GetSetting(string key, bool defaultValue)
ServerConfiguration.GetSetting(string key, double defaultValue)
ServerConfiguration.GetSetting(string key, TimeSpan defaultValue)
ServerConfiguration.GetSetting<T>(string key, T defaultValue) where T : struct, Enum
```
### SetSetting
```csharp
ServerConfiguration.SetSetting("mySystem.customValue", "42");
// Immediately persisted to modernuo.json
```
## Configuration Pattern
```csharp
namespace Server.Custom;
public static class MySystem
{
private static bool _enabled;
private static int _maxItems;
private static TimeSpan _cooldown;
public static void Configure()
{
_enabled = ServerConfiguration.GetOrUpdateSetting("mySystem.enabled", true);
_maxItems = ServerConfiguration.GetOrUpdateSetting("mySystem.maxItems", 100);
_cooldown = ServerConfiguration.GetOrUpdateSetting("mySystem.cooldown", TimeSpan.FromMinutes(5));
}
// System logic uses _enabled, _maxItems, _cooldown...
}
```
## Custom Config Files with JsonConfig
For complex configuration that doesn't fit in `modernuo.json`:
```csharp
using Server.Json;
public static class MyComplexSystem
{
private static MyConfig _config;
private static readonly string ConfigPath =
Path.Combine(Core.BaseDirectory, "Configuration/MySystem/config.json");
public static void Configure()
{
_config = JsonConfig.Deserialize<MyConfig>(ConfigPath)
?? new MyConfig();
}
public static void SaveConfig()
{
JsonConfig.Serialize(ConfigPath, _config);
}
}
public class MyConfig
{
public bool Enabled { get; set; } = true;
public int MaxItems { get; set; } = 100;
public List<string> BlockedNames { get; set; } = new();
public Dictionary<string, int> Scores { get; set; } = new();
}
```
### JsonConfig Options
- Pretty-printed (WriteIndented = true)
- Comments allowed (ReadCommentHandling = Skip)
- Trailing commas allowed
- Null values omitted (WhenWritingNull)
- Enums serialized as strings (JsonStringEnumConverter)
- Built-in converters for: `ClientVersion`, `Guid`, `Map`, `Point3D`, `Rectangle3D`, `TimeSpan`, `IPEndPoint`, `Type`, `WorldLocation`, `TextDefinition`
## modernuo.json Structure
Location: `Distribution/Configuration/modernuo.json`
```json
{
"assemblyDirectories": ["./Assemblies"],
"dataDirectories": ["C:\\Ultima Online Classic"],
"listeners": ["0.0.0.0:2593"],
"settings": {
"accountHandler.enableAutoAccountCreation": "True",
"accountHandler.maxAccountsPerIP": "1",
"autosave.enabled": "True",
"autosave.saveDelay": "00:05:00",
"world.savePath": "Saves",
"stats.statMax": "100",
"timer.initialPoolCapacity": "1024",
"mySystem.enabled": "True"
}
}
```
All settings are stored as strings in the `settings` dictionary.
## Naming Convention for Keys
Use dot-separated hierarchical keys:
```
systemName.settingName
systemName.subSystem.settingName
```
Examples:
- `accountHandler.maxAccountsPerIP`
- `stats.statMax`
- `movement.delay.walkFoot`
- `autosave.saveDelay`
## Anti-Patterns
- **Reading config in constructors**: Use `Configure()` static method instead
- **Hardcoding values**: Use `ServerConfiguration.GetOrUpdateSetting()` for tunable values
- **Complex objects in modernuo.json**: Use `JsonConfig` with separate files instead
- **Not providing defaults**: Always pass a sensible default value
## Real Examples
- ServerConfiguration: `Projects/Server/Configuration/ServerConfiguration.cs`
- JsonConfig: `Projects/Server/Json/JsonConfig.cs`
- Config usage: `Projects/UOContent/Skills/SkillCheck.cs` (Configure method)
- Timer pool config: `Projects/Server/Timer/Timer.Pool.cs`
- Main config: `Distribution/Configuration/modernuo.json`
## See Also
- `dev-docs/configuration.md` - Complete configuration documentation
- `dev-docs/claude-skills/modernuo-era-expansion.md` - Expansion configuration
- `dev-docs/claude-skills/modernuo-events.md` - Configure() pattern

View file

@ -0,0 +1,370 @@
---
name: modernuo-content-patterns
description: >
Trigger when creating new items, mobiles, creatures, spells, skills, loot tables, or any game content under Projects/UOContent/. This is the hub skill that connects to all other ModernUO skills.
---
# ModernUO Content Patterns (Hub Skill)
## When This Activates
- Creating new items, weapons, armor, clothing, containers
- Creating new creatures, NPCs, vendors
- Creating new spells
- Implementing skill handlers
- Adding loot tables
- Adding context menus
- Any new game content under `Projects/UOContent/`
## Key Rules
1. **Always ask target era** if the user hasn't specified (see `modernuo-era-expansion.md`)
2. **All serializable classes must be `partial`** with `[SerializationGenerator]`
3. **All Item/Mobile constructors need `[Constructible]`**
4. **Clean up timers and references in `OnDelete()`/`OnAfterDelete()`**
5. **No LINQ** in game logic -- use loops and `PooledRefList<T>`
6. **File placement** matters -- follow the directory conventions below
## New Item Template
```csharp
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0, false)]
public partial class MyItem : Item
{
[Constructible]
public MyItem() : base(0x1234) // itemID from art
{
Weight = 1.0;
// Stackable = true; // if stackable
// Amount = 1; // if stackable
}
public override string DefaultName => "a my item";
// OR: public override int LabelNumber => 1234567; // cliloc number
public override void OnDoubleClick(Mobile from)
{
if (!IsChildOf(from.Backpack))
{
from.SendLocalizedMessage(1042001); // Must be in backpack
return;
}
// Item use logic
}
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
// list.Add(1060741, $"{_charges}"); // charges: ~1_val~
}
}
```
## New Creature Template
```csharp
using ModernUO.Serialization;
using Server.Items;
namespace Server.Mobiles;
[SerializationGenerator(0, false)]
public partial class MyCreature : BaseCreature
{
[Constructible]
public MyCreature() : base(AIType.AI_Melee, FightMode.Closest)
{
Body = 0; // Body graphic ID
BaseSoundID = 0; // Base sound ID
SetStr(100, 150); // Strength min/max
SetDex(80, 100); // Dexterity min/max
SetInt(30, 50); // Intelligence min/max
SetHits(80, 120);
SetMana(0);
SetDamage(8, 14);
SetDamageType(ResistanceType.Physical, 100);
SetResistance(ResistanceType.Physical, 30, 40);
SetResistance(ResistanceType.Fire, 10, 20);
SetResistance(ResistanceType.Cold, 10, 20);
SetResistance(ResistanceType.Poison, 15, 25);
SetResistance(ResistanceType.Energy, 10, 20);
SetSkill(SkillName.MagicResist, 30.0, 50.0);
SetSkill(SkillName.Tactics, 50.0, 70.0);
SetSkill(SkillName.Wrestling, 50.0, 70.0);
Fame = 1000;
Karma = -1000; // Negative = evil, positive = good, 0 = neutral
VirtualArmor = 30;
}
public override string CorpseName => "a creature corpse";
public override string DefaultName => "a creature";
// Optional overrides:
// public override int Meat => 1;
// public override int Hides => 8;
// public override HideType HideType => HideType.Regular;
// public override FoodType FavoriteFood => FoodType.Meat;
// public override PackInstinct PackInstinct => PackInstinct.Canine;
// public override bool CanRummageCorpses => true;
// public override Poison PoisonImmune => Poison.Lesser;
// public override Poison HitPoison => Poison.Regular;
public override void GenerateLoot()
{
AddLoot(LootPack.Average);
AddLoot(LootPack.Gems, 1);
// PackItem(new SpecificItem());
// PackGold(50, 100);
}
}
```
### Tameable Creature Additions
```csharp
// In constructor:
Tamable = true;
ControlSlots = 1; // 1-5, how many pet slots it uses
MinTameSkill = 35.1; // Required Animal Taming skill
```
### AI Types
| AIType | Behavior |
|---|---|
| `AI_Melee` | Charges into melee combat |
| `AI_Mage` | Casts spells, keeps distance |
| `AI_Archer` | Uses ranged attacks |
| `AI_Animal` | Passive, flees or fights back |
| `AI_Predator` | Hunts other creatures |
| `AI_Healer` | Heals allies |
| `AI_Vendor` | NPC vendor behavior |
| `AI_Berserk` | Aggressive, attacks everything |
| `AI_Thief` | Steals from players |
### Fight Modes
| FightMode | Target Selection |
|---|---|
| `None` | Never attacks |
| `Aggressor` | Only attacks those who attack first |
| `Strongest` | Targets highest stats |
| `Weakest` | Targets lowest stats |
| `Closest` | Targets nearest entity |
| `Evil` | Attacks aggressors or negative-karma entities |
## New Spell Template
```csharp
using System;
using Server.Targeting;
namespace Server.Spells.First;
public class MySpell : MagerySpell, ITargetingSpell<Mobile>
{
private static readonly SpellInfo _info = new(
"Spell Name", // Display name
"In Vas Ort", // Power words (mantra)
212, // Cast animation action
9041, // Cast sound
Reagent.Bloodmoss, // Required reagents
Reagent.MandrakeRoot
);
public MySpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info)
{
}
public override SpellCircle Circle => SpellCircle.First;
public void Target(Mobile m)
{
if (CheckHSequence(m)) // Harmful spell check
{
SpellHelper.Turn(Caster, m);
double damage = GetNewAosDamage(10, 1, 4, m);
SpellHelper.Damage(this, m, damage, 0, 100, 0, 0, 0);
// Damage types: phys, fire, cold, poison, energy (must sum to 100)
}
}
public override void OnCast()
{
Caster.Target = new SpellTarget<Mobile>(this, TargetFlags.Harmful);
}
}
```
### Spell Circles (Magery)
| Circle | Mana Cost | Min Skill |
|---|---|---|
| First | 4 | -50.0 (Pre-ML) / -46.0 (ML+) |
| Second | 6 | -30.0 / -32.0 |
| Third | 9 | 0.0 / -18.0 |
| Fourth | 11 | 10.0 / -4.0 |
| Fifth | 14 | 20.0 / 10.0 |
| Sixth | 20 | 30.0 / 24.0 |
| Seventh | 40 | 40.0 / 38.0 |
| Eighth | 50 | 50.0 / 52.0 |
## Skill Implementation
```csharp
using Server.Targeting;
namespace Server.Skills;
public static class MySkillHandler
{
public static void Initialize()
{
// Register handler delegate
SkillInfo.Table[(int)SkillName.Alchemy].Callback = OnUse;
}
public static TimeSpan OnUse(Mobile from)
{
from.SendMessage("You begin working...");
from.Target = new InternalTarget();
return TimeSpan.FromSeconds(1.0); // Delay before next use
}
private class InternalTarget : Target
{
public InternalTarget() : base(2, false, TargetFlags.None)
{
}
protected override void OnTarget(Mobile from, object targeted)
{
if (targeted is Item item)
{
// from.CheckSkill(SkillName.Alchemy, minSkill, maxSkill)
if (from.CheckSkill(SkillName.Alchemy, 0.0, 100.0))
{
from.SendMessage("Success!");
}
else
{
from.SendMessage("You fail.");
}
}
}
}
}
```
## Loot Packs
Use predefined packs -- they auto-select era-appropriate loot:
```csharp
public override void GenerateLoot()
{
AddLoot(LootPack.Poor); // ~50 gold equivalent
AddLoot(LootPack.Meager); // ~100 gold equivalent
AddLoot(LootPack.Average); // ~250 gold equivalent
AddLoot(LootPack.Rich); // ~500 gold equivalent
AddLoot(LootPack.FilthyRich); // ~1000 gold equivalent
AddLoot(LootPack.UltraRich); // ~2000 gold equivalent
AddLoot(LootPack.SuperBoss); // Boss-level loot
AddLoot(LootPack.Gems, 2); // 2 random gems
AddLoot(LootPack.Potions); // Random potion
// Specific items
PackItem(new Arrow(Utility.RandomMinMax(20, 40)));
PackGold(100, 200);
}
```
## Context Menus
```csharp
public override void GetContextMenuEntries(Mobile from, ref PooledRefList<ContextMenuEntry> list)
{
base.GetContextMenuEntries(from, ref list);
if (from.Alive && from.InRange(this, 2))
{
list.Add(new MyContextMenuEntry(this));
}
}
private class MyContextMenuEntry : ContextMenuEntry
{
private readonly Item _item;
public MyContextMenuEntry(Item item) : base(6100) // Cliloc number
{
_item = item;
}
public override void OnClick(Mobile from, IEntity target)
{
// Handle click
}
}
```
## Two-Phase Deletion
```csharp
public override void OnDelete()
{
_timerToken.Cancel(); // Cancel timers FIRST
base.OnDelete();
}
public override void OnAfterDelete()
{
_timer?.Stop(); // Stop Timer references
_timer = null;
_owner = null; // Clear Mobile/Item references
base.OnAfterDelete();
}
```
## File Placement
| Content Type | Directory |
|---|---|
| Items | `Projects/UOContent/Items/{Category}/` |
| Weapons | `Projects/UOContent/Items/Weapons/{Type}/` |
| Armor | `Projects/UOContent/Items/Armor/{Type}/` |
| Creatures | `Projects/UOContent/Mobiles/{Type}/` |
| Animals | `Projects/UOContent/Mobiles/Animals/{Species}/` |
| Monsters | `Projects/UOContent/Mobiles/Monsters/{Era}/` |
| Spells | `Projects/UOContent/Spells/{School}/` |
| Skills | `Projects/UOContent/Skills/` |
| Engines | `Projects/UOContent/Engines/{SystemName}/` |
| Gumps | `Projects/UOContent/Gumps/` |
## Real Examples
- Simple creature: `Projects/UOContent/Mobiles/Animals/Bears/BlackBear.cs`
- Boss creature: `Projects/UOContent/Mobiles/Special/Barracoon.cs`
- SE creature: `Projects/UOContent/Mobiles/Monsters/SE/RaiJu.cs`
- Spell: `Projects/UOContent/Spells/First/MagicArrow.cs`
- Skill check: `Projects/UOContent/Skills/SkillCheck.cs`
- Loot packs: `Projects/UOContent/Misc/LootPack.cs`
## See Also
- `dev-docs/claude-skills/modernuo-serialization.md` - Serialization details
- `dev-docs/claude-skills/modernuo-era-expansion.md` - Era-conditional code
- `dev-docs/claude-skills/modernuo-timers.md` - Timer patterns
- `dev-docs/claude-skills/modernuo-property-lists.md` - Item tooltips
- `dev-docs/claude-skills/modernuo-gump-system.md` - UI dialogs
- `dev-docs/claude-skills/modernuo-commands-targeting.md` - Commands and targeting
- `dev-docs/claude-skills/modernuo-events.md` - Event system
- `dev-docs/content-patterns.md` - Full content documentation

View file

@ -0,0 +1,134 @@
---
name: modernuo-era-expansion
description: >
Trigger when writing era-conditional code, using Core.AOS/SE/ML etc., or when user hasn't specified target era. Always ask which expansion to target if not specified.
---
# ModernUO Era & Expansion Support
## When This Activates
- Writing code that depends on game era (damage formulas, skill caps, mechanics)
- Using `Core.AOS`, `Core.SE`, `Core.ML`, etc.
- User asks for a feature without specifying era
- Implementing mechanics that changed across expansions
## CRITICAL RULE
**Never assume era.** If the user hasn't specified which expansion to target, ASK before writing era-dependent code.
## Expansion Enum
```csharp
public enum Expansion
{
None, // 0 - Pre-T2A
T2A, // 1 - The Second Age
UOR, // 2 - Renaissance
UOTD, // 3 - Third Dawn
LBR, // 4 - Blackthorn's Revenge
AOS, // 5 - Age of Shadows (major overhaul)
SE, // 6 - Samurai Empire
ML, // 7 - Mondain's Legacy
SA, // 8 - Stygian Abyss
HS, // 9 - High Seas
TOL, // 10 - Time of Legends
EJ // 11 - Endless Journey
}
```
## Era Check Properties
Each returns `true` when `Core.Expansion >= that era`:
```csharp
Core.T2A // >= The Second Age
Core.UOR // >= Renaissance
Core.UOTD // >= Third Dawn
Core.LBR // >= Blackthorn's Revenge
Core.AOS // >= Age of Shadows
Core.SE // >= Samurai Empire
Core.ML // >= Mondain's Legacy
Core.SA // >= Stygian Abyss
Core.HS // >= High Seas
Core.TOL // >= Time of Legends
Core.EJ // >= Endless Journey
```
For exact expansion: `Core.Expansion == Expansion.AOS`
## Key Era Boundaries
### AOS (Age of Shadows) -- Most Significant Change
- Complete combat overhaul: resistance-based damage system
- Property-based item system (magic properties)
- New damage formula: `GetNewAosDamage()` vs flat `Utility.Random()`
- Luck system for loot
- Insurance system
### SE (Samurai Empire)
- Bushido / Ninjitsu skills
- Samurai/Ninja classes
- Loot pack adjustments
### ML (Mondain's Legacy)
- Spellweaving
- Adjusted skill gain chances
- Container weight display changes
## Pattern: Era-Conditional Code
```csharp
// Ternary for simple values
var delay = Core.SE ? 250 : Core.AOS ? 500 : 1000;
// If/else for logic branches
if (Core.AOS)
{
damage = GetNewAosDamage(10, 1, 4, target);
}
else
{
damage = Utility.Random(4, 4);
if (CheckResisted(target))
damage *= 0.75;
}
// Property display varies by era
if (Core.ML)
{
list.Add(1072241, $"{TotalItems}\t{MaxItems}\t{TotalWeight}\t{MaxWeight}");
}
else
{
list.Add(1050044, $"{TotalItems}\t{TotalWeight}");
}
```
## Pattern: Era-Dependent Loot
`LootPack` properties auto-select based on expansion:
```csharp
// These automatically pick the right era variant
LootPack.Poor // OldPoor / AosPoor / SePoor
LootPack.Average // OldAverage / AosAverage / SeAverage
LootPack.Rich // OldRich / AosRich / SeRich
LootPack.FilthyRich // OldFilthyRich / AosFilthyRich / SeFilthyRich
LootPack.UltraRich // OldUltraRich / AosUltraRich / SeUltraRich
```
## Anti-Patterns
- **Hardcoding mechanics for one era** without conditional checks
- **Assuming AOS** when the user might want pre-AOS
- **Using era-specific APIs** without checking (e.g., `GetNewAosDamage()` pre-AOS)
## Real Examples
- Era-conditional damage: `Projects/UOContent/Spells/First/MagicArrow.cs`
- Era-conditional properties: `Projects/Server/Items/Container.cs` (`GetProperties`)
- Era-conditional values: `Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs`
- Expansion enum: `Projects/Server/ExpansionInfo.cs`
- Stat config by era: `Projects/UOContent/Skills/SkillCheck.cs`
## See Also
- `dev-docs/era-expansion.md` - Complete expansion documentation
- `dev-docs/claude-skills/modernuo-content-patterns.md` - Content templates
- `dev-docs/claude-skills/modernuo-configuration.md` - Configuration system

View file

@ -0,0 +1,150 @@
---
name: modernuo-event-scheduler
description: >
Trigger when creating holiday events, seasonal content, scheduled maintenance, daily/weekly resets, or any wall-clock/calendar-based scheduling. When using EventScheduler, ScheduledEvent, YearlyScheduledEvent, or IRecurrencePattern.
---
# ModernUO EventScheduler (Wall-Clock / Calendar Scheduling)
## When This Activates
- Creating holiday or seasonal events (Halloween, Christmas, etc.)
- Scheduling daily/weekly/monthly resets or activities
- Any event that must fire at a real-world time or date
- Working with `EventScheduler`, `ScheduledEvent`, `YearlyScheduledEvent`, `CallbackScheduledEvent`
- Working with `IRecurrencePattern`, `AllowedDays`, `AllowedMonths`, `MonthDay`
## Key Rules
1. **EventScheduler for wall-clock/calendar, Timer for game-tick delays** — if the event is "at 9 AM every Monday," use EventScheduler; if it's "5 seconds from now," use Timer
2. **Always specify timezone** for local-time events — omitting defaults to UTC
3. **Prefer `CallbackScheduledEvent` (via static methods)** for simple recurring actions
4. **Use `YearlyScheduledEvent`** for seasonal windows (e.g., Oct 15 - Nov 1 each year)
5. **Cancel events on cleanup** — call `Cancel()` when disabling or shutting down
6. **1-second granularity** — EventScheduler ticks every second, not suitable for sub-second precision
## Timer vs EventScheduler Decision
| Need | Use |
|---|---|
| "Every 5 seconds" | `Timer.StartTimer` |
| "At 6:00 AM daily" | `EventScheduler.DailyAt` |
| "Delete after 10 seconds" | `Timer.StartTimer` |
| "Every Monday at noon" | `EventScheduler.WeeklyAt` |
| "Combat tick every 250ms" | `Timer.StartTimer` |
| "Oct 15 - Nov 1 each year" | `YearlyScheduledEvent` |
| "First Tuesday of each month" | `MonthlyOrdinalRecurrencePattern` |
## Quick Patterns
### Daily Event at a Specific Time
```csharp
var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
var startOn = new DateTime(2025, 1, 1, 6, 0, 0); // 6:00 AM
EventScheduler.DailyAt(startOn, ResetDailyQuests, eastern);
```
### Weekly Event
```csharp
var startOn = new DateTime(2025, 1, 6, 18, 0, 0); // Monday 6:00 PM
EventScheduler.WeeklyAt(startOn, StartWeeklyTournament, eastern);
```
### Monthly Event on a Specific Day
```csharp
var startOn = new DateTime(2025, 1, 15, 12, 0, 0); // 15th at noon
EventScheduler.MonthlyAt(startOn, MonthlyRewards, eastern);
```
### Yearly Seasonal Event (with Window)
```csharp
var halloween = new YearlyCallbackScheduledEvent(
new TimeOnly(0, 0),
new MonthDay(2025, 10, 15), // Start: Oct 15
new MonthDay(2025, 11, 1), // End: Nov 1
SpawnHalloweenContent,
EventScheduler.Daily
);
halloween.Schedule(DateTime.UtcNow, eastern);
```
### Filtered Weekly (Specific Days/Months)
```csharp
var pattern = new WeeklyRecurrencePattern(
intervalWeeks: 1,
allowedMonths: AllowedMonths.June | AllowedMonths.July | AllowedMonths.August,
allowedDays: AllowedDays.Friday | AllowedDays.Saturday
);
var evt = new CallbackScheduledEvent(new TimeOnly(20, 0), SummerWeekendEvent, pattern);
evt.Schedule(DateTime.UtcNow, eastern);
```
### Ordinal Monthly (e.g., "Second Tuesday")
```csharp
var pattern = new MonthlyOrdinalRecurrencePattern(
OrdinalDayOccurrence.Second,
DayOfWeek.Tuesday
);
var evt = new CallbackScheduledEvent(new TimeOnly(12, 0), MonthlyMeeting, pattern);
evt.Schedule(DateTime.UtcNow, eastern);
```
## Custom Event Class Template
```csharp
using System;
using Server.Engines.Events;
public class MyScheduledEvent : ScheduledEvent
{
public MyScheduledEvent(TimeOnly time, IRecurrencePattern recurrence)
: base(time, recurrence)
{
}
public override void OnEvent()
{
// Your event logic here
}
}
// Schedule it:
var evt = new MyScheduledEvent(new TimeOnly(9, 0), EventScheduler.Daily);
evt.Schedule(DateTime.UtcNow, timeZone);
// Cancel it:
evt.Cancel();
```
### Custom Yearly Seasonal Event Template
```csharp
public class MySeasonalEvent : YearlyScheduledEvent
{
protected MySeasonalEvent(
TimeOnly time,
MonthDay yearlyStart,
MonthDay yearlyEnd,
IRecurrencePattern recurrence
) : base(time, yearlyStart, yearlyEnd, recurrence)
{
}
public override void OnEvent()
{
// Only fires when date is within [yearlyStart, yearlyEnd]
}
}
```
## Anti-Patterns
- **Using `Timer.StartTimer` for calendar events**: Timers drift across restarts and have no timezone support — use EventScheduler
- **Forgetting to specify timezone**: Event fires at UTC instead of expected local time — always pass `TimeZoneInfo`
- **Not cancelling events on cleanup**: Scheduled events keep firing after the system is disabled — call `Cancel()`
- **Using EventScheduler for sub-second timing**: 1-second granularity is too coarse — use `Timer.StartTimer`
- **Constructing `MonthDay` with invalid day**: Throws `ArgumentOutOfRangeException` — validate against `DateTime.DaysInMonth`
## See Also
- `dev-docs/event-scheduler.md` — Complete EventScheduler documentation
- `dev-docs/timers.md` — Game-tick timer system (Timer.StartTimer, TimerExecutionToken)
- `dev-docs/claude-skills/modernuo-timers.md` — Timer skill for game-tick delays

View file

@ -0,0 +1,184 @@
---
name: modernuo-events
description: >
Trigger when subscribing to or creating game events, working with EventSink or generated events. When hooking into player login, death, speech, or other game events.
---
# ModernUO Events System
## When This Activates
- Subscribing to game events (login, logout, death, speech)
- Creating new events
- Working with `EventSink` static events
- Using `[GeneratedEvent]` / `[OnEvent]` attributes
## Key Rules
1. **Subscribe to events in `Configure()`** static method
2. **EventSink events are `static event Action<T>`** -- subscribe with `+=`
3. **Event handlers must match the delegate signature**
4. **Unsubscribe if your system can be disabled** (prevent leaks)
## EventSink Events
Subscribe in `Configure()`:
```csharp
public static void Configure()
{
EventSink.Connected += OnPlayerConnected;
EventSink.Disconnected += OnPlayerDisconnected;
EventSink.Logout += OnLogout;
EventSink.ServerStarted += OnServerStarted;
}
```
### Available Events
#### Core Lifecycle
```csharp
EventSink.ServerStarted // Action -- Server fully started
EventSink.Shutdown // Action -- Server shutting down
EventSink.WorldLoad // Action -- World loaded from saves
EventSink.WorldSave // Action -- World save triggered
EventSink.WorldSavePostSnapshot // Action<WorldSavePostSnapshotEventArgs>
```
#### Player Connection
```csharp
EventSink.Connected // Action<Mobile> -- Player connected
EventSink.BeforeDisconnected // Action<Mobile> -- About to disconnect
EventSink.Disconnected // Action<Mobile> -- Player disconnected
EventSink.Logout // Action<Mobile> -- Player logged out
```
#### Account
```csharp
EventSink.AccountLogin // Action<AccountLoginEventArgs>
// AccountLoginEventArgs: .State (NetState), .Username, .Password, .Accepted (set), .RejectReason (set)
```
#### Communication
```csharp
EventSink.Speech // Action<SpeechEventArgs>
// SpeechEventArgs: .Mobile, .Speech, .Type, .Hue, .Keywords, .Handled (set), .Blocked (set)
```
#### Combat
```csharp
EventSink.AggressiveAction // Action<AggressiveActionEventArgs>
// AggressiveActionEventArgs: .Aggressed, .Aggressor, .Criminal
```
#### Movement
```csharp
EventSink.Movement // Action<MovementEventArgs>
// MovementEventArgs: .Mobile, .Direction, .Blocked (set)
```
#### Network
```csharp
EventSink.SocketConnect // Action<SocketConnectEventArgs>
// SocketConnectEventArgs: .Address, .AllowConnection (set)
EventSink.ServerCrashed // Action<ServerCrashedEventArgs>
// ServerCrashedEventArgs: .Exception, .Close (set)
```
#### UI
```csharp
EventSink.PaperdollRequest // Action<Mobile, Mobile> -- (beholder, beheld)
```
## Event Handler Pattern
```csharp
public static class MyEventSystem
{
public static void Configure()
{
EventSink.Connected += OnConnected;
EventSink.Speech += OnSpeech;
}
private static void OnConnected(Mobile m)
{
if (m is PlayerMobile pm)
{
pm.SendMessage("Welcome back!");
}
}
private static void OnSpeech(SpeechEventArgs e)
{
if (e.Speech.InsensitiveContains("help"))
{
e.Mobile.SendMessage("How can I help you?");
e.Handled = true; // Prevent further processing
}
}
}
```
## Generated Events (Code-Generated)
For custom events on game entities, use the CodeGeneratedEvents package:
### Defining Events
```csharp
// On the class that fires the event:
[GeneratedEvent(nameof(PlayerLoginEvent))]
public static partial void PlayerLoginEvent(PlayerMobile player);
```
### Subscribing to Events
```csharp
// On the handler class:
[OnEvent(nameof(PlayerMobile.PlayerLoginEvent))]
public static void HandleLogin(PlayerMobile player)
{
// Handle the event
}
```
### Known Generated Events
- `PlayerMobile.PlayerLoginEvent`
- `PlayerMobile.PlayerDeathEvent`
- `BaseCreature.CreatureDeathEvent`
External reference: https://github.com/modernuo/CodeGeneratedEvents
## EventArgs Pool Pattern
Some EventArgs use object pooling to avoid allocation:
```csharp
// Movement uses pooling:
var args = MovementEventArgs.Create(mobile, dir);
EventSink.InvokeMovement(args);
args.Free(); // Return to pool
// AggressiveAction uses pooling:
var args = AggressiveActionEventArgs.Create(aggressed, aggressor, criminal);
EventSink.InvokeAggressiveAction(args);
args.Free();
```
## Anti-Patterns
- **Subscribing outside `Configure()`**: Won't be called during startup
- **Not checking player type**: `EventSink.Connected` fires for all mobiles, cast to `PlayerMobile` if needed
- **Blocking in event handlers**: Event handlers run on the game loop -- keep them fast
- **Not unsubscribing**: If system can be disabled, unsubscribe to prevent leaks
## Real Examples
- EventSink core: `Projects/Server/Events/EventSink.cs`
- Speech events: `Projects/Server/Events/SpeechEvent.cs`
- Movement events: `Projects/Server/Events/MovementEvent.cs`
- Account events: `Projects/Server/Events/AccountLoginEvent.cs`
- World events: `Projects/Server/Events/EventSink.cs` (WorldLoad, WorldSave, ServerStarted, Shutdown)
- Connection events: `Projects/Server/Events/SocketConnectionEvent.cs`
- GumpSystem subscription: `Projects/UOContent/Gumps/Base/GumpSystem.cs`
## See Also
- `dev-docs/events.md` - Complete events documentation
- `dev-docs/claude-skills/modernuo-content-patterns.md` - Content hooks
- `dev-docs/claude-skills/modernuo-configuration.md` - Configure() pattern

View file

@ -0,0 +1,350 @@
---
name: modernuo-gump-system
description: >
Trigger when creating or modifying gumps (UI dialogs). When working with BaseGump, StaticGump, DynamicGump, or GumpSystem.
---
# ModernUO Gump System
## When This Activates
- Creating new gumps (UI dialogs/windows)
- Modifying existing gump layouts
- Handling gump button responses
- Using `mobile.SendGump()`, `HasGump<T>()`, `CloseGump<T>()`
## Key Rules
1. **Use `StaticGump<TSelf>`** when layout is fixed (cached, better performance)
2. **Use `DynamicGump`** when layout depends on instance data (rebuilt each time)
3. **Use `mobile.SendGump(gump)`** to send -- requires `using Server.Gumps;`
4. **Override `Singleton => true`** if only one instance should be open per player
5. **Never forget `using Server.Gumps;`** -- extension methods won't resolve without it
## Hierarchy
```
BaseGump (abstract)
├── StaticGump<TSelf> -- Cached layout, use for menus/dialogs
└── DynamicGump -- Rebuilt layout, use for dynamic content
```
## StaticGump Pattern (Preferred for Fixed Layouts)
```csharp
using Server.Gumps;
namespace Server.Gumps;
public class MyGump : StaticGump<MyGump>
{
private readonly Mobile _player;
private readonly string _message;
public override bool Singleton => true; // One per player
public MyGump(Mobile player, string message) : base(50, 50)
{
_player = player;
_message = message;
}
protected override void BuildLayout(ref StaticGumpBuilder builder)
{
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
// Static text (cached)
builder.AddHtmlLocalized(10, 10, 380, 20, 1060635, 0x7800); // "Warning"
// Dynamic text placeholder (filled per-instance)
builder.AddHtmlPlaceholder(10, 40, 380, 220, "content", false, true);
// Buttons
builder.AddButton(150, 265, 4005, 4007, 1); // OK button (buttonID=1)
builder.AddHtmlLocalized(185, 267, 100, 20, 1011036); // "OK"
builder.AddButton(250, 265, 4017, 4019, 0); // Cancel (buttonID=0 = close)
builder.AddHtmlLocalized(285, 267, 100, 20, 1011012); // "Cancel"
}
protected override void BuildStrings(ref GumpStringsBuilder builder)
{
// Fill dynamic placeholders
builder.SetHtmlText("content", _message, "#FFC000");
}
public override void OnResponse(NetState sender, in RelayInfo info)
{
if (info.ButtonID == 1)
{
_player.SendMessage("You clicked OK!");
}
}
}
```
## DynamicGump Pattern (For Variable Layouts)
```csharp
using Server.Gumps;
namespace Server.Gumps;
public class InventoryGump : DynamicGump
{
private readonly Mobile _player;
private readonly List<Item> _items;
public override bool Singleton => true;
public InventoryGump(Mobile player, List<Item> items) : base(50, 50)
{
_player = player;
_items = items;
}
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
builder.AddPage();
builder.AddBackground(0, 0, 400, 40 + _items.Count * 30, 5054);
builder.AddHtml(10, 10, 380, 20, "Inventory");
for (var i = 0; i < _items.Count; i++)
{
var y = 40 + i * 30;
builder.AddLabel(10, y, 0, _items[i].Name ?? "Unknown");
builder.AddButton(350, y, 4005, 4007, i + 1); // Button per item
}
}
public override void OnResponse(NetState sender, in RelayInfo info)
{
if (info.ButtonID > 0 && info.ButtonID <= _items.Count)
{
var item = _items[info.ButtonID - 1];
_player.SendMessage($"Selected: {item.Name}");
}
}
}
```
## Sending and Managing Gumps
```csharp
using Server.Gumps;
// Send gump
mobile.SendGump(new MyGump(mobile, "Hello!"));
// Check if gump is open
if (mobile.HasGump<MyGump>())
{
// Already open
}
// Find existing gump
var gump = mobile.FindGump<MyGump>();
// Close gump
mobile.CloseGump<MyGump>();
```
## Builder Methods Reference
### Layout Elements
```csharp
builder.AddPage(int page = 0);
builder.AddBackground(int x, int y, int width, int height, int gumpID);
builder.AddAlphaRegion(int x, int y, int width, int height);
builder.AddImageTiled(int x, int y, int width, int height, int gumpID);
builder.AddImage(int x, int y, int gumpID, int hue = 0);
builder.AddItem(int x, int y, int itemID, int hue = 0);
```
### Text
```csharp
builder.AddLabel(int x, int y, int hue, ReadOnlySpan<char> text);
builder.AddHtml(int x, int y, int w, int h, ReadOnlySpan<char> text, ...);
builder.AddHtmlLocalized(int x, int y, int w, int h, int number, ...);
builder.AddLabelPlaceholder(int x, int y, int hue, ReadOnlySpan<char> slotKey);
builder.AddHtmlPlaceholder(int x, int y, int w, int h, ReadOnlySpan<char> slotKey, ...);
```
### Interactive
```csharp
builder.AddButton(int x, int y, int normalID, int pressedID, int buttonID, ...);
builder.AddCheckbox(int x, int y, int inactiveID, int activeID, bool selected, int switchID);
builder.AddRadio(int x, int y, int inactiveID, int activeID, bool selected, int switchID);
builder.AddTextEntry(int x, int y, int w, int h, int hue, int entryID, ...);
```
### Modifiers
```csharp
builder.SetNoClose(); // Cannot close with right-click
builder.SetNoMove(); // Cannot move the gump
builder.SetNoResize(); // Cannot resize
builder.SetNoDispose(); // Cannot dispose
builder.AddTooltip(int number); // Hover tooltip
```
## Response Handling
```csharp
public override void OnResponse(NetState sender, in RelayInfo info)
{
var buttonID = info.ButtonID; // 0 = close, 1+ = button clicks
var isChecked = info.IsSwitched(0); // Checkbox/radio state by switchID
var text = info.GetTextEntry(0); // Text entry value by entryID
}
```
## Static vs Dynamic: When to Use Which
| Use StaticGump | Use DynamicGump |
|---|---|
| Confirmation dialogs | Lists of variable length |
| Static menus | Player-specific content |
| Warning prompts | Crafting interfaces |
| Settings panels | Search results |
| Help pages | Dynamic data display |
## Important Properties
### Singleton
```csharp
public override bool Singleton => true;
```
Automatically closes any existing instance of this gump type for the player before sending a new one. **Always set this for gumps that shouldn't stack.** Without it, repeated sends create duplicates the player must close individually.
### Cached (StaticGump only)
```csharp
protected virtual bool Cached => true; // default
```
Controls whether `StaticGump<T>` caches its compiled layout bytes. Override to `false` **during development only** to force layout rebuild each send — useful for iterating on layout without restarting the server:
```csharp
protected override bool Cached => false; // TEMPORARY — remove before commit
```
## Empty Gump Rule (CRITICAL AUDIT RULE)
**NEVER send a gump with no visual components.** An empty gump cannot be closed by the client — no close button, no right-click dismiss. This causes a **gump leak** on both client and server.
Empty gumps happen when short-circuiting inside a constructor or `BuildLayout`:
```csharp
// BAD — early return in constructor leaves gump empty but it still gets sent
public MyGump(Mobile from) : base(50, 50)
{
if (!from.Alive) return; // Empty gump — LEAK!
// ...layout...
}
```
### Fix: Static DisplayTo Pattern
Validate prerequisites **before** constructing the gump. Make the constructor `private`:
```csharp
public class MyGump : DynamicGump
{
public override bool Singleton => true;
private MyGump(Mobile from, SomeData data) : base(50, 50) { /* store fields */ }
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
// Always has visual content — DisplayTo guarantees valid state
}
public static void DisplayTo(Mobile from)
{
if (!from.Alive || from.NetState == null) return; // No gump created
var data = GetData(from);
if (data == null) return; // No gump created
from.SendGump(new MyGump(from, data));
}
}
```
Reference: `Projects/UOContent/Gumps/Go/GoGump.cs`
## Converting Legacy Gump to DynamicGump / StaticGump
### Choose target type
- Layout is fixed structure → `StaticGump<T>` (cached, best performance)
- Layout varies per instance (loops, conditionals) → `DynamicGump`
- When in doubt → `DynamicGump` (simpler, still much better than legacy)
### Conversion checklist
1. Change base class: `Gump``DynamicGump` or `StaticGump<MyGump>`
2. Move layout code from constructor into `BuildLayout(ref DynamicGumpBuilder builder)` or `BuildLayout(ref StaticGumpBuilder builder)`
3. Store any state the constructor used as fields (constructor now just stores state, doesn't build layout)
4. Replace property flags with builder methods:
- `Closable = false``builder.SetNoClose()`
- `Draggable = false``builder.SetNoMove()`
- `Resizable = false``builder.SetNoResize()`
- `Disposable = false``builder.SetNoDispose()`
5. `AddPage(0)``builder.AddPage()` (0 is default)
6. For `StaticGump<T>`: extract dynamic text into placeholders (`AddLabelPlaceholder` / `AddHtmlPlaceholder`) and fill in `BuildStrings(ref GumpStringsBuilder builder)`
7. Update `OnResponse` signature: `RelayInfo info``in RelayInfo info`
8. Add `public override bool Singleton => true;` if appropriate
9. Make constructor `private`, add `public static void DisplayTo(Mobile from)` method
10. Move all validation/short-circuit logic into `DisplayTo` (never leave `BuildLayout` empty)
### Example: legacy → modern
```csharp
// BEFORE (legacy Gump)
public class OldGump : Gump
{
public OldGump(Mobile from) : base(50, 50)
{
Closable = false;
AddPage(0);
AddBackground(0, 0, 400, 300, 5054);
AddLabel(20, 20, 0x480, from.Name);
AddButton(20, 260, 4005, 4007, 1);
}
public override void OnResponse(NetState sender, RelayInfo info) { }
}
// AFTER (DynamicGump)
public class NewGump : DynamicGump
{
private readonly Mobile _from;
public override bool Singleton => true;
private NewGump(Mobile from) : base(50, 50) => _from = from;
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
builder.SetNoClose();
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
builder.AddLabel(20, 20, 0x480, _from.Name);
builder.AddButton(20, 260, 4005, 4007, 1);
}
public override void OnResponse(NetState sender, in RelayInfo info) { }
public static void DisplayTo(Mobile from) => from.SendGump(new NewGump(from));
}
```
## Anti-Patterns
- **Missing `using Server.Gumps;`**: `SendGump()`, `HasGump<T>()` won't resolve
- **Creating DynamicGump for static layouts**: Wastes CPU rebuilding every time
- **Not setting `Singleton`**: Multiple copies of same gump stack up
- **ButtonID 0 for actions**: 0 means "close" -- use 1+ for action buttons
- **Empty gumps**: Short-circuiting in constructor/BuildLayout causes gump leaks — use `DisplayTo` pattern
- **Leaving `Cached => false` in production**: Defeats the purpose of StaticGump — only disable during development
## Real Examples
- StaticGump warning: `Projects/UOContent/Gumps/StaticWarningGump.cs`
- DynamicGump craft: `Projects/UOContent/Engines/Craft/Core/CraftGump.cs`
- GumpSystem extensions: `Projects/UOContent/Gumps/Base/GumpSystem.cs`
- StaticGumpBuilder: `Projects/UOContent/Gumps/Base/StaticGumpBuilder.cs`
- DynamicGumpBuilder: `Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs`
- BaseGump: `Projects/UOContent/Gumps/Base/BaseGump.cs`
## See Also
- `dev-docs/gump-system.md` - Complete gump documentation
- `dev-docs/claude-skills/modernuo-commands-targeting.md` - Commands that open gumps

View file

@ -0,0 +1,259 @@
---
name: modernuo-networking
description: >
Trigger when creating or modifying packets, working with NetState, SpanWriter, SpanReader, or implementing network protocol handlers.
---
# ModernUO Networking & Packets
## When This Activates
- Creating new packets (outgoing or incoming)
- Modifying existing packet handlers
- Working with `NetState`, `SpanWriter`, `SpanReader`
- Implementing network protocol features
## Key Rules
1. **Outgoing packets**: Static `Create*` method fills `Span<byte>`, extension `Send*` method on `NetState`
2. **Incoming packets**: Register with function pointers in `Configure()`
3. **Use `stackalloc`** for small fixed-size outgoing packets
4. **Always check `ns.CannotSendPackets()`** before sending
5. **Big-endian by default** -- use `WriteLE()` for little-endian
## Outgoing Packet Pattern
### Step 1: Create Extension Method on NetState
```csharp
public static class OutgoingMyPackets
{
public const int MyPacketLength = 12;
public static void SendMyPacket(this NetState ns, Serial target, int value)
{
if (ns.CannotSendPackets())
return;
var buffer = stackalloc byte[MyPacketLength].InitializePacket();
CreateMyPacket(buffer, target, value);
ns.Send(buffer);
}
public static void CreateMyPacket(Span<byte> buffer, Serial target, int value)
{
if (buffer[0] != 0) // Already initialized check
return;
var writer = new SpanWriter(buffer);
writer.Write((byte)0xBF); // Packet ID
writer.Write((ushort)12); // Length
writer.Write((ushort)0x99); // Sub-command
writer.Write(target); // Serial (4 bytes)
writer.Write((short)value); // Value (2 bytes)
}
}
```
### Step 2: Call from Game Code
```csharp
mobile.NetState.SendMyPacket(target.Serial, 42);
// Or for all nearby players:
foreach (var ns in mobile.GetClientsInRange(18))
{
ns.SendMyPacket(target.Serial, 42);
}
```
### Variable-Length Outgoing Packets
```csharp
public static void SendMyDynamicPacket(this NetState ns, string name)
{
if (ns.CannotSendPackets())
return;
var writer = new SpanWriter(stackalloc byte[64]); // Or use pooled buffer for large packets
writer.Write((byte)0x99); // Packet ID
writer.Write((ushort)0); // Placeholder for length
writer.WriteBigUniNull(name); // Unicode string with null terminator
writer.WritePacketLength(); // Fill in actual length
ns.Send(writer.Span);
}
```
## Incoming Packet Pattern
### Step 1: Register Handler in Configure()
```csharp
public static class IncomingMyPackets
{
public static unsafe void Configure()
{
IncomingPackets.Register(0x99, 12, true, &MyPacketHandler);
// ^ID ^len ^inGameOnly ^handler
// len=0 for variable-length packets
}
public static void MyPacketHandler(NetState state, SpanReader reader)
{
var from = state.Mobile;
if (from == null)
return;
var targetSerial = (Serial)reader.ReadUInt32();
var value = reader.ReadInt16();
var target = World.FindMobile(targetSerial);
if (target != null)
{
// Process packet
}
}
}
```
### Encoded Packet Registration
```csharp
public static unsafe void Configure()
{
IncomingPackets.RegisterEncoded(0x28, true, &GuildGumpRequest);
// ^subID ^inGame ^handler
}
public static void GuildGumpRequest(NetState state, IEntity target, EncodedReader reader)
{
// Handle encoded packet
}
```
## SpanWriter Reference
```csharp
// Constructors
var writer = new SpanWriter(Span<byte> buffer);
var writer = new SpanWriter(stackalloc byte[64]);
var writer = new SpanWriter(int capacity, bool resize = false);
// Integer writes (big-endian by default)
writer.Write(bool value);
writer.Write(byte value);
writer.Write(sbyte value);
writer.Write(short value); // Big-endian
writer.Write(ushort value); // Big-endian
writer.Write(int value); // Big-endian
writer.Write(uint value); // Big-endian
writer.Write(long value);
writer.Write(Serial serial); // 4 bytes, big-endian
// Little-endian variants
writer.WriteLE(short value);
writer.WriteLE(ushort value);
writer.WriteLE(int value);
writer.WriteLE(uint value);
// String writes
writer.WriteAscii(string value);
writer.WriteAsciiNull(string value); // Null-terminated
writer.WriteAscii(string value, int fixedLength);
writer.WriteLatin1(string value);
writer.WriteLatin1Null(string value);
writer.WriteBigUni(string value); // UTF-16 big-endian
writer.WriteBigUniNull(string value);
writer.WriteLittleUni(string value); // UTF-16 little-endian
writer.WriteLittleUniNull(string value);
writer.WriteUTF8(string value);
writer.WriteUTF8Null(string value);
// Utilities
writer.Write(ReadOnlySpan<byte> data);
writer.Clear(int count); // Write zeros
writer.Seek(int offset, SeekOrigin origin);
writer.WritePacketLength(); // Fill in length at position 1-2
writer.EnsureCapacity(int capacity);
writer.Dispose(); // Return pooled buffer if any
// Properties
writer.Position; // Current write position
writer.Capacity; // Buffer size
writer.Span; // ReadOnlySpan<byte> of written data
```
## SpanReader Reference
```csharp
// Constructor
var reader = new SpanReader(ReadOnlySpan<byte> data);
// Integer reads (big-endian by default)
reader.ReadByte();
reader.ReadBoolean(); // byte > 0
reader.ReadSByte();
reader.ReadInt16(); // Big-endian
reader.ReadUInt16(); // Big-endian
reader.ReadInt32(); // Big-endian
reader.ReadUInt32(); // Big-endian
reader.ReadInt64();
reader.ReadUInt64();
// Little-endian variants
reader.ReadInt16LE();
reader.ReadUInt16LE();
reader.ReadUInt32LE();
// String reads
reader.ReadAscii(int fixedLength = -1);
reader.ReadAsciiSafe(int fixedLength = -1); // Filters control chars
reader.ReadLatin1(int fixedLength = -1);
reader.ReadLatin1Safe(int fixedLength = -1);
reader.ReadBigUni(int fixedLength = -1);
reader.ReadBigUniSafe(int fixedLength = -1);
reader.ReadLittleUni(int fixedLength = -1);
reader.ReadUTF8(int fixedLength = -1);
// Utilities
reader.Seek(int offset, SeekOrigin origin);
reader.Read(Span<byte> destination);
// Properties
reader.Position; // Current read position
reader.Length; // Total data length
reader.Remaining; // Bytes remaining
```
## Common Packet Patterns
### Sound Effect
```csharp
ns.SendSoundEffect(0x1E5, target);
```
### Mobile Animation
```csharp
ns.SendMobileAnimation(mobile.Serial, action, frameCount, repeatCount, forward, repeat, delay);
```
### Damage
```csharp
ns.SendDamage(target.Serial, amount);
```
## Anti-Patterns
- **Not checking `CannotSendPackets()`**: Player may have disconnected
- **Using `new byte[]` for packets**: Use `stackalloc` for fixed-size, `SpanWriter` for variable
- **Wrong endianness**: UO protocol is big-endian; only use `WriteLE` when spec requires it
- **Not calling `InitializePacket()`**: Required for `Create*` pattern with static buffer reuse
## Real Examples
- Mobile packets: `Projects/Server/Network/Packets/OutgoingMobilePackets.cs`
- Item packets: `Projects/Server/Network/Packets/OutgoingItemPackets.cs`
- Damage packets: `Projects/Server/Network/Packets/OutgoingDamagePackets.cs`
- Effect packets: `Projects/Server/Network/Packets/OutgoingEffectPackets.cs`
- Incoming registration: `Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs`
- Movement handler: `Projects/UOContent/Network/Packets/IncomingMovementPackets.cs`
- SpanWriter: `Projects/Server/Buffers/SpanWriter.cs`
- SpanReader: `Projects/Server/Buffers/SpanReader.cs`
## See Also
- `dev-docs/networking-packets.md` - Complete networking documentation
- `dev-docs/claude-skills/modernuo-threading.md` - Network I/O threading context

View file

@ -0,0 +1,205 @@
---
name: modernuo-property-lists
description: >
Trigger when implementing GetProperties(), working with IPropertyList/ObjectPropertyList, or customizing item tooltips.
---
# ModernUO Property Lists (Tooltips)
## When This Activates
- Implementing `GetProperties()` override
- Working with `IPropertyList` or `ObjectPropertyList`
- Customizing item or mobile tooltips
- Using `[InvalidateProperties]` attribute
- Adding cliloc-based text to items
## Key Rules
1. **Always call `base.GetProperties(list)` first** in overrides
2. **Use cliloc numbers** when possible (int IDs that map to localized strings)
3. **String interpolation** works with `IPropertyList` -- use `$"..."` syntax
4. **`[InvalidateProperties]`** on `[SerializableField]` auto-refreshes tooltip on change
5. **Call `InvalidateProperties()`** manually when non-serialized state changes tooltip
## IPropertyList Interface
```csharp
public interface IPropertyList
{
void Add(int number); // Cliloc number only
void Add(int number, string argument); // Cliloc with ~1_val~ arg
void Add(string text); // Raw string (uses internal cliloc)
void Add(int number, int value); // Cliloc with int arg
void AddLocalized(int value); // Cliloc number as value
void AddLocalized(int number, int value); // Cliloc wrapper for cliloc
// String interpolation overloads
void Add(ref InterpolatedStringHandler handler);
void Add(int number, ref InterpolatedStringHandler handler);
}
```
## Patterns
### Basic GetProperties Override
```csharp
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list); // ALWAYS call base first
list.Add(1060741, $"{_charges}"); // "charges: ~1_val~"
list.Add($"{"Quality: "}{_quality}"); // Raw string
list.Add(1060637, $"{_uses}\t{_maxUses}"); // "~1_val~ / ~2_val~"
}
```
### Cliloc Arguments Format
Cliloc strings use `~1_val~`, `~2_val~`, etc. as placeholders. Arguments are tab-separated:
```csharp
// Cliloc 1060637 = "~1_val~ / ~2_val~"
list.Add(1060637, $"{current}\t{max}");
// Cliloc 1072241 = "Contents: ~1_ITEMS~/~2_MAXITEMS~ items, ~3_WEIGHT~/~4_MAXWEIGHT~ stones"
list.Add(1072241, $"{TotalItems}\t{MaxItems}\t{TotalWeight}\t{MaxWeight}");
// Cliloc 1042971 = "~1_val~" (generic single argument)
list.Add(1042971, $"{"Custom text here"}");
```
### String Literals Must Be Holes (CRITICAL)
The interpolated string handler distinguishes **literals** (bare text between `{}` holes) from **holes** (values inside `{}`). Literals are delimiters. Holes are arguments. This matters because the property list system is also used for web rendering, which must tell arguments apart from delimiters.
**String constants must always be wrapped as holes: `{"..."}`**
```csharp
// BAD — "Chances" becomes a literal/delimiter, not an argument
list.Add(1060658, $"Chances\t{_charges}");
// GOOD — "Chances" is a hole → argument ~1_val~
list.Add(1060658, $"{"Chances"}\t{_charges}");
```
Real examples (`Teleporter.cs`):
```csharp
list.Add(1060658, $"{"Map"}\t{_mapDest}"); // "~1_val~: ~2_val~"
list.Add(1060659, $"{"Coords"}\t{_pointDest}");
list.Add(1060661, $"{"Range"}\t{_range}");
```
**Rule**: Only `\t` (argument separator) should be bare literal text. Everything else — including string constants — must be inside `{}` holes.
### Cliloc as Argument (Use `:#` Format Specifier)
When an argument is itself a cliloc number, use the `:#` format specifier — **not** a `"#number"` string:
```csharp
// BAD — "#1060000" is a string, web renderers will display it literally
list.Add(1050039, $"{m_Amount}\t{"#1060000"}");
// GOOD — :# tells the handler this is a cliloc number to resolve
list.Add(1050039, $"{m_Amount}\t{1060000:#}");
```
The `:#` format lets the handler (and other consumers like web renderers) know the value is a cliloc reference to resolve, not a raw number. Also available via `list.AddLocalized(number, clilocValue)`.
### Looking Up Cliloc Text
If you don't know what arguments a cliloc number expects, you can read the `cliloc.enu` binary file. Loading logic is in `Projects/Server/Localization/Localization.cs``LoadClilocs(string lang, string file)`. Ask the user where their `cliloc.enu` file is (typically in the UO client data directory).
### Auto-Refresh with [InvalidateProperties]
```csharp
[SerializableField(0)]
[InvalidateProperties] // Auto-calls InvalidateProperties() when Charges changes
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
```
### Manual Refresh
```csharp
public void UseCharge()
{
_charges--;
InvalidateProperties(); // Manually trigger tooltip refresh
this.MarkDirty();
}
```
### Conditional Properties
```csharp
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
if (_charges > 0)
list.Add(1060741, $"{_charges}");
if (_owner != null)
list.Add($"{"Owned by: "}{_owner.Name}");
if (Core.AOS) // Era-conditional properties
list.Add(1061170, $"{_imbueLevel}"); // "animal " ~1_val~
}
```
### Mobile Properties
```csharp
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
if (Core.AOS && Faction != null)
{
list.Add(1060776, $"{Rank.Title}\t{Faction.Definition.PropName}");
}
if (DisplayChampionTitle)
{
var titleLabel = ChampionTitleSystem.GetChampionTitleLabel(this);
if (titleLabel > 0)
list.Add(titleLabel);
}
}
```
## Common Cliloc Numbers
| Number | Text | Usage |
|---|---|---|
| 1042971 | `~1_val~` | Generic single argument |
| 1060741 | `charges: ~1_val~` | Charge count |
| 1060637 | `~1_val~ / ~2_val~` | Current/max values |
| 1060658 | `~1_val~: ~2_val~` | Key: value pair |
| 1050044 | `~1_ITEMS~ items, ~2_WEIGHT~ stones` | Container contents |
| 1072241 | `Contents: ~1~/~2~ items, ~3~/~4~ stones` | ML container |
| 1060776 | `~1_val~, ~2_val~` | Two comma-separated values |
| 1061170 | `animal lore ~1_val~` | Taming info |
| 1053099 | `damage ~1_val~ - ~2_val~` | Damage range |
## ObjectPropertyList Internals
- Packet ID: 0xD6
- Hash-based change detection -- only sends if content actually changed
- `InvalidateProperties()` rebuilds the list and compares hash
- Uses `STArrayPool<char>` for string building (zero GC)
- Global toggle: `ObjectPropertyList.Enabled`
## Anti-Patterns
- **Forgetting `base.GetProperties(list)`**: Loses default name/weight display
- **Not using cliloc**: Raw strings don't get localized
- **Excessive rebuilds**: Don't call `InvalidateProperties()` in tight loops
- **Assuming tooltip support**: Check `ObjectPropertyList.Enabled` if needed
## Real Examples
- Item properties: `Projects/Server/Items/Item.cs` (AddNameProperties, GetProperties)
- Mobile properties: `Projects/UOContent/Mobiles/PlayerMobile.cs` (GetProperties)
- Container properties: `Projects/Server/Items/Container.cs` (era-conditional display)
- Interface: `Projects/Server/PropertyList/IPropertyList.cs`
- Implementation: `Projects/Server/PropertyList/ObjectPropertyList.cs`
## See Also
- `dev-docs/property-lists.md` - Complete property list documentation
- `dev-docs/claude-skills/modernuo-serialization.md` - [InvalidateProperties] on fields
- `dev-docs/claude-skills/modernuo-era-expansion.md` - Era-conditional properties

View file

@ -0,0 +1,235 @@
---
name: modernuo-regions
description: >
Trigger when creating custom regions, dynamic item-controlled areas, dungeon sub-zones, travel restrictions, housing blocks, spawn control, or any spatial gameplay rule. When working with Region, BaseRegion, GuardedRegion, DungeonRegion, HouseRegion, or CheckTravel.
---
# ModernUO Regions
## When This Activates
- Creating a custom region (dungeon zone, boss arena, restricted area)
- Creating a dynamic region tied to an item (chest effect zone, spawn area)
- Adding travel spell restrictions, housing blocks, or spawn control
- Working with `Region`, `BaseRegion`, `GuardedRegion`, `DungeonRegion`, `HouseRegion`
- Overriding `CheckTravel`, `AllowHousing`, `OnEnter`, `OnBeginSpellCast`, etc.
- Modifying region JSON or `RegionJsonRegistration`
## Key Rules
1. **Inherit from the right base**`BaseRegion` for general, `DungeonRegion` for dungeons, `GuardedRegion` for towns, `NoTravelSpellsAllowedRegion` for no-travel dungeons
2. **Dynamic regions must Register/Unregister** — call `Register()` after creation, `Unregister()` before recreation or deletion
3. **Use `Region.Find(location, map)` as parent** for dynamic child regions — this inherits the existing region's behavior
4. **Defer registration in `[AfterDeserialization]`** — use `Timer.StartTimer(TimeSpan.Zero, UpdateRegion)` since map/parents may not be loaded yet
5. **Always override `AllowHousing` returning false** if your region should block housing
6. **Staff bypass** — travel/spell checks should allow `AccessLevel > Player`
7. **Virtual hooks delegate to Parent** — you only need to override what you change
## Choosing a Base Class
| You Need | Inherit From |
|---|---|
| Custom overworld area | `BaseRegion` |
| Dungeon area (dark, no housing) | `DungeonRegion` |
| Dungeon + no travel spells | `NoTravelSpellsAllowedRegion` |
| Town with guards | `GuardedRegion` or `TownRegion` |
| No housing only | `NoHousingRegion` |
| Item-controlled dynamic area | `BaseRegion` (parent = `Region.Find(...)`) |
## Quick Patterns
### Static Region (JSON-Defined)
Add to `Distribution/Data/regions.json`:
```json
{
"$type": "NoTravelSpellsAllowedRegion",
"Name": "My Dungeon",
"Map": "Felucca",
"Parent": { "Name": "Felucca", "Map": "Felucca" },
"Area": [{ "x1": 100, "y1": 200, "x2": 300, "y2": 400 }],
"GoLocation": { "x": 150, "y": 250, "z": 0 },
"Music": "Dungeon9"
}
```
Register the type (if new) in `RegionJsonRegistration.Configure()`:
```csharp
RegionJsonSerializer.Register<MyCustomRegion>();
```
### Custom Region Class
```csharp
public class MyCustomRegion : BaseRegion
{
public MyCustomRegion(string name, Map map, Region parent, params Rectangle3D[] area)
: base(name, map, parent, area)
{
}
public override bool AllowHousing(Mobile from, Point3D p) => false;
public override bool CheckTravel(
Mobile m, Point3D newLocation, TravelCheckType travelType, out TextDefinition message)
{
message = null;
return m.AccessLevel > AccessLevel.Player;
}
}
```
### Dynamic Item-Tracked Region
Full pattern for an item that creates a region around itself:
```csharp
public partial class MySpecialItem : Item
{
private MyItemRegion _region;
public void UpdateRegion()
{
_region?.Unregister();
if (!Deleted && Map != Map.Internal)
{
_region = new MyItemRegion(this);
_region.Register();
}
}
public override void OnLocationChange(Point3D oldLoc)
{
base.OnLocationChange(oldLoc);
UpdateRegion();
}
public override void OnMapChange()
{
base.OnMapChange();
UpdateRegion();
}
public override void OnAfterDelete()
{
base.OnAfterDelete();
UpdateRegion(); // Unregisters because Deleted == true
}
[AfterDeserialization(false)]
private void AfterDeserialization()
{
Timer.StartTimer(TimeSpan.Zero, UpdateRegion); // Defer to next tick
}
}
public class MyItemRegion : BaseRegion
{
public MySpecialItem Item { get; }
public MyItemRegion(MySpecialItem item)
: base(null, item.Map,
Region.Find(item.Location, item.Map), // Parent = existing region
new Rectangle2D(item.X - 5, item.Y - 5, 11, 11))
{
Item = item;
}
public override void OnEnter(Mobile m)
{
base.OnEnter(m);
// Custom enter logic
}
}
```
### Child Region Inside a Dungeon
When you want effects that don't break dungeon rules (inherits lighting, no housing, etc.):
```csharp
public class BossArenaRegion : BaseRegion
{
public BossArenaRegion(Item source)
: base(null, source.Map,
Region.Find(source.Location, source.Map), // Parent = dungeon
new Rectangle2D(source.X - 10, source.Y - 10, 21, 21))
{
}
// Inherits DungeonRegion behavior from parent automatically
// Only add what's different:
public override bool CheckTravel(
Mobile m, Point3D newLocation, TravelCheckType travelType, out TextDefinition message)
{
message = null;
return m.AccessLevel > AccessLevel.Player; // No escape during boss fight
}
public override void SpellDamageScalar(Mobile caster, Mobile target, ref double damage)
{
base.SpellDamageScalar(caster, target, ref damage);
damage *= 1.25; // Bonus damage in arena
}
}
```
### Blocking Specific Spells
```csharp
public override bool OnBeginSpellCast(Mobile m, ISpell s)
{
if (m.AccessLevel == AccessLevel.Player && s is MarkSpell)
{
m.SendLocalizedMessage(501802); // Thy spell doth not appear to work...
return false;
}
return base.OnBeginSpellCast(m, s);
}
```
## Region Hierarchy & IsPartOf
Virtual hooks delegate to `Parent` — child regions inherit all parent behavior:
```csharp
// A child of DungeonRegion automatically has:
// - Dungeon lighting
// - No housing
// - YoungProtected = false
// Check hierarchy:
region.IsPartOf<DungeonRegion>() // true if this or any ancestor is DungeonRegion
region.GetRegion<GuardedRegion>() // returns first GuardedRegion in ancestor chain
```
## Existing Region Types Reference
| Type | Key Behavior |
|---|---|
| `BaseRegion` | CheckTravel, spawn weights, RuneName |
| `GuardedRegion` | NPC guards, no housing, town spell restrictions |
| `TownRegion` | Guard behavior + Entrance property |
| `DungeonRegion` | Dungeon lighting, no housing, young not protected |
| `NoTravelSpellsAllowedRegion` | Blocks all travel spells (extends DungeonRegion) |
| `NoHousingRegion` | Blocks housing placement |
| `NoHousingGuardedRegion` | Guards + housing block |
| `GreenAcresRegion` | No housing, no travel, no Mark |
| `JailRegion` | Full lockdown: no skills, spells, combat, travel |
| `HouseRegion` | Dynamic per-house; access, bans, lockdowns |
| `MondainRegion` | Mondain's Legacy dungeon (no travel) |
## Anti-Patterns
- **Registering regions in deserialization constructors**: Map/parents may not be loaded — use `[AfterDeserialization]` with `Timer.StartTimer(TimeSpan.Zero, ...)`
- **Forgetting `Unregister()` on item delete**: Ghost region stays on map — always unregister in `OnAfterDelete()`
- **Not using parent for dynamic regions**: Loses inherited behavior (lighting, guards, etc.) — pass `Region.Find(location, map)` as parent
- **Using `Region.Find(string, Map)` in hot paths**: Linear O(n) scan — use `Region.Find(Point3D, Map)` (sector-indexed)
- **Overriding a hook without calling `base`**: Breaks parent delegation chain — always call `base.Method()` unless intentionally blocking
## See Also
- `dev-docs/regions.md` — Complete region system documentation
- `dev-docs/timers.md` — Timer system (for deferred registration)
- `dev-docs/claude-skills/modernuo-content-patterns.md` — Item/Mobile lifecycle (OnAfterDelete, AfterDeserialization)
- `dev-docs/claude-skills/modernuo-serialization.md` — Serialization and AfterDeserialization hooks

View file

@ -0,0 +1,253 @@
---
name: modernuo-serialization
description: >
Trigger when creating or modifying classes inheriting Item, Mobile, BaseCreature, or any type with [SerializationGenerator]. When adding serialized fields. When discussing migration or version bumps.
---
# ModernUO Serialization System
## When This Activates
- Creating/modifying classes that inherit `Item`, `Mobile`, `BaseCreature`, or any serializable type
- Adding `[SerializableField]` or `[SerializableProperty]` attributes
- Bumping serialization versions
- Working with migration schemas
- Discussing save/load behavior
## Key Rules
1. **Always use `partial` class** when applying `[SerializationGenerator]`
2. **Always add `[Constructible]`** on parameterless constructors for Items/Mobiles
3. **Never serialize `TimerExecutionToken`** -- restore timers in `[AfterDeserialization]`
4. **Call `this.MarkDirty()`** in custom property setters that modify serialized state
5. **Use `using ModernUO.Serialization;`** for serialization attributes
6. **Field order matters** -- `[SerializableField(N)]` index determines serialization order
7. **Increment version** when adding, removing, or reordering fields
## Core Attributes
### [SerializationGenerator(version, encodedVersion)]
Applied to class. Generates Serialize/Deserialize methods.
- `version`: Current serialization version (0+)
- `encodedVersion`: Use `false` for Items/Mobiles (default `true` for other types)
```csharp
[SerializationGenerator(0, false)]
public partial class MyItem : Item { }
```
### [SerializableField(index, setter, saveIf)]
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
```csharp
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
// Generates: public int Charges { get; set; }
```
### [SerializableProperty(index, useField)]
Applied to properties with custom get/set logic.
- `index`: Serialization order
- `useField`: Backing field name if auto-detection fails
```csharp
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int MaxItems
{
get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
set
{
_maxItems = value;
InvalidateProperties();
this.MarkDirty();
}
}
```
### [InvalidateProperties]
On serialized fields -- auto-calls `InvalidateProperties()` when field changes (refreshes client tooltip).
```csharp
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _balanced;
```
### [SerializedCommandProperty(accessLevel)]
Exposes field to `[Props` gump for in-game editing.
### [EncodedInt]
Variable-length int encoding (saves space for small values).
### [DeltaDateTime]
Stores DateTime as offset from current time (handles server restarts).
### [InternString]
Interns strings to reduce memory for repeated values.
### [Tidy]
Auto-removes null/deleted entries from collections after deserialization.
### [CanBeNull]
Marks field as nullable during deserialization.
### [AfterDeserialization]
Method called after all fields are deserialized. Use for initialization, timer restoration, relationship setup.
```csharp
[AfterDeserialization]
private void AfterDeserialization()
{
Timer.StartTimer(TimeSpan.FromSeconds(5), CheckExpiry, out _timerToken);
}
```
### [DeserializeTimerField(fieldIndex)]
Custom timer deserialization. Timer is saved as remaining TimeSpan.
```csharp
[SerializableField(0, setter: "private")]
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.
```csharp
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeMaxItems() => _maxItems != -1;
[SerializableFieldDefault(0)]
private int MaxItemsDefaultValue() => -1;
```
### [TypeAlias(aliases)]
Maps old type names for backward-compatible deserialization.
```csharp
[TypeAlias("Server.Mobiles.Bear")]
[SerializationGenerator(0, false)]
public partial class BlackBear : BaseCreature { }
```
## Patterns
### Minimal Item (Version 0, No Custom Fields)
```csharp
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0, false)]
public partial class MyItem : Item
{
[Constructible]
public MyItem() : base(0x1234)
{
Weight = 1.0;
}
public override string DefaultName => "a my item";
}
```
### Item with Fields
```csharp
[SerializationGenerator(0, false)]
public partial class ChargedItem : Item
{
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
[SerializableField(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private Mobile _owner;
private TimerExecutionToken _timerToken; // NOT serialized
[Constructible]
public ChargedItem() : base(0x1234) => _charges = 10;
[AfterDeserialization]
private void AfterDeserialization()
{
Timer.StartTimer(TimeSpan.FromSeconds(5), CheckExpiry, out _timerToken);
}
public override void OnAfterDelete()
{
_timerToken.Cancel();
base.OnAfterDelete();
}
}
```
### Item with Custom Properties
```csharp
[SerializationGenerator(2, false)]
public partial class BagOfSending : Item
{
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public BagOfSendingHue BagOfSendingHue
{
get => _bagOfSendingHue;
set
{
_bagOfSendingHue = value;
Hue = value switch
{
BagOfSendingHue.Yellow => 0x8A5,
BagOfSendingHue.Blue => 0x8AD,
BagOfSendingHue.Red => 0x89B,
_ => Hue
};
this.MarkDirty();
}
}
}
```
## Anti-Patterns
- **Missing `partial`**: `[SerializationGenerator]` requires `partial class`
- **Serializing timers**: `TimerExecutionToken` cannot be serialized
- **Missing `MarkDirty()`**: Custom property setters must call `this.MarkDirty()`
- **Wrong field prefix**: Use `_camelCase`, not `m_camelCase` for new fields
- **Forgetting `[Constructible]`**: Items/Mobiles need this for `[add` command
## 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`
- Complex with AfterDeserialization: `Projects/UOContent/Accounting/Account.cs`
- Timer deserialization: `Projects/UOContent/Items/Aquarium/Aquarium.cs`
- Tidy + DeltaDateTime: `Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs`
- Conditional serialization: `Projects/Server/Items/Container.cs`
## Version Migration
Migration schemas are JSON files in `Projects/Server/Migrations/` and `Projects/UOContent/Migrations/`:
- Format: `TypeName.vN.json`
- Generated automatically by the serialization generator
- Used for reading old save formats
External reference: https://github.com/modernuo/SerializationGenerator
## See Also
- `dev-docs/serialization.md` - Complete serialization documentation
- `dev-docs/claude-skills/modernuo-timers.md` - Timer token patterns
- `dev-docs/claude-skills/modernuo-content-patterns.md` - Item/Mobile templates
- `dev-docs/claude-skills/modernuo-property-lists.md` - [InvalidateProperties] usage

View file

@ -0,0 +1,162 @@
---
name: modernuo-threading
description: >
Trigger when discussing async patterns, world saves, game loop, or reviewing code for threading issues. When using await, Task, or any concurrency-related code in game logic.
---
# ModernUO Threading & Event Loop
## When This Activates
- Reviewing code for threading issues
- Discussing async/await patterns
- Working with world saves
- Any mention of `Task.Run`, `Thread`, `lock`, `ConcurrentDictionary`
- Understanding the game loop
## CRITICAL RULE: Single-Threaded Game Logic
ModernUO uses a **single-threaded game loop**. All game logic runs on one thread. There are NO exceptions for game code.
## Forbidden in Game Code
```csharp
// ALL of these are WRONG in Projects/UOContent/ code:
Task.Run(() => ProcessItems()); // Background thread
new Thread(BackgroundWork).Start(); // Manual thread
ThreadPool.QueueUserWorkItem(Work); // Thread pool
lock (_syncObj) { ... } // Locking
Monitor.Enter(obj); // Monitor
volatile int _counter; // Volatile
ConcurrentDictionary<int, Item> _items; // Concurrent collections
ConcurrentQueue<T> _queue; // Concurrent collections
Interlocked.Increment(ref _count); // Atomics
Mutex mutex; // OS mutex
Semaphore sem; // Semaphore
ReaderWriterLockSlim rwl; // RW lock
```
**Why**: The game loop is single-threaded. Concurrency primitives add overhead for no benefit, and background threads would cause data races with game state.
## Why await Is Safe
`EventLoopContext` implements `SynchronizationContext` and routes all `await` continuations back to the main thread:
```csharp
// This is SAFE in game code:
await Timer.Pause(TimeSpan.FromMilliseconds(100));
// Continuation runs on the game thread, not a thread pool thread
```
The flow:
1. `await` captures `EventLoopContext` as the synchronization context
2. When the awaited task completes, the continuation is posted to `EventLoopContext._queue`
3. `LoopContext.ExecuteTasks()` runs those continuations on the main thread during the next game loop tick
## Game Loop Structure
```csharp
// Simplified from Projects/Server/Main.cs
while (!Closing)
{
_tickCount = GetTimestamp();
_now = DateTime.UtcNow;
Mobile.ProcessDeltaQueue(); // Send mobile state changes to clients
Item.ProcessDeltaQueue(); // Send item state changes to clients
Timer.Slice(_tickCount); // Execute due timers
NetState.Slice(); // Process network I/O
LoopContext.ExecuteTasks(); // Run async continuations (Timer.Pause, etc.)
Timer.CheckTimerPool(); // Refill timer pool if needed
}
```
## EventLoopContext Details
```csharp
public sealed class EventLoopContext : SynchronizationContext
{
private readonly ConcurrentQueue<Action> _queue; // Normal tasks
private readonly ConcurrentQueue<Action> _priorityQueue; // High priority
private readonly int _maxPerFrame; // Default: 128
// Posts run on next ExecuteTasks() call
public void Post(Action d, Priority priority = Priority.Normal);
// Send blocks if called from another thread, immediate if on game thread
public override void Send(SendOrPostCallback d, object state);
// Called once per game loop tick
public void ExecuteTasks();
}
```
## Memory: STArrayPool vs ArrayPool
In game code, use `STArrayPool<T>.Shared` (single-threaded, no locks):
```csharp
// GOOD - no locking overhead
var buffer = STArrayPool<byte>.Shared.Rent(1024);
try { /* use buffer */ }
finally { STArrayPool<byte>.Shared.Return(buffer); }
```
`ArrayPool<T>.Shared` uses locks for thread safety -- unnecessary overhead in single-threaded context.
## Memory: PooledRefList
```csharp
// Stack-allocated list using pooled arrays
using var list = PooledRefList<Mobile>.Create();
list.Add(mobile);
// Automatically returns array to pool on Dispose
// For multi-threaded contexts (rare):
using var list = PooledRefList<Mobile>.CreateMT();
```
## World Saves
World saves DO involve background work, but this is handled by server infrastructure:
1. **Serialization** happens on the main thread (safe access to game state)
2. **Disk I/O** may happen on background threads (no game state access)
3. Main thread blocks briefly during serialization snapshot
```csharp
// From World.cs -- save flow:
World.Save();
→ Preserialize() on thread pool (allocate heaps)
→ Snapshot() on main thread (serialize game state)
→ WriteFiles() on thread pool (disk I/O only)
```
## Exceptions: Server Infrastructure
These files MAY use threading (they're server infrastructure, not game logic):
- `Projects/Server/Main.cs` - Event loop, thread setup
- `Projects/Server/World/World.cs` - World save I/O
- `Projects/Server/Network/` - Network I/O
- `Projects/Server/Timer/Timer.Pool.cs` - Pool refill
## Anti-Patterns
| Pattern | Problem | Solution |
|---|---|---|
| `Task.Run(...)` | Runs on thread pool, races with game state | Use `Timer.StartTimer()` |
| `new Thread(...)` | Same as above | Use `Timer.StartTimer()` |
| `lock(obj)` | Unnecessary overhead, no contention exists | Remove lock, use plain code |
| `ConcurrentDictionary` | Lock-free but still overhead | Use `Dictionary<K,V>` |
| `volatile` | Memory barriers not needed on single thread | Use plain field |
| `Thread.Sleep()` | Blocks entire game loop | Use `await Timer.Pause()` |
| `ArrayPool<T>.Shared` | Uses locks | Use `STArrayPool<T>.Shared` |
## Real Examples
- Game loop: `Projects/Server/Main.cs` (RunEventLoop)
- EventLoopContext: `Projects/Server/EventLoopTasks.cs`
- STArrayPool: `Projects/Server/Buffers/STArrayPool.cs`
- PooledRefList: `Projects/Server/Collections/PooledRefList.cs`
- World save: `Projects/Server/World/World.cs`
## See Also
- `dev-docs/threading-model.md` - Complete threading documentation
- `dev-docs/claude-skills/modernuo-code-audit.md` - Threading audit rules
- `dev-docs/claude-skills/modernuo-timers.md` - Timer-based scheduling

View file

@ -0,0 +1,219 @@
---
name: modernuo-timers
description: >
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
1. **Prefer `Timer.StartTimer` with token** for cancellable timers
2. **Prefer `Timer.StartTimer` without token** for fire-and-forget
3. **Never serialize `TimerExecutionToken`** -- restore in `[AfterDeserialization]`
4. **Always cancel timers in `OnDelete()`/`OnAfterDelete()`**
5. **8ms minimum precision** -- timer wheel uses 8ms tick rate
## Preferred APIs (In Order)
### 1. Timer.StartTimer with Token (Cancellable Fire-and-Forget)
```csharp
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)
```csharp
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)
```csharp
var timer = Timer.DelayCall(TimeSpan.FromSeconds(5), DoSomething);
timer.Stop(); // Cancel later if needed
```
### 4. Timer.DelayCall with State (Avoids Closures)
```csharp
// 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)
```csharp
await Timer.Pause(TimeSpan.FromMilliseconds(100));
await Timer.Pause(500); // 500ms overload
```
## TimerExecutionToken Properties
```csharp
_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
```csharp
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
```csharp
[SerializationGenerator(0, false)]
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();
}
}
```
### [DeserializeTimerField] Pattern (for Timer fields)
```csharp
[SerializableField(0, setter: "private")]
private Timer _evaluateTimer;
[DeserializeTimerField(0)]
private void DeserializeEvaluateTimer(TimeSpan delay)
{
_evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate);
}
```
### Custom Timer Class (When You Need Complex Logic)
```csharp
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. Use `Timer.StartTimer` or `await Timer.Pause` instead
- **Creating timers in constructors called during deserialization**: Use `[AfterDeserialization]` instead
## 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 class
- `Projects/Server/Timer/Timer.DelayCall.cs` - DelayCall + StartTimer
- `Projects/Server/Timer/Timer.TimerWheel.cs` - Scheduler
- `Projects/Server/Timer/Timer.Pool.cs` - Pool management
- `Projects/Server/Timer/Timer.DelayStateCall.cs` - Generic state timers
- `Projects/Server/Timer/TimerExecutionToken.cs` - Token struct
## See Also
- `dev-docs/timers.md` - Complete timer documentation
- `dev-docs/event-scheduler.md` - Wall-clock/calendar scheduling (EventScheduler) — use for daily resets, weekly events, holiday seasons instead of Timer
- `dev-docs/claude-skills/modernuo-event-scheduler.md` - EventScheduler skill for calendar-based events
- `dev-docs/claude-skills/modernuo-serialization.md` - Timer fields not serialized
- `dev-docs/claude-skills/modernuo-content-patterns.md` - Deletion patterns
- `dev-docs/claude-skills/modernuo-threading.md` - Single-threaded model

579
dev-docs/code-standards.md Normal file
View file

@ -0,0 +1,579 @@
# ModernUO Coding Standards
This document defines the coding conventions and standards for ModernUO content development. All code under `Projects/UOContent/` and `Projects/Server/` must follow these guidelines.
## Table of Contents
1. [Naming Conventions](#naming-conventions)
2. [Performance Rules](#performance-rules)
3. [Serialization Requirements](#serialization-requirements)
4. [Logging](#logging)
5. [Threading Model](#threading-model)
6. [Memory Management](#memory-management)
7. [Entity Lifecycle](#entity-lifecycle)
8. [Era-Conditional Code](#era-conditional-code)
9. [File Organization](#file-organization)
---
## Naming Conventions
### Fields and Properties
- **Private fields**: `_camelCase` prefix with underscore
```csharp
private int _charges;
private Mobile _owner;
private TimerExecutionToken _timerToken;
```
- **Properties**: `PascalCase`
```csharp
public int Charges { get; set; }
public Mobile Owner => _owner;
```
- **Methods**: `PascalCase`
```csharp
public void OnDoubleClick(Mobile from) { }
private void CheckExpiry() { }
```
- **Constants**: `PascalCase`
```csharp
public const int MaxCharges = 20;
```
- **Local variables**: `camelCase`
```csharp
var damage = Utility.RandomMinMax(10, 20);
```
### Legacy Code
Older code uses `m_` prefix for private fields (e.g., `m_Amount`). Do not change existing `m_` fields, but always use `_` prefix for new code.
### Access Levels
```csharp
public enum AccessLevel
{
Player, // Regular players
Counselor, // Support staff
GameMaster, // GMs
Seer, // Event coordinators
Administrator,// Server admins
Developer, // Developers
Owner // Server owner
}
```
Reference: `Projects/Server/Mobiles/Mobile.cs`
---
## Performance Rules
### LINQ: Know What's Optimized (.NET 10)
.NET 10's JIT and Dynamic PGO can now eliminate abstraction overhead for specific LINQ patterns. Not all LINQ is banned — but most still is. This section defines exactly what's allowed and what isn't.
**Prerequisites**: All optimizations below require .NET 10 with tiered compilation and Dynamic PGO enabled (both on by default — don't disable them). Tier 1 optimizations need ~30+ calls for the JIT to recompile at Tier 1 with PGO data.
#### Tier 1 — Zero-Cost Abstractions (use freely on hot paths)
These patterns produce **zero heap allocations** after JIT warmup, performing as well as hand-written code.
**`foreach` over `IEnumerable<T>` backed by known collection types:**
PGO profiles the concrete type. Guarded devirtualization (GDV) emits a specialized path. The enumerator is devirtualized, inlined, and stack-allocated.
Optimized backing types: `T[]`, `List<T>`, `Stack<T>`, `Queue<T>`, `ConcurrentDictionary<TKey,TValue>`, `PriorityQueue<TElement,TPriority>`.
```csharp
// ✅ ALLOWED — JIT eliminates all abstraction overhead
int Sum(IEnumerable<int> values) // caller passes int[] or List<int>
{
int sum = 0;
foreach (int v in values) sum += v;
return sum;
}
```
Falls back to normal virtual dispatch + heap-allocated enumerator for unknown/uncommon collection types or before JIT warmup.
**`.Contains()` after a preceding LINQ operator:**
LINQ has ~30 specialized `Contains` overrides that bypass intermediate processing entirely. No sort, no HashSet, no buffering — the source is searched directly.
| Preceding operator | What `.Contains()` does | Speedup vs .NET 9 |
|---|---|---|
| `.Distinct()` | Searches source directly — no HashSet built | ~363x |
| `.Union(other)` | Searches both sources — no HashSet | ~302x |
| `.OrderBy()` / `.OrderByDescending()` | Searches source directly — no sort | ~258x |
| `.ThenBy()` / `.ThenByDescending()` | Same — no sort | ~258x |
| `.Append()` / `.Prepend()` / `.Concat()` | Searches sequentially | ~56x |
| `.SelectMany(f)` | Searches each sub-source | ~49x |
| `.Reverse()` | Searches source directly — no buffering | ~9x |
| `.Where(p).Select(f)` | Applies predicate+projection inline | ~7x |
| `.Select(f)` | Applies projection inline | moderate |
| `.Skip(n)` / `.Take(n)` | Searches within bounds | moderate |
| `.OfType<T>()` / `.Cast<T>()` | Filters/casts and searches | moderate |
| `.Intersect(other)` / `.Except(other)` | Searches appropriately | large |
| `.Shuffle()` | Searches source directly — no shuffle | large |
| `.Shuffle().Take(n)` | Hypergeometric probability — near O(1) math | massive |
```csharp
// ✅ ALLOWED — no sort performed, source searched directly
bool exists = source.OrderBy(x => x.Name).Contains(target);
// ✅ ALLOWED — no HashSet built
bool exists = source.Distinct().Contains(target);
// ✅ ALLOWED — no buffering/reversing
bool exists = source.Reverse().Contains(target);
```
Falls back to normal enumeration for custom `IEnumerable<T>` implementations that LINQ doesn't recognize.
**`.Count()` on sized collections:**
Returns `.Count` property directly when source implements `ICollection<T>` or is a known LINQ iterator with tracked count (after `Range`, `Repeat`, `Skip`, `Take`, `Append`, etc.). O(1), no enumeration.
```csharp
// ✅ ALLOWED — O(1) property access
int count = myList.Count();
int count = Enumerable.Range(0, 1000).Skip(10).Take(50).Count();
```
**`.OrderBy().First()` / `.OrderByDescending().First()` / `.OrderBy().Last()`:**
LINQ performs O(N) min/max scan instead of O(N log N) sort. No sort buffer allocated.
```csharp
// ✅ ALLOWED — O(N) scan, no sort
var cheapest = products.OrderBy(p => p.Price).First();
var newest = events.OrderByDescending(e => e.Timestamp).First();
```
**`.Shuffle().Take(n)`:**
Uses reservoir sampling — single pass over source, O(n) memory. Does NOT shuffle the entire collection.
```csharp
// ✅ ALLOWED — reservoir sampling, not full shuffle
var sample = population.Shuffle().Take(10).ToArray();
```
**`Enumerable.Range()` / `Enumerable.Sequence()` terminal operations:**
When followed by `.Count()`, `.Contains()`, `.ToArray()`, `.ToList()`, `.Skip()`, `.Take()`, `.ElementAt()`, `.Last()`. Specialized iterators compute results from arithmetic, not enumeration.
```csharp
// ✅ ALLOWED — range check, no enumeration
bool has = Enumerable.Range(0, 1000).Contains(500);
// ✅ ALLOWED — single allocation, span fill
int[] arr = Enumerable.Range(0, 100).ToArray();
```
#### Tier 2 — Low Overhead (acceptable on warm paths, benchmark if critical)
These patterns have some overhead but are significantly optimized in .NET 10.
**`.Skip(n).Take(m).ToArray()` / `.ToList()` on `T[]` or `List<T>`:**
Uses vectorized `Span<T>.CopyTo` (~5x faster than .NET 9). Still allocates the output array/list.
```csharp
// ✅ Acceptable on warm paths — vectorized copy
var page = items.Skip(offset).Take(pageSize).ToArray();
```
**`.LeftJoin()` / `.RightJoin()` (new in .NET 10):**
~2x faster and ~2x less memory than the manual `GroupJoin`+`SelectMany`+`DefaultIfEmpty` pattern.
```csharp
// ✅ Prefer over manual GroupJoin chain
var results = orders.LeftJoin(customers, o => o.CustomerId, c => c.Id,
(order, customer) => new { order, customer });
```
**`.Where(predicate)` on `T[]` or `List<T>`:**
The `WhereIterator` still heap-allocates, but enumerating it is cheaper due to PGO. For true hot paths, manual `foreach`+`if` is still faster.
```csharp
// ⚠️ Acceptable but not zero-cost — WhereIterator allocates
foreach (var item in items.Where(x => x.IsActive))
Process(item);
// 🏆 Faster manual alternative for true hot paths:
foreach (var item in items)
if (item.IsActive) Process(item);
```
#### Tier 3 — Still Forbidden on Hot Paths
These patterns still carry meaningful abstraction overhead. Use manual code.
| Pattern | Why it's still slow | Manual alternative |
|---|---|---|
| `.Select(f).Where(p)` (this order) | Each intermediate iterator allocates | `foreach` + `if` + inline transform |
| `.GroupBy(k)` | Builds dictionary internally | Manual dictionary loop |
| `.ToDictionary()` / `.ToHashSet()` | Always allocates the collection | Pre-size and fill manually |
| `.ToLookup()` | Always builds grouping structure | Manual dictionary of lists |
| `.Aggregate(f)` | Delegate overhead per element | Manual accumulator loop |
| `.Sum()` / `.Min()` / `.Max()` on `float`/`double` | No SIMD vectorization in LINQ (ARM) | `TensorPrimitives.Sum()` etc. |
| `.SelectMany(f)` (iterating results, not `.Contains()`) | Multiple enumerator allocations | Nested manual loops |
| `.Zip()` iterating | Enumerator allocations | Dual-index `for` loop |
| Any LINQ over `IAsyncEnumerable<T>` | No PGO/escape analysis for async | Manual `await foreach` |
| Long chains: `.Where().Select().OrderBy().Take()` | Each step allocates an iterator | Manual loop with sort |
```csharp
// ❌ STILL FORBIDDEN — allocates iterator + delegate per step
var targets = nearbyMobiles.Where(m => m.Alive).ToList();
var count = items.Count(i => i.Stackable); // Count with predicate is NOT .Count()
var first = mobiles.FirstOrDefault(m => m is PlayerMobile);
// ✅ CORRECT — zero allocations
using var targets = PooledRefList<Mobile>.Create();
foreach (var m in nearbyMobiles)
{
if (m.Alive)
targets.Add(m);
}
// ✅ CORRECT — manual count
var count = 0;
foreach (var i in items)
{
if (i.Stackable)
count++;
}
```
#### Quick Decision Flowchart
```
Is it .Contains() after another LINQ operator?
YES → ✅ Use it (see Tier 1 table)
Is it foreach over IEnumerable<T> backed by T[]/List<T>/Stack<T>/Queue<T>?
YES → ✅ Use it (zero-alloc with PGO)
Is it .OrderBy().First() or .OrderBy().Last()?
YES → ✅ Use it (O(N) not O(N log N))
Is it .Shuffle().Take(n)?
YES → ✅ Use it (reservoir sampling)
Is it .Count() on a sized collection or Range?
YES → ✅ Use it (O(1))
Is it .Skip().Take().ToArray() on T[]/List<T>?
YES → ✅ Acceptable (vectorized copy)
Is it anything else on a hot path?
→ ❌ Write manual code
```
*Reference: Stephen Toub, "Performance Improvements in .NET 10", September 2025. dotnet/runtime PRs: #112684, #108153, #111473, #116978, #112173, #118425.*
### Array Pooling
Use `STArrayPool<T>.Shared` instead of `ArrayPool<T>.Shared` in game logic. STArrayPool is optimized for single-threaded access (no locks).
```csharp
var buffer = STArrayPool<byte>.Shared.Rent(1024);
try
{
// Use buffer...
}
finally
{
STArrayPool<byte>.Shared.Return(buffer);
}
```
Reference: `Projects/Server/Buffers/STArrayPool.cs`
### PooledRefList
For temporary lists in methods, use `PooledRefList<T>` instead of `new List<T>()`:
```csharp
using var list = PooledRefList<Mobile>.Create();
// list is stack-allocated, uses pooled backing array
list.Add(mobile);
// Automatically returns array to pool on Dispose
```
Reference: `Projects/Server/Collections/PooledRefList.cs`
### Spatial Queries
Never iterate `World.Mobiles` or `World.Items` directly. Use map-based spatial queries:
```csharp
// BAD - O(n) over ALL mobiles in the world
foreach (var m in World.Mobiles.Values)
{
if (m.InRange(location, 10))
DoSomething(m);
}
// GOOD - O(1) sector lookup
foreach (var m in map.GetMobilesInRange<Mobile>(location, 10))
{
DoSomething(m);
}
```
Available spatial queries (on `Map`):
- `GetMobilesAt<T>(Point3D p)` - exact location
- `GetMobilesInRange<T>(Point3D p, int range)` - within range
- `GetMobilesInBounds<T>(Rectangle2D bounds)` - within rectangle
- Same patterns for `GetItemsAt`, `GetItemsInRange`, `GetItemsInBounds`
---
## Serialization Requirements
### Partial Classes
Any class with `[SerializationGenerator]` **must** be declared `partial`:
```csharp
[SerializationGenerator(0, false)]
public partial class MyItem : Item // MUST be partial
{
}
```
### Constructible Attribute
Items and Mobiles must have `[Constructible]` on their parameterless constructor:
```csharp
[Constructible]
public MyItem() : base(0x1234)
{
}
```
### Timer Fields Are Not Serialized
`TimerExecutionToken` fields must NOT have `[SerializableField]`:
```csharp
// CORRECT
private TimerExecutionToken _timerToken; // No serialization attribute
// WRONG
[SerializableField(2)]
private TimerExecutionToken _timerToken; // Will cause errors
```
Timers are restored in `[AfterDeserialization]` methods.
See: `dev-docs/serialization.md` for complete serialization guide.
---
## Logging
Use structured logging via `ILogger`, never `Console.WriteLine`.
### Setup
```csharp
using Server.Logging;
public class MySystem
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(MySystem));
}
```
### Usage
```csharp
logger.Debug("Processing {Count} items for {Player}", items.Count, player.Name);
logger.Information("Player {Name} logged in from {IP}", name, ip);
logger.Warning("Unexpected state in {System}: {Details}", "Combat", details);
logger.Error(exception, "Failed to process {Action}", action);
logger.Fatal(exception, "Unrecoverable error in {System}", system);
```
### Levels
- `Debug` - Detailed diagnostic information
- `Information` - General operational events
- `Warning` - Unexpected but recoverable situations
- `Error` - Failures that affect specific operations
- `Fatal` - Unrecoverable errors
Reference: `Projects/Logger/ILogger.cs`
---
## Threading Model
ModernUO uses a **single-threaded game loop**. All game logic runs on one thread.
### Forbidden in Game Code
```csharp
// ALL of these are WRONG in game code:
Task.Run(() => ProcessItems());
new Thread(BackgroundWork).Start();
ThreadPool.QueueUserWorkItem(Work);
lock (_syncObj) { }
volatile int _counter;
ConcurrentDictionary<int, Item> _items;
```
### Why It Works
- `EventLoopContext` (SynchronizationContext) routes all `await` continuations to the main thread
- `await` is safe because it always resumes on the game thread
- No data races possible in single-threaded code
### Exceptions
Only server infrastructure code may use threading:
- `Projects/Server/Main.cs` - Event loop setup
- World save disk I/O (serialization on main thread, writes may be background)
- Network I/O
See: `dev-docs/threading-model.md` for complete threading documentation.
---
## Memory Management
### Array Returns
Always return pooled arrays:
```csharp
var arr = STArrayPool<int>.Shared.Rent(size);
try
{
// Use arr
}
finally
{
STArrayPool<int>.Shared.Return(arr);
}
```
### Avoid Allocations in Hot Paths
- Use `PooledRefList<T>` instead of `new List<T>()`
- Use `stackalloc` for small fixed-size buffers
- Use `STArrayPool<T>` for larger buffers
- Avoid string concatenation in loops (use `StringBuilder` or string interpolation in `IPropertyList`)
---
## Entity Lifecycle
### Two-Phase Deletion
Items and Mobiles use two deletion hooks:
1. **`OnDelete()`** - Called first. Cancel timers, remove from tracking systems.
```csharp
public override void OnDelete()
{
_timerToken.Cancel();
base.OnDelete();
}
```
2. **`OnAfterDelete()`** - Called after entity is removed from world. Clean up references.
```csharp
public override void OnAfterDelete()
{
_timer?.Stop();
_timer = null;
_owner = null;
base.OnAfterDelete();
}
```
### Reference Cleanup
Any field holding an `Item` or `Mobile` reference should be nulled in deletion:
```csharp
public override void OnAfterDelete()
{
_target = null;
_owner = null;
base.OnAfterDelete();
}
```
---
## Era-Conditional Code
### Always Ask for Target Era
If the user hasn't specified which expansion to target, **always ask**. Different eras have dramatically different mechanics.
### Pattern
```csharp
if (Core.AOS) // Age of Shadows or later
{
damage = GetNewAosDamage(10, 1, 4, target);
}
else // Pre-AOS
{
damage = Utility.Random(4, 4);
}
```
### Available Checks
```csharp
Core.T2A // >= The Second Age
Core.UOR // >= Renaissance
Core.UOTD // >= Third Dawn
Core.LBR // >= Blackthorn's Revenge
Core.AOS // >= Age of Shadows
Core.SE // >= Samurai Empire
Core.ML // >= Mondain's Legacy
Core.SA // >= Stygian Abyss
Core.HS // >= High Seas
Core.TOL // >= Time of Legends
Core.EJ // >= Endless Journey
```
See: `dev-docs/era-expansion.md` for complete expansion guide.
---
## File Organization
### Directory Structure
```
Projects/UOContent/
├── Items/
│ ├── Weapons/ # BaseWeapon, Swords/, Maces/, etc.
│ ├── Armor/ # BaseArmor, Plate/, Chain/, etc.
│ ├── Clothing/ # Shirts, hats, etc.
│ ├── Misc/ # General items
│ └── Special/ # Unique/quest items
├── Mobiles/
│ ├── Animals/ # Bears/, Birds/, etc.
│ ├── Monsters/ # AOS/, SE/, ML/ by era
│ ├── Special/ # Champions, bosses
│ └── Vendors/ # NPC vendors
├── Spells/
│ ├── Base/ # Spell base classes
│ ├── First/ - Eighth/ # Magery circles
│ ├── Necromancy/ # Necro spells
│ └── Spellweaving/ # Spellweaving
├── Skills/ # Skill implementations
├── Gumps/ # UI dialogs
│ └── Base/ # Gump base classes
└── Engines/ # Complex systems
```
### File Naming
- One class per file (generally)
- File name matches class name
- Group related items in subdirectories
---
## Quick Reference: Common Anti-Patterns
| Anti-Pattern | Correct Pattern |
|---|---|
| `list.Where(x => x.Alive)` | `foreach` + `if` (Tier 2 — acceptable on warm paths) |
| `.GroupBy()` / `.ToDictionary()` / `.ToHashSet()` | Manual dictionary loop (Tier 3 — still forbidden) |
| `.Select(f).Where(p)` chain | `foreach` + `if` + inline transform (Tier 3) |
| `.OrderBy().First()` | ✅ Allowed — O(N) scan, no sort (Tier 1) |
| `.Distinct().Contains()` | ✅ Allowed — no HashSet built (Tier 1) |
| `Console.WriteLine(msg)` | `logger.Information(msg)` |
| `new List<T>()` in hot path | `PooledRefList<T>.Create()` |
| `ArrayPool<T>.Shared` | `STArrayPool<T>.Shared` |
| `ConcurrentDictionary` | `Dictionary` |
| `Task.Run(...)` | Don't. Use timers. |
| `World.Mobiles.Values` iteration | `map.GetMobilesInRange<T>()` |
| Missing `partial` on serialized class | Add `partial` keyword |
| Serializing `TimerExecutionToken` | Leave unserialized, restore in `[AfterDeserialization]` |

View file

@ -0,0 +1,341 @@
# ModernUO Commands & Targeting
This document covers ModernUO's command system for in-game `[` commands and the targeting system for player interactions.
## Command System
### Overview
Commands are prefixed with `[` by default (e.g., `[MyCommand`). They are registered in static `Configure()` methods and associated with an access level.
### Registration
```csharp
using Server.Commands;
namespace Server.Custom;
public static class MyCommands
{
public static void Configure()
{
CommandSystem.Register("MyCommand", AccessLevel.GameMaster, MyCommand_OnCommand);
}
[Usage("MyCommand <name> [count]")]
[Description("Does something with a name and optional count")]
[Aliases("mc", "mycmd")]
public static void MyCommand_OnCommand(CommandEventArgs e)
{
var from = e.Mobile;
if (e.Length < 1)
{
from.SendMessage("Usage: [MyCommand <name> [count]");
return;
}
var name = e.GetString(0);
var count = e.Length > 1 ? e.GetInt32(1) : 1;
from.SendMessage($"Processing {name} x{count}");
}
}
```
### CommandSystem API
```csharp
public static class CommandSystem
{
public static string Prefix { get; set; } = "[";
public static Dictionary<string, CommandEntry> Entries { get; }
public static void Register(string command, AccessLevel access, CommandEventHandler handler);
public static bool Handle(Mobile from, string text, MessageType type = MessageType.Regular);
public static string[] Split(string value);
}
```
### CommandEventArgs
```csharp
public class CommandEventArgs
{
public Mobile Mobile { get; } // Who issued the command
public string Command { get; } // Command name
public string ArgString { get; } // Raw argument string
public string[] Arguments { get; } // Split arguments
public int Length { get; } // Argument count
// Typed accessors (return default if index out of range)
public string GetString(int index); // "" if missing
public int GetInt32(int index); // 0 if missing
public uint GetUInt32(int index); // 0 if missing
public bool GetBoolean(int index); // false if missing
public double GetDouble(int index); // 0.0 if missing
public TimeSpan GetTimeSpan(int index); // TimeSpan.Zero if missing
}
```
### Access Levels
```csharp
public enum AccessLevel
{
Player, // 0 - Regular players
Counselor, // 1 - Support staff
GameMaster, // 2 - Game Masters
Seer, // 3 - Event coordinators
Administrator, // 4 - Server administrators
Developer, // 5 - Developers
Owner // 6 - Server owner
}
```
### Command Attributes
```csharp
[Usage("CommandName <required> [optional]")]
// Documents command syntax. Displayed in help listings.
[Description("What this command does")]
// Documents command purpose. Displayed in help listings.
[Aliases("alias1", "alias2")]
// Alternative names for the command.
```
Defined in `Projects/Server/Attributes.cs`.
### Command + Targeting Pattern
A common pattern: command starts targeting, target handler performs the action.
```csharp
public static class HealCommand
{
public static void Configure()
{
CommandSystem.Register("Heal", AccessLevel.GameMaster, Heal_OnCommand);
}
[Usage("Heal")]
[Description("Fully heals a targeted mobile")]
public static void Heal_OnCommand(CommandEventArgs e)
{
e.Mobile.SendMessage("Select a mobile to heal.");
e.Mobile.Target = new HealTarget();
}
private class HealTarget : Target
{
public HealTarget() : base(-1, false, TargetFlags.Beneficial) { }
protected override void OnTarget(Mobile from, object targeted)
{
if (targeted is Mobile m)
{
m.Hits = m.HitsMax;
m.Mana = m.ManaMax;
m.Stam = m.StamMax;
m.Poison = null;
from.SendMessage($"You have healed {m.Name}.");
}
else
{
from.SendMessage("That is not a mobile.");
}
}
}
}
```
---
## Targeting System
### Overview
The targeting system allows players to select objects in the game world. When a target is set on a mobile, the client shows a targeting cursor. The player clicks on something, and the server processes the selection.
### Target Base Class
```csharp
public abstract class Target
{
// Constructor
protected Target(
int range, // Max range (-1 for unlimited)
bool allowGround, // Can target ground tiles
TargetFlags flags // None, Harmful, Beneficial
);
// Properties
public int Range { get; set; }
public bool AllowGround { get; set; }
public TargetFlags Flags { get; set; }
public bool CheckLOS { get; set; } // Default: true
public bool DisallowMultis { get; set; } // Default: false
public bool AllowNonlocal { get; set; } // Default: false
public int TargetID { get; }
// Override for main handling
protected virtual void OnTarget(Mobile from, object targeted);
// Override for error handling
protected virtual void OnTargetCancel(Mobile from, TargetCancelType cancelType);
protected virtual void OnTargetFinish(Mobile from);
protected virtual void OnTargetOutOfRange(Mobile from, object targeted);
protected virtual void OnTargetOutOfLOS(Mobile from, object targeted);
protected virtual void OnTargetNotAccessible(Mobile from, object targeted);
protected virtual void OnTargetDeleted(Mobile from, object targeted);
protected virtual void OnTargetUntargetable(Mobile from, object targeted);
protected virtual void OnNonlocalTarget(Mobile from, object targeted);
protected virtual void OnCantSeeTarget(Mobile from, object targeted);
protected virtual void OnTargetInSecureTrade(Mobile from, object targeted);
// Validation overrides
protected virtual bool CanTarget(Mobile from, Mobile mobile, ref Point3D loc, ref Map map);
protected virtual bool CanTarget(Mobile from, Item item, ref Point3D loc, ref Map map);
protected virtual bool CanTarget(Mobile from, LandTarget land, ref Point3D loc, ref Map map);
protected virtual bool CanTarget(Mobile from, StaticTarget st, ref Point3D loc, ref Map map);
// Timeout
public void BeginTimeout(Mobile from, long delay);
public void CancelTimeout();
}
```
### TargetFlags
```csharp
[Flags]
public enum TargetFlags : byte
{
None = 0x00, // Neutral targeting
Harmful = 0x01, // Triggers criminal check, PvP flag
Beneficial = 0x02 // Healing, buffing
}
```
### TargetCancelType
```csharp
public enum TargetCancelType
{
Overridden, // New target replaced this one
Canceled, // Player pressed Escape
Disconnected, // Player disconnected
Timeout // Target timed out
}
```
### Target Object Types
When `OnTarget` is called, the `targeted` parameter can be:
| Type | Description | Key Properties |
|---|---|---|
| `Mobile` | A player or creature | `.Name`, `.Hits`, `.Location` |
| `Item` | An item | `.Name`, `.ItemID`, `.Location` |
| `LandTarget` | Ground tile | `.Location`, `.TileID`, `.Name` |
| `StaticTarget` | Static map object | `.Location`, `.ItemID`, `.Hue` |
### Setting a Target
```csharp
// Set target on mobile (shows targeting cursor)
mobile.Target = new MyTarget();
// Cancel current target
Target.Cancel(mobile);
```
### Basic Target Implementation
```csharp
private class IdentifyTarget : Target
{
public IdentifyTarget() : base(12, false, TargetFlags.None)
{
// CheckLOS = false; // Uncomment to skip line-of-sight
}
protected override void OnTarget(Mobile from, object targeted)
{
switch (targeted)
{
case Mobile m:
from.SendMessage($"Mobile: {m.Name}, Hits: {m.Hits}/{m.HitsMax}");
break;
case Item item:
from.SendMessage($"Item: {item.Name ?? item.DefaultName}, ID: 0x{item.ItemID:X}");
break;
case LandTarget land:
from.SendMessage($"Land at {land.Location}, Tile: {land.TileID}");
break;
case StaticTarget st:
from.SendMessage($"Static at {st.Location}, ID: 0x{st.ItemID:X}");
break;
}
}
protected override void OnTargetCancel(Mobile from, TargetCancelType cancelType)
{
if (cancelType == TargetCancelType.Canceled)
from.SendMessage("Targeting cancelled.");
}
protected override void OnTargetFinish(Mobile from)
{
// Always called after success or cancel
}
}
```
### Target Validation Flow
1. Client sends target response
2. Server validates: same map, within range, line of sight
3. Calls `CanTarget()` override for type-specific validation
4. If valid: calls `OnTarget()`
5. If invalid: calls appropriate error handler
6. Always calls `OnTargetFinish()` at the end
### SpellTarget
For spells, use the built-in `SpellTarget<T>`:
```csharp
public override void OnCast()
{
Caster.Target = new SpellTarget<Mobile>(this, TargetFlags.Harmful);
}
// The spell's Target(Mobile m) method is called when the player targets
```
---
## Best Practices
1. **Register commands in `Configure()`** -- it's called automatically during startup
2. **Validate argument count** before accessing -- `GetInt32()` returns 0 for missing args (not an error)
3. **Use appropriate access levels** -- don't give players GM commands
4. **Use `TargetFlags.Harmful`** for offensive actions (triggers criminal flagging)
5. **Use `TargetFlags.Beneficial`** for healing/buffing
6. **Handle `OnTargetCancel`** to provide feedback when player cancels
7. **Clean up in `OnTargetFinish`** if you have state to release
## Key File References
| File | Description |
|---|---|
| `Projects/Server/Commands.cs` | CommandSystem, CommandEventArgs |
| `Projects/Server/Attributes.cs` | Usage, Description, Aliases |
| `Projects/Server/Targeting/Target.cs` | Target base class |
| `Projects/Server/Targeting/TargetFlags.cs` | TargetFlags enum |
| `Projects/Server/Targeting/TargetCancelType.cs` | Cancel types |
| `Projects/Server/Targeting/LandTarget.cs` | Land target |
| `Projects/Server/Targeting/StaticTarget.cs` | Static target |

282
dev-docs/configuration.md Normal file
View file

@ -0,0 +1,282 @@
# ModernUO Configuration System
This document covers ModernUO's configuration system, including ServerConfiguration for global settings, JsonConfig for custom config files, and best practices.
## Overview
ModernUO has two configuration mechanisms:
1. **ServerConfiguration**: Global key-value settings stored in `modernuo.json`
2. **JsonConfig**: Custom JSON configuration files for complex data structures
## ServerConfiguration
Defined in `Projects/Server/Configuration/ServerConfiguration.cs`.
### Reading Settings
#### GetSetting (Read-Only)
Returns the configured value or the default. Does NOT write the default to the config file.
```csharp
int statMax = ServerConfiguration.GetSetting("stats.statMax", 100);
bool enabled = ServerConfiguration.GetSetting("mySystem.enabled", true);
TimeSpan delay = ServerConfiguration.GetSetting("autosave.saveDelay", TimeSpan.FromMinutes(5));
double rate = ServerConfiguration.GetSetting("stats.gainChanceMultiplier", 1.0);
Expansion exp = ServerConfiguration.GetSetting("core.expansion", Expansion.ML);
```
Supported types:
- `int`
- `bool`
- `double`
- `TimeSpan`
- `T where T : struct, Enum`
#### GetOrUpdateSetting (Read-Write)
Returns the configured value. If the key doesn't exist, writes the default to the config file and returns it.
```csharp
int maxAccounts = ServerConfiguration.GetOrUpdateSetting("accountHandler.maxAccountsPerIP", 1);
bool autoCreate = ServerConfiguration.GetOrUpdateSetting("accountHandler.enableAutoAccountCreation", true);
int poolSize = ServerConfiguration.GetOrUpdateSetting("timer.initialPoolCapacity", 1024);
```
Use this when you want new settings to appear in `modernuo.json` automatically with sensible defaults.
#### SetSetting
Directly sets a value and immediately persists to disk:
```csharp
ServerConfiguration.SetSetting("mySystem.enabled", "true");
ServerConfiguration.SetSetting("mySystem.maxItems", "100");
```
### Configuration Pattern
Read settings in your `Configure()` static method:
```csharp
namespace Server.Custom;
public static class MySystem
{
private static bool _enabled;
private static int _maxItems;
private static TimeSpan _cooldown;
public static void Configure()
{
_enabled = ServerConfiguration.GetOrUpdateSetting("mySystem.enabled", true);
_maxItems = ServerConfiguration.GetOrUpdateSetting("mySystem.maxItems", 100);
_cooldown = ServerConfiguration.GetOrUpdateSetting("mySystem.cooldown", TimeSpan.FromMinutes(5));
}
public static void Initialize()
{
if (!_enabled)
return;
// System initialization that depends on config values
}
}
```
### Key Naming Convention
Use dot-separated hierarchical keys:
```
systemName.settingName
systemName.subSystem.settingName
```
Examples from the codebase:
```
accountHandler.enableAutoAccountCreation
accountHandler.enablePlayerPasswordCommand
accountHandler.maxAccountsPerIP
autosave.enabled
autosave.saveDelay
world.savePath
world.useMultithreadedSaves
movement.delay.walkFoot
movement.delay.runFoot
stats.statMax
stats.gainChanceMultiplier
stats.primaryStatGainChance
stats.gainDelay
stats.petGainDelay
stats.usePub45StatGain
timer.initialPoolCapacity
timer.maxPoolCapacity
core.enableIdleCPU
```
### modernuo.json Structure
Located at `Distribution/Configuration/modernuo.json`:
```json
{
"assemblyDirectories": ["./Assemblies"],
"dataDirectories": ["C:\\Ultima Online Classic"],
"listeners": ["0.0.0.0:2593"],
"settings": {
"accountHandler.enableAutoAccountCreation": "True",
"accountHandler.maxAccountsPerIP": "1",
"autosave.enabled": "True",
"autosave.saveDelay": "00:05:00",
"world.savePath": "Saves",
"stats.statMax": "100"
}
}
```
Key points:
- All settings are stored as strings in the `settings` dictionary
- Top-level fields (`assemblyDirectories`, `dataDirectories`, `listeners`) are structural
- `GetOrUpdateSetting` adds new entries to `settings` automatically
---
## JsonConfig
For complex configuration that doesn't fit in flat key-value pairs, use `JsonConfig`.
Defined in `Projects/Server/Json/JsonConfig.cs`.
### API
```csharp
// Deserialize from file (returns default if file doesn't exist)
T config = JsonConfig.Deserialize<T>(filePath);
T config = JsonConfig.Deserialize<T>(filePath, customOptions);
// Serialize to file (creates directory if needed)
JsonConfig.Serialize(filePath, config);
JsonConfig.Serialize(filePath, config, customOptions);
// Default options (available for customization)
JsonSerializerOptions options = JsonConfig.DefaultOptions;
```
### Default JSON Options
```csharp
WriteIndented = true // Pretty-printed
AllowTrailingCommas = true // Forgiving parser
ReadCommentHandling = JsonCommentHandling.Skip // Comments allowed
DefaultIgnoreCondition = WhenWritingNull // Null values omitted
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
```
### Built-in Converters
JsonConfig includes converters for ModernUO types:
- `ClientVersion`
- `Guid`
- `Map`
- `Point3D`
- `Rectangle3D`
- `TimeSpan`
- `IPEndPoint`
- `Type`
- `WorldLocation`
- `TextDefinition`
- All enums (as strings via `JsonStringEnumConverter`)
### Custom Config File Pattern
```csharp
using Server.Json;
namespace Server.Custom;
public class MySystemConfig
{
public bool Enabled { get; set; } = true;
public int MaxItems { get; set; } = 100;
public TimeSpan Cooldown { get; set; } = TimeSpan.FromMinutes(5);
public List<string> BlockedNames { get; set; } = new();
public Dictionary<string, int> Scores { get; set; } = new();
}
public static class MySystem
{
private static MySystemConfig _config;
private static readonly string ConfigPath =
Path.Combine(Core.BaseDirectory, "Configuration/MySystem/config.json");
public static void Configure()
{
_config = JsonConfig.Deserialize<MySystemConfig>(ConfigPath);
if (_config == null)
{
_config = new MySystemConfig();
JsonConfig.Serialize(ConfigPath, _config);
}
}
public static void SaveConfig()
{
JsonConfig.Serialize(ConfigPath, _config);
}
}
```
This creates a config file like:
```json
{
"Enabled": true,
"MaxItems": 100,
"Cooldown": "00:05:00",
"BlockedNames": [],
"Scores": {}
}
```
### Custom Converters
Add custom converters via `JsonConfig.GetOptions()`:
```csharp
var options = JsonConfig.GetOptions(new MyCustomConverterFactory());
var data = JsonConfig.Deserialize<MyType>(path, options);
```
---
## Configuration File Locations
| File | Purpose |
|---|---|
| `Distribution/Configuration/modernuo.json` | Main server settings |
| `Distribution/Configuration/expansion.json` | Target expansion |
| `Distribution/Data/expansions.json` | Expansion metadata |
| `Distribution/Configuration/` | Custom config directory |
Custom config files should be placed under `Distribution/Configuration/` in a subdirectory named after your system.
## Best Practices
1. **Read in `Configure()`** -- called before `Initialize()`, ensures settings are available early
2. **Use `GetOrUpdateSetting`** for new features -- ensures defaults appear in config file
3. **Use `GetSetting`** for optional/advanced settings -- doesn't clutter config file
4. **Use JsonConfig for complex data** -- lists, dictionaries, nested objects
5. **Provide sensible defaults** -- system should work without manual configuration
6. **Use era-aware defaults** -- `Core.LBR ? 125 : 100` for values that vary by expansion
7. **Document key names** -- use clear hierarchical naming
## Key File References
| File | Description |
|---|---|
| `Projects/Server/Configuration/ServerConfiguration.cs` | ServerConfiguration class |
| `Projects/Server/Json/JsonConfig.cs` | JsonConfig utility |
| `Distribution/Configuration/modernuo.json` | Main config file |
| `Projects/UOContent/Skills/SkillCheck.cs` | Config usage example |
| `Projects/Server/Timer/Timer.Pool.cs` | Config usage example |

View file

@ -0,0 +1,557 @@
# ModernUO Content Creation Patterns
This document covers the patterns and templates for creating game content in ModernUO: items, creatures, spells, skills, loot, context menus, and file organization.
## Table of Contents
1. [New Item](#new-item)
2. [New Creature](#new-creature)
3. [New Spell](#new-spell)
4. [Skill Implementation](#skill-implementation)
5. [Loot System](#loot-system)
6. [Context Menus](#context-menus)
7. [Entity Lifecycle](#entity-lifecycle)
8. [File Organization](#file-organization)
---
## New Item
### Minimal Item
```csharp
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0, false)]
public partial class SimpleItem : Item
{
[Constructible]
public SimpleItem() : base(0x1234) // itemID from UO art
{
Weight = 1.0;
}
public override string DefaultName => "a simple item";
// OR: public override int LabelNumber => 1234567; // cliloc number
}
```
### Item with Properties and Behavior
```csharp
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0, false)]
public partial class MagicLantern : Item
{
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
[SerializableField(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private Mobile _owner;
private TimerExecutionToken _glowTimer;
[Constructible]
public MagicLantern() : base(0xA25)
{
_charges = Utility.RandomMinMax(5, 15);
Weight = 2.0;
Light = LightType.Circle300;
StartGlow();
}
public override string DefaultName => "a magic lantern";
private void StartGlow()
{
Timer.StartTimer(TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3), Glow, out _glowTimer);
}
[AfterDeserialization]
private void AfterDeserialization() => StartGlow();
public override void OnAfterDelete()
{
_glowTimer.Cancel();
base.OnAfterDelete();
}
private void Glow()
{
if (_charges > 0)
Effects.SendLocationParticles(this, 0x376A, 9, 10, 5042);
}
public override void OnDoubleClick(Mobile from)
{
if (!IsChildOf(from.Backpack))
{
from.SendLocalizedMessage(1042001); // Must be in your backpack
return;
}
if (_charges <= 0)
{
from.SendMessage("The lantern is depleted.");
return;
}
Charges--;
from.SendMessage("The lantern flares brightly!");
from.FixedParticles(0x376A, 9, 32, 5042, EffectLayer.Waist);
}
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
list.Add(1060741, $"{_charges}"); // "charges: ~1_val~"
}
}
```
### Common Item Base Classes
| Base Class | Use For |
|---|---|
| `Item` | Generic items |
| `BaseWeapon` | Melee weapons |
| `BaseRanged` | Ranged weapons (bows, crossbows) |
| `BaseArmor` | Armor pieces |
| `BaseShield` | Shields |
| `BaseClothing` | Wearable clothing |
| `BaseJewel` | Rings, bracelets, necklaces |
| `BaseContainer` | Containers (bags, boxes) |
| `BasePotion` | Potions |
| `BaseReagent` | Spell reagents |
| `Food` | Edible items |
| `SpellScroll` | Spell scrolls |
### Key Item Properties
```csharp
Weight = 1.0; // Item weight in stones
Stackable = true; // Can stack with same type
Amount = 1; // Stack amount
Movable = true; // Can be picked up
Visible = true; // Visible to players
Hue = 0; // Color hue (0 = default)
Light = LightType.Circle300; // Light emission
LootType = LootType.Regular; // Regular, Newbied, Blessed, Cursed
Layer = Layer.OneHanded; // Equipment layer
```
---
## New Creature
### Basic Creature
```csharp
using ModernUO.Serialization;
using Server.Items;
namespace Server.Mobiles;
[SerializationGenerator(0, false)]
public partial class ForestWolf : BaseCreature
{
[Constructible]
public ForestWolf() : base(AIType.AI_Melee, FightMode.Closest)
{
Body = 225; // Wolf body graphic
BaseSoundID = 0xE5; // Base sound ID
SetStr(80, 120);
SetDex(90, 110);
SetInt(20, 40);
SetHits(60, 80);
SetMana(0);
SetDamage(8, 14);
SetDamageType(ResistanceType.Physical, 100);
SetResistance(ResistanceType.Physical, 25, 35);
SetResistance(ResistanceType.Fire, 5, 10);
SetResistance(ResistanceType.Cold, 15, 25);
SetResistance(ResistanceType.Poison, 10, 15);
SetResistance(ResistanceType.Energy, 5, 10);
SetSkill(SkillName.MagicResist, 30.0, 50.0);
SetSkill(SkillName.Tactics, 50.0, 70.0);
SetSkill(SkillName.Wrestling, 50.0, 70.0);
Fame = 600;
Karma = 0;
VirtualArmor = 28;
Tamable = true;
ControlSlots = 1;
MinTameSkill = 50.1;
}
public override string CorpseName => "a wolf corpse";
public override string DefaultName => "a forest wolf";
public override int Meat => 1;
public override int Hides => 6;
public override HideType HideType => HideType.Regular;
public override FoodType FavoriteFood => FoodType.Meat;
public override PackInstinct PackInstinct => PackInstinct.Canine;
public override void GenerateLoot()
{
AddLoot(LootPack.Meager);
}
}
```
### AI Types
| AIType | Use For |
|---|---|
| `AI_Melee` | Warriors, melee fighters |
| `AI_Mage` | Spellcasters |
| `AI_Archer` | Ranged attackers |
| `AI_Animal` | Passive animals (flee/fight back) |
| `AI_Predator` | Hunting animals |
| `AI_Healer` | Healing NPCs |
| `AI_Vendor` | Shop NPCs |
| `AI_Berserk` | Mindless aggressors |
| `AI_Thief` | Pickpockets |
### Fight Modes
| FightMode | Behavior |
|---|---|
| `None` | Never attacks |
| `Aggressor` | Only retaliates |
| `Strongest` | Targets highest-stat enemy |
| `Weakest` | Targets lowest-stat enemy |
| `Closest` | Targets nearest enemy |
| `Evil` | Attacks aggressors or evil-karma targets |
### Creature Stats Guide
| Creature Level | Str | Dex | Int | Hits | Damage | Fame |
|---|---|---|---|---|---|---|
| Weak | 30-60 | 30-50 | 10-20 | 20-40 | 2-6 | 100-300 |
| Average | 80-120 | 60-90 | 20-40 | 60-100 | 6-14 | 500-1500 |
| Strong | 150-250 | 80-120 | 50-100 | 120-200 | 12-22 | 2000-5000 |
| Elite | 300-500 | 100-150 | 100-200 | 250-500 | 18-30 | 5000-15000 |
| Boss | 500-1000 | 150-250 | 200-400 | 500-2000 | 25-40 | 15000+ |
### Optional Creature Overrides
```csharp
public override Poison PoisonImmune => Poison.Regular; // Poison immunity
public override Poison HitPoison => Poison.Lesser; // Melee poison
public override double HitPoisonChance => 0.2; // 20% poison chance
public override bool CanRummageCorpses => true; // Loots corpses
public override bool BardImmune => true; // Cannot be provoked/peaced
public override bool Unprovokable => true; // Cannot be provoked
public override bool CanFly => true; // Can fly
public override int TreasureMapLevel => 3; // Drops treasure map
public override double WeaponAbilityChance => 0.4; // Weapon ability chance
```
---
## New Spell
### Targeted Damage Spell (Magery)
```csharp
using System;
using Server.Targeting;
namespace Server.Spells.Third;
public class FireballSpellCustom : MagerySpell, ITargetingSpell<Mobile>
{
private static readonly SpellInfo _info = new(
"Fireball", // Name
"Vas Flam", // Mantra
212, // Cast animation
9041, // Cast sound
Reagent.BlackPearl // Reagents (comma-separated)
);
public FireballSpellCustom(Mobile caster, Item scroll = null) : base(caster, scroll, _info) { }
public override SpellCircle Circle => SpellCircle.Third;
public override bool DelayedDamage => true;
public void Target(Mobile m)
{
if (CheckHSequence(m)) // Harmful spell sequence check
{
var source = Caster;
SpellHelper.Turn(source, m);
SpellHelper.CheckReflect((int)Circle, ref source, ref m);
double damage;
if (Core.AOS)
{
damage = GetNewAosDamage(19, 1, 5, m);
}
else
{
damage = Utility.Random(10, 7);
if (CheckResisted(m))
{
damage *= 0.75;
m.SendLocalizedMessage(501783); // You resist
}
damage *= GetDamageScalar(m);
}
source.MovingParticles(m, 0x36D4, 7, 0, false, true, 9502, 4019, 0x160);
source.PlaySound(0x15E);
// Damage types must sum to 100
SpellHelper.Damage(this, m, damage, 0, 100, 0, 0, 0);
// phys fire cold pois energy
}
}
public override void OnCast()
{
Caster.Target = new SpellTarget<Mobile>(this, TargetFlags.Harmful);
}
}
```
### Spell Helper Methods
```csharp
SpellHelper.Turn(caster, target); // Face target
SpellHelper.CheckReflect(circle, ref source, ref target); // Magic reflect
SpellHelper.Damage(spell, target, damage, phys, fire, cold, poison, energy);
SpellHelper.AddStatCurse(caster, target, stat);
SpellHelper.AddStatBonus(caster, target, stat);
SpellHelper.CanRevealCaster(spell);
CheckHSequence(target); // Harmful spell checks (LOS, range, criminal)
CheckBSequence(target); // Beneficial spell checks
CheckResisted(target); // Resistance check
GetNewAosDamage(bonus, dice, sides, target); // AOS damage formula
GetDamageScalar(target); // Pre-AOS damage multiplier
```
### Spell Circles (Magery)
| Circle | Mana | Base Delay |
|---|---|---|
| First | 4 | 0.25s + circle |
| Second | 6 | 0.50s + circle |
| Third | 9 | 0.75s + circle |
| Fourth | 11 | 1.00s + circle |
| Fifth | 14 | 1.25s + circle |
| Sixth | 20 | 1.50s + circle |
| Seventh | 40 | 1.75s + circle |
| Eighth | 50 | 2.00s + circle |
---
## Skill Implementation
### Registering a Skill Handler
```csharp
namespace Server.SkillHandlers;
public static class MySkillHandler
{
public static void Initialize()
{
SkillInfo.Table[(int)SkillName.Tracking].Callback = OnUse;
}
public static TimeSpan OnUse(Mobile from)
{
from.SendMessage("You begin tracking...");
from.Target = new TrackingTarget();
return TimeSpan.FromSeconds(10.0); // Cooldown
}
}
```
### Skill Check
```csharp
// Difficulty-based check (with skill gain chance)
if (from.CheckSkill(SkillName.Mining, 0.0, 100.0))
{
// Success
}
// Direct chance check
if (from.CheckSkill(SkillName.Hiding, minSkill: 25.0, maxSkill: 75.0))
{
// Success
}
```
### SkillName Enum (58 skills)
Key skills: `Alchemy`, `Anatomy`, `AnimalLore`, `AnimalTaming`, `Archery`, `ArmsLore`, `Begging`, `Blacksmith`, `Bushido`, `Camping`, `Carpentry`, `Cartography`, `Chivalry`, `Cooking`, `DetectHidden`, `Discordance`, `EvalInt`, `Fencing`, `Fishing`, `Fletching`, `Focus`, `Forensics`, `Healing`, `Herding`, `Hiding`, `Inscribe`, `ItemID`, `Lockpicking`, `Lumberjacking`, `Macing`, `Magery`, `MagicResist`, `Meditation`, `Mining`, `Musicianship`, `Necromancy`, `Ninjitsu`, `Parry`, `Peacemaking`, `Poisoning`, `Provocation`, `RemoveTrap`, `Snooping`, `Spellweaving`, `SpiritSpeak`, `Stealing`, `Stealth`, `Swords`, `Tactics`, `Tailoring`, `TasteID`, `Tinkering`, `Tracking`, `Veterinary`, `Wrestling`
---
## Loot System
### Using Predefined Packs
```csharp
public override void GenerateLoot()
{
AddLoot(LootPack.Poor); // ~50g equivalent
AddLoot(LootPack.Meager); // ~100g equivalent
AddLoot(LootPack.Average); // ~250g equivalent
AddLoot(LootPack.Rich); // ~500g equivalent
AddLoot(LootPack.FilthyRich); // ~1000g equivalent
AddLoot(LootPack.UltraRich); // ~2000g equivalent
AddLoot(LootPack.SuperBoss); // Boss-level loot
// Auxiliary packs
AddLoot(LootPack.Gems, 2); // 2 random gems
AddLoot(LootPack.Potions); // Random potion
AddLoot(LootPack.LowScrolls); // Low circle scroll
AddLoot(LootPack.MedScrolls); // Med circle scroll
AddLoot(LootPack.HighScrolls); // High circle scroll
}
```
Packs auto-select era-appropriate loot (Pre-AOS, AOS, SE variants).
### Specific Items
```csharp
PackItem(new Arrow(Utility.RandomMinMax(20, 40)));
PackGold(100, 200);
PackItem(new Bandage(Utility.RandomMinMax(5, 10)));
```
---
## Context Menus
```csharp
public override void GetContextMenuEntries(Mobile from, ref PooledRefList<ContextMenuEntry> list)
{
base.GetContextMenuEntries(from, ref list);
if (from.Alive && from.InRange(this, 2))
{
list.Add(new RepairEntry(this));
}
}
private class RepairEntry : ContextMenuEntry
{
private readonly Item _item;
public RepairEntry(Item item) : base(6100) // Cliloc number
{
_item = item;
Enabled = item is { Deleted: false };
}
public override void OnClick(Mobile from, IEntity target)
{
if (_item.Deleted || !from.InRange(_item, 2))
return;
from.SendMessage("You repair the item.");
}
}
```
---
## Entity Lifecycle
### Two-Phase Deletion
```csharp
// Phase 1: Pre-removal cleanup
public override void OnDelete()
{
_timerToken.Cancel(); // Cancel managed timers
// Remove from tracking systems
base.OnDelete();
}
// Phase 2: Post-removal cleanup
public override void OnAfterDelete()
{
_timer?.Stop(); // Stop Timer references
_timer = null;
_owner = null; // Null Item/Mobile refs
base.OnAfterDelete();
}
```
### OnDoubleClick Validation
```csharp
public override void OnDoubleClick(Mobile from)
{
if (!IsChildOf(from.Backpack))
{
from.SendLocalizedMessage(1042001); // Must be in backpack
return;
}
if (!from.InRange(GetWorldLocation(), 2))
{
from.SendLocalizedMessage(500446); // Too far away
return;
}
// Item logic here
}
```
---
## File Organization
```
Projects/UOContent/
├── Items/
│ ├── Weapons/Swords/ # Swords
│ ├── Weapons/Maces/ # Maces
│ ├── Weapons/Ranged/ # Bows, crossbows
│ ├── Armor/Plate/ # Plate armor
│ ├── Armor/Chain/ # Chain armor
│ ├── Armor/Leather/ # Leather armor
│ ├── Clothing/ # Wearable clothing
│ ├── Containers/ # Bags, boxes
│ ├── Misc/ # General items
│ ├── Special/ # Unique/quest items
│ └── Resources/ # Crafting materials
├── Mobiles/
│ ├── Animals/Bears/ # Bears (BlackBear, GrizzlyBear)
│ ├── Animals/Birds/ # Birds
│ ├── Monsters/AOS/ # AOS-era monsters
│ ├── Monsters/SE/ # SE-era monsters
│ ├── Monsters/ML/ # ML-era monsters
│ ├── Special/ # Champions, bosses
│ ├── Vendors/ # NPC vendors
│ └── Townfolk/ # NPCs
├── Spells/
│ ├── Base/ # Spell base classes
│ ├── First/ - Eighth/ # Magery circles
│ ├── Necromancy/ # Necromancer spells
│ ├── Chivalry/ # Paladin spells
│ ├── Bushido/ # Samurai abilities
│ ├── Ninjitsu/ # Ninja abilities
│ └── Spellweaving/ # Spellweaving
├── Skills/ # Skill handlers
├── Gumps/ # UI dialogs
│ └── Base/ # Gump base classes
├── Engines/ # Complex systems
│ ├── Craft/ # Crafting system
│ ├── CannedEvil/ # Champion spawns
│ ├── Factions/ # Faction system
│ └── Quests/ # Quest system
└── Misc/ # Miscellaneous
└── LootPack.cs # Loot tables
```
### Naming Rules
- File name = class name
- One primary class per file
- Group related items in subdirectories
- Era-specific content goes in era-named subdirectories

188
dev-docs/era-expansion.md Normal file
View file

@ -0,0 +1,188 @@
# ModernUO Era & Expansion System
This document covers ModernUO's expansion system, era checks, and how to write era-conditional code.
## Overview
ModernUO supports all Ultima Online expansions from the original game through Endless Journey. The server's target expansion is configured at startup and affects gameplay mechanics, damage formulas, loot tables, skill systems, and more.
## Expansion Enum
Defined in `Projects/Server/ExpansionInfo.cs`:
```csharp
public enum Expansion
{
None, // 0 - Original UO (pre-T2A)
T2A, // 1 - The Second Age (October 1998)
UOR, // 2 - Renaissance (May 2000)
UOTD, // 3 - Third Dawn (March 2001)
LBR, // 4 - Lord Blackthorn's Revenge (February 2002)
AOS, // 5 - Age of Shadows (February 2003)
SE, // 6 - Samurai Empire (November 2004)
ML, // 7 - Mondain's Legacy (August 2005)
SA, // 8 - Stygian Abyss (September 2009)
HS, // 9 - High Seas (October 2010)
TOL, // 10 - Time of Legends (October 2015)
EJ // 11 - Endless Journey (March 2018)
}
```
## Era Check Properties
Each property returns `true` when `Core.Expansion >= that expansion`:
```csharp
Core.Expansion // Exact expansion (Expansion enum value)
Core.T2A // bool: >= The Second Age
Core.UOR // bool: >= Renaissance
Core.UOTD // bool: >= Third Dawn
Core.LBR // bool: >= Blackthorn's Revenge
Core.AOS // bool: >= Age of Shadows
Core.SE // bool: >= Samurai Empire
Core.ML // bool: >= Mondain's Legacy
Core.SA // bool: >= Stygian Abyss
Core.HS // bool: >= High Seas
Core.TOL // bool: >= Time of Legends
Core.EJ // bool: >= Endless Journey
```
## Major Era Boundaries
### Pre-AOS (None through LBR)
- Simple damage model: flat random damage
- Skill-based resistance
- No item properties system
- Simple loot tables
### AOS (Age of Shadows) -- The Big Divide
AOS fundamentally changed UO's combat and item systems:
- **Resistance system**: 5 damage types (Physical, Fire, Cold, Poison, Energy) each with resistances
- **Item properties**: Magic items with bonus properties (hit chance, damage increase, etc.)
- **New damage formula**: `GetNewAosDamage()` replaces flat random
- **Luck system**: Affects magic item quality from loot
- **Insurance**: Players can insure items against loss
- **Necromancy**: New spell school
- **Chivalry**: New spell school (Paladin)
### SE (Samurai Empire)
- **Bushido/Ninjitsu**: Two new skill/spell schools
- **Adjusted loot packs**: `SePoor`, `SeMeager`, `SeAverage`, etc.
- **Reduced hit delay**: `Core.SE ? 250 : Core.AOS ? 500 : 1000`
### ML (Mondain's Legacy)
- **Spellweaving**: New magic school
- **Adjusted skill requirements**: Different min skill values for spells
- **Container display**: Shows weight limits in tooltips
- **Stat gain changes**: Faster stat gain option
### SA (Stygian Abyss)
- **Gargoyle race**: New playable race
- **Mysticism/Throwing**: New skills
- **Extended mobile status**: Additional stats in status bar
### HS (High Seas)
- **Ship combat**: Naval warfare system
- **Extended status bar**: More stats visible
## Writing Era-Conditional Code
### Simple Value Selection
```csharp
// Ternary chain (most common pattern)
var delay = Core.SE ? 250 : Core.AOS ? 500 : 1000;
var statMax = Core.LBR ? 125 : 100;
```
### Logic Branching
```csharp
if (Core.AOS)
{
// AOS+ damage formula
damage = GetNewAosDamage(10, 1, 4, target);
}
else
{
// Pre-AOS damage formula
damage = Utility.Random(4, 4);
if (CheckResisted(target))
{
damage *= 0.75;
target.SendLocalizedMessage(501783);
}
damage *= GetDamageScalar(target);
}
```
### Display Branching
```csharp
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
if (Core.ML)
{
// ML+ shows full container info
list.Add(1072241, $"{TotalItems}\t{MaxItems}\t{TotalWeight}\t{MaxWeight}");
}
else
{
// Pre-ML shows basic info
list.Add(1050044, $"{TotalItems}\t{TotalWeight}");
}
}
```
### Skill Requirements by Era
```csharp
// From MagerySpell.cs - spell difficulty varies by era
private static readonly double[] _requiredSkill = Core.ML
? new[] { -46.0, -32.0, -18.0, -4.0, 10.0, 24.0, 38.0, 52.0, 66.0, 80.0 }
: new[] { -50.0, -30.0, 0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0 };
```
### Loot by Era
LootPack properties auto-select era-appropriate variants:
```csharp
LootPack.Poor // Selects: OldPoor / AosPoor / SePoor
LootPack.Meager // Selects: OldMeager / AosMeager / SeMeager
LootPack.Average // etc.
LootPack.Rich
LootPack.FilthyRich
LootPack.UltraRich
LootPack.SuperBoss
```
## Configuration
Expansion is set in `Distribution/Configuration/expansion.json`:
```json
{
"expansion": "ML"
}
```
Full expansion metadata is in `Distribution/Data/expansions.json`, containing:
- Required client version
- Supported feature flags
- Character list flags
- Housing flags
- Mobile status version
- Map selection flags
## Best Practices
1. **Always ask the user** which expansion to target if not specified
2. **Test both branches** when writing era-conditional code
3. **Use `Core.XYZ` properties** (not `Core.Expansion >= Expansion.XYZ`)
4. **Chain ternaries** from newest to oldest for value selection
5. **Document era requirements** in comments when the logic is complex
6. **Use era-aware LootPack** properties instead of hardcoding specific era packs
## Key File References
- Expansion enum: `Projects/Server/ExpansionInfo.cs`
- Core properties: `Projects/Server/Core.cs`
- Expansion data: `Distribution/Data/expansions.json`
- Loot packs: `Projects/UOContent/Misc/LootPack.cs`
- Spell circles: `Projects/UOContent/Spells/Base/MagerySpell.cs`
- Skill check: `Projects/UOContent/Skills/SkillCheck.cs`

347
dev-docs/event-scheduler.md Normal file
View file

@ -0,0 +1,347 @@
# ModernUO EventScheduler System
This document covers ModernUO's wall-clock and calendar-based event scheduling system. For game-tick timers (sub-second precision, combat ticks, decay, etc.), see `dev-docs/timers.md`.
## Overview
The `EventScheduler` provides **wall-clock scheduling** — firing events at real-world times, dates, and calendar patterns. It runs as a 1-second `Timer` on the game loop and uses a `PriorityQueue` ordered by next UTC occurrence.
### Timer vs EventScheduler
| Aspect | `Timer.StartTimer` | `EventScheduler` |
|---|---|---|
| Clock basis | Game-tick (8ms wheel) | Wall-clock (1s poll) |
| Precision | 8ms | 1 second |
| Use case | Combat ticks, decay, delays | Holidays, daily resets, scheduled maintenance |
| Recurrence | Fixed interval | Calendar patterns (hourly, daily, weekly, monthly, yearly) |
| Timezone | N/A | Full `TimeZoneInfo` + DST handling |
| Seasonal windows | No | Yes (`YearlyScheduledEvent`) |
**Rule of thumb**: If the event must happen "at 9:00 AM EST every Monday" or "during October through November each year," use `EventScheduler`. If it must happen "5 seconds from now" or "every 2 seconds," use `Timer`.
## Architecture
`EventScheduler` is a singleton `Timer` that ticks every second. Internally it holds a `PriorityQueue<BaseScheduledEvent, DateTime>` sorted by `NextOccurrence` (UTC). Each tick it dequeues and fires all events whose time has passed, then each event self-re-enqueues for its next occurrence.
```
EventScheduler (Timer, 1s tick)
└─ PriorityQueue<BaseScheduledEvent, DateTime>
├─ CallbackScheduledEvent (fires Action)
├─ YearlyCallbackScheduledEvent (fires Action within seasonal window)
└─ Your custom subclass
```
## Class Hierarchy
```
BaseScheduledEvent (abstract: Schedule, Cancel, Advance, OnEvent)
└─ ScheduledEvent (adds IRecurrencePattern, TimeOnly, EndDate)
├─ CallbackScheduledEvent (sealed: fires an Action callback)
└─ YearlyScheduledEvent (abstract: adds MonthDay start/end seasonal window)
└─ YearlyCallbackScheduledEvent (fires an Action within seasonal window)
```
### BaseScheduledEvent
The abstract root. Key members:
| Member | Description |
|---|---|
| `TimeZone` | `TimeZoneInfo` — defaults to UTC |
| `NextOccurrence` | `DateTime` (UTC) of the next fire time |
| `Cancelled` | `bool` — set by `Cancel()` |
| `Scheduler` | Back-reference to the owning `EventScheduler` |
| `Schedule(startAfter, timeZone?)` | Schedule on the shared instance |
| `Schedule(scheduler, startAfter, timeZone?)` | Schedule on a specific scheduler |
| `Cancel()` | Cancel and unschedule the event |
| `Advance()` | Called by the scheduler — fires `OnEvent()`, then re-schedules |
| `OnEvent()` | Abstract — your event logic goes here |
### ScheduledEvent
Extends `BaseScheduledEvent` with recurrence support:
| Member | Description |
|---|---|
| `Recurrence` | `IRecurrencePattern` — determines when the event recurs |
| `Time` | `TimeOnly` — the time-of-day component for recurrence calculation |
| `EndDate` | `DateTime` — stop recurring after this date (default: never) |
### CallbackScheduledEvent
Sealed concrete class. Wraps an `Action` callback:
```csharp
var evt = new CallbackScheduledEvent(new TimeOnly(9, 0), myAction, EventScheduler.Daily);
evt.Schedule(DateTime.UtcNow);
```
Most users should use the static convenience methods on `EventScheduler` instead of constructing directly.
### YearlyScheduledEvent
Abstract. Adds a seasonal window defined by `MonthDay` start and end:
| Member | Description |
|---|---|
| `YearlyStart` | `MonthDay` — first day of the active window |
| `YearlyEnd` | `MonthDay` — last day of the active window |
The event only fires when the next occurrence falls within the `[YearlyStart, YearlyEnd]` range. If it falls outside, the scheduler fast-forwards to the next year's window start. Supports year-boundary wrapping (e.g., Nov 15 through Feb 15).
### YearlyCallbackScheduledEvent
Concrete version of `YearlyScheduledEvent` that fires an `Action`:
```csharp
var halloween = new YearlyCallbackScheduledEvent(
new TimeOnly(0, 0),
new MonthDay(2025, 10, 1), // Oct 1
new MonthDay(2025, 11, 1), // Nov 1
SpawnHalloweenContent,
EventScheduler.Daily
);
halloween.Schedule(DateTime.UtcNow, easternTimeZone);
```
## Recurrence Patterns
All patterns implement `IRecurrencePattern`:
```csharp
public interface IRecurrencePattern
{
DateTime GetNextOccurrence(DateTime afterUtc, TimeOnly time, TimeZoneInfo timeZone);
}
```
### Built-In Patterns
| Pattern | Static accessor | Behavior |
|---|---|---|
| `HourlyRecurrencePattern` | `EventScheduler.Hourly` | Every N hours (default 1) at the same minute |
| `DailyRecurrencePattern` | `EventScheduler.Daily` | Every N days (default 1) at the specified time |
| `WeeklyRecurrencePattern` | `EventScheduler.Weekly` | Every N weeks (default 1), with optional `AllowedDays` and `AllowedMonths` filters |
| `WeeklyRecurrencePattern(2)` | `EventScheduler.Biweekly` | Every 2 weeks |
| `MonthlyRecurrencePattern` | `EventScheduler.Monthly` | Every N months (default 1) on a specific day-of-month |
| `MonthlyRecurrencePattern(-1, 12)` | `EventScheduler.Yearly` | Every 12 months (yearly) |
| `MonthlyOrdinalRecurrencePattern` | (construct directly) | E.g., "second Tuesday of every month" or "last Friday" |
### MonthlyOrdinalRecurrencePattern
For patterns like "the third Wednesday of every month":
```csharp
// Second Tuesday of every month
var pattern = new MonthlyOrdinalRecurrencePattern(
OrdinalDayOccurrence.Second,
DayOfWeek.Tuesday
);
// Last Friday of every month
var pattern = new MonthlyOrdinalRecurrencePattern(
OrdinalDayOccurrence.Last,
DayOfWeek.Friday
);
```
The `OrdinalDayOccurrence` enum:
| Value | Meaning |
|---|---|
| `Last` (-1) | Last occurrence in the month |
| `First` (0) | First occurrence |
| `Second` (1) | Second occurrence |
| `Third` (2) | Third occurrence |
| `Fourth` (3) | Fourth occurrence |
| `Fifth` (4) | Fifth occurrence (skipped if doesn't exist) |
### WeeklyRecurrencePattern with Filters
```csharp
// Every week on Monday and Wednesday, only in January through March
var pattern = new WeeklyRecurrencePattern(
intervalWeeks: 1,
allowedMonths: AllowedMonths.January | AllowedMonths.February | AllowedMonths.March,
allowedDays: AllowedDays.Monday | AllowedDays.Wednesday
);
```
## Supporting Types
### AllowedDays (Flags Enum)
```csharp
[Flags]
public enum AllowedDays : byte
{
None, Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, All
}
```
Extension: `DayOfWeek.Monday.ToDaysOfWeek()``AllowedDays.Monday`
### AllowedMonths (Flags Enum)
```csharp
[Flags]
public enum AllowedMonths
{
None, January, February, ..., December, All
}
```
### MonthDay (Record Struct)
Represents a month/day pair without a year. Used for seasonal window boundaries:
```csharp
var oct1 = new MonthDay(2025, 10, 1); // Year is used only for day-count validation
var nov1 = new MonthDay(2025, 11, 1);
```
Extension method `DateTime.IsBetween(MonthDay start, MonthDay end)` handles year-boundary wrapping:
- `IsBetween(Oct 1, Nov 1)` — standard range within one year
- `IsBetween(Nov 15, Feb 15)` — wraps across year boundary
## Static Convenience Methods
`EventScheduler` provides static methods that create, schedule, and return a `ScheduledEvent`:
```csharp
// All methods accept: DateTime startOn, Action action, TimeZoneInfo timeZone = null
EventScheduler.HourlyAt(startOn, action, timeZone);
EventScheduler.DailyAt(startOn, action, timeZone);
EventScheduler.WeeklyAt(startOn, action, timeZone);
EventScheduler.BiweeklyAt(startOn, action, timeZone);
EventScheduler.MonthlyAt(startOn, action, timeZone);
EventScheduler.YearlyAt(startOn, action, timeZone);
```
The `startOn` parameter determines:
1. The `TimeOnly` component (hour/minute for recurrence)
2. The starting reference date
3. For `MonthlyAt`/`YearlyAt`, the day-of-month
Example:
```csharp
// Fire at 6:00 AM Eastern every day, starting tomorrow
var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
var tomorrow6am = new DateTime(2025, 6, 15, 6, 0, 0);
EventScheduler.DailyAt(tomorrow6am, ResetDailyQuests, eastern);
```
## Timezone & DST Handling
The system fully supports timezones via `TimeZoneInfo`:
- **Default**: UTC if no timezone specified
- **DST-safe**: Uses `LocalToUtc()` extension which handles:
- **Ambiguous times** (fall-back): Uses the later offset (standard time)
- **Invalid times** (spring-forward): Recurrence patterns skip invalid candidates
- **Conversion**: All internal scheduling uses UTC; local time is only for calculating the next occurrence
```csharp
// Extension method in Server namespace (Utility.cs)
public static DateTime LocalToUtc(this DateTime local, TimeZoneInfo tz)
```
**Always specify a timezone** when your event needs to fire at a local time. Omitting it defaults to UTC.
## Custom Event Classes
For complex logic, inherit from `ScheduledEvent` or `YearlyScheduledEvent`:
```csharp
public class WeekendBonusEvent : ScheduledEvent
{
public WeekendBonusEvent()
: base(
new TimeOnly(18, 0), // 6:00 PM
new WeeklyRecurrencePattern(
intervalWeeks: 1,
allowedDays: AllowedDays.Friday | AllowedDays.Saturday
))
{
}
public override void OnEvent()
{
// Enable weekend bonus XP
BonusSystem.ActivateWeekendBonus();
}
}
// Usage:
var evt = new WeekendBonusEvent();
evt.Schedule(DateTime.UtcNow, easternTimeZone);
```
### Yearly Seasonal Custom Event
```csharp
public class HalloweenSpawnEvent : YearlyScheduledEvent
{
protected HalloweenSpawnEvent()
: base(
new TimeOnly(0, 0),
new MonthDay(2025, 10, 15), // Oct 15
new MonthDay(2025, 11, 1), // Nov 1
EventScheduler.Daily)
{
}
public override void OnEvent()
{
// Spawn Halloween creatures daily during the window
HalloweenSystem.SpawnCreatures();
}
}
```
## Cancellation
Call `Cancel()` on any `BaseScheduledEvent` to remove it from the scheduler:
```csharp
private BaseScheduledEvent _dailyReset;
public void StartDailyResets()
{
var tomorrow = new DateTime(2025, 6, 15, 0, 0, 0);
_dailyReset = EventScheduler.DailyAt(tomorrow, ResetDaily, easternTz);
}
public void StopDailyResets()
{
_dailyReset?.Cancel();
_dailyReset = null;
}
```
## Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Using `Timer` for calendar events | Drifts with server restarts, no timezone support | Use `EventScheduler` |
| Forgetting timezone | Event fires at UTC instead of local time | Always pass `TimeZoneInfo` for local-time events |
| Not cancelling on cleanup | Event keeps firing after system disabled | Call `Cancel()` in cleanup/shutdown |
| Using `EventScheduler` for sub-second timing | 1-second granularity too coarse | Use `Timer.StartTimer` instead |
| Constructing `MonthDay` with invalid days | Throws `ArgumentOutOfRangeException` | Check `DateTime.DaysInMonth` for the given month |
## Key File References
| File | Description |
|---|---|
| `Projects/UOContent/Engines/Events/EventScheduler.cs` | Singleton scheduler, PriorityQueue, static factory methods |
| `Projects/UOContent/Engines/Events/BaseScheduledEvent.cs` | Abstract base: Schedule, Cancel, Advance, OnEvent |
| `Projects/UOContent/Engines/Events/ScheduledEvent.cs` | Adds IRecurrencePattern, TimeOnly, EndDate |
| `Projects/UOContent/Engines/Events/CallbackScheduledEvent.cs` | Sealed Action-callback concrete class |
| `Projects/UOContent/Engines/Events/YearlyScheduledEvent.cs` | Seasonal window with MonthDay start/end |
| `Projects/UOContent/Engines/Events/YearlyCallbackScheduledEvent.cs` | Yearly seasonal + Action callback |
| `Projects/UOContent/Engines/Events/CommonRecurrencePatterns.cs` | All IRecurrencePattern implementations |
| `Projects/UOContent/Engines/Events/AllowedDays.cs` | Flags enum for day-of-week filtering |
| `Projects/UOContent/Engines/Events/AllowedMonths.cs` | Flags enum for month filtering |
| `Projects/UOContent/Engines/Events/MonthDay.cs` | Record struct + IsBetween extension |
| `Projects/Server/Utilities/Utility.cs` | `LocalToUtc()` extension method |

281
dev-docs/events.md Normal file
View file

@ -0,0 +1,281 @@
# ModernUO Event System
This document covers ModernUO's event system, including EventSink static events and the CodeGeneratedEvents system for custom entity events.
## Overview
ModernUO provides two event mechanisms:
1. **EventSink**: Static events for core game lifecycle (login, logout, death, speech, etc.)
2. **CodeGeneratedEvents**: Attribute-based events on game entities (player login, creature death, etc.)
## EventSink
### Architecture
`EventSink` is a `static partial class` spread across multiple files in `Projects/Server/Events/`. Each event is defined as a `public static event Action<T>` with a corresponding `InvokeXxx()` method.
### Subscribing to Events
Subscribe in your `Configure()` static method:
```csharp
public static class MySystem
{
public static void Configure()
{
EventSink.Connected += OnPlayerConnected;
EventSink.Disconnected += OnPlayerDisconnected;
EventSink.Speech += OnSpeech;
EventSink.ServerStarted += OnServerStarted;
}
private static void OnPlayerConnected(Mobile m)
{
if (m is PlayerMobile pm)
pm.SendMessage("Welcome to the server!");
}
private static void OnPlayerDisconnected(Mobile m)
{
// Cleanup player state
}
private static void OnSpeech(SpeechEventArgs e)
{
if (e.Speech.InsensitiveContains("help"))
{
e.Mobile.SendMessage("Type [help for commands.");
e.Handled = true;
}
}
private static void OnServerStarted()
{
// Initialize after all systems loaded
}
}
```
### Available Events
#### Server Lifecycle
| Event | Signature | When |
|---|---|---|
| `ServerStarted` | `Action` | Server fully initialized |
| `Shutdown` | `Action` | Server shutting down |
| `WorldLoad` | `Action` | World loaded from saves |
| `WorldSave` | `Action` | World save triggered |
| `WorldSavePostSnapshot` | `Action<WorldSavePostSnapshotEventArgs>` | After save snapshot |
| `ServerCrashed` | `Action<ServerCrashedEventArgs>` | Unhandled exception |
#### Player Connection
| Event | Signature | When |
|---|---|---|
| `Connected` | `Action<Mobile>` | Player connected to server |
| `BeforeDisconnected` | `Action<Mobile>` | About to disconnect |
| `Disconnected` | `Action<Mobile>` | Player disconnected |
| `Logout` | `Action<Mobile>` | Player logged out |
#### Account
| Event | Signature | When |
|---|---|---|
| `AccountLogin` | `Action<AccountLoginEventArgs>` | Account login attempt |
#### Communication
| Event | Signature | When |
|---|---|---|
| `Speech` | `Action<SpeechEventArgs>` | Player speaks |
| `PaperdollRequest` | `Action<Mobile, Mobile>` | Paperdoll opened (beholder, beheld) |
#### Combat
| Event | Signature | When |
|---|---|---|
| `AggressiveAction` | `Action<AggressiveActionEventArgs>` | Aggressive action taken |
#### Movement
| Event | Signature | When |
|---|---|---|
| `Movement` | `Action<MovementEventArgs>` | Player moves |
#### Network
| Event | Signature | When |
|---|---|---|
| `SocketConnect` | `Action<SocketConnectEventArgs>` | New socket connection |
### EventArgs Classes
#### SpeechEventArgs
```csharp
public class SpeechEventArgs
{
public Mobile Mobile { get; }
public string Speech { get; set; } // Can modify speech text
public MessageType Type { get; }
public int Hue { get; }
public int[] Keywords { get; }
public bool Handled { get; set; } // Set true to consume
public bool Blocked { get; set; } // Set true to block
public bool HasKeyword(int keyword);
}
```
#### AccountLoginEventArgs
```csharp
public class AccountLoginEventArgs
{
public NetState State { get; }
public string Username { get; }
public string Password { get; }
public bool Accepted { get; set; } // Set false to reject
public ALRReason RejectReason { get; set; } // Reason for rejection
}
```
#### MovementEventArgs (Pooled)
```csharp
public class MovementEventArgs
{
public Mobile Mobile { get; }
public Direction Direction { get; }
public bool Blocked { get; set; } // Set true to block movement
// Object pooling
public static MovementEventArgs Create(Mobile m, Direction dir);
public void Free(); // Return to pool
}
```
#### AggressiveActionEventArgs (Pooled)
```csharp
public class AggressiveActionEventArgs
{
public Mobile Aggressed { get; }
public Mobile Aggressor { get; }
public bool Criminal { get; }
public static AggressiveActionEventArgs Create(Mobile aggressed, Mobile aggressor, bool criminal);
public void Free();
}
```
#### WorldSavePostSnapshotEventArgs
```csharp
public class WorldSavePostSnapshotEventArgs
{
public string OldSavePath { get; }
public string NewSavePath { get; }
}
```
#### ServerCrashedEventArgs
```csharp
public class ServerCrashedEventArgs
{
public Exception Exception { get; }
public bool Close { get; set; } // Set false to continue running
}
```
#### SocketConnectEventArgs
```csharp
public class SocketConnectEventArgs
{
public IPAddress Address { get; }
public bool AllowConnection { get; set; } // Set false to reject
}
```
### Creating Custom EventSink Events
Add to EventSink as a partial class:
```csharp
// Projects/Server/Events/MyCustomEvent.cs
namespace Server;
public static partial class EventSink
{
public static event Action<Mobile, Item> ItemCrafted;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void InvokeItemCrafted(Mobile crafter, Item item) =>
ItemCrafted?.Invoke(crafter, item);
}
```
Then invoke from game code:
```csharp
EventSink.InvokeItemCrafted(crafter, craftedItem);
```
---
## CodeGeneratedEvents
For events on specific game entities, ModernUO uses source-generated events via the `CodeGeneratedEvents` package.
External reference: https://github.com/modernuo/CodeGeneratedEvents
### Defining Generated Events
On the class that fires the event:
```csharp
[GeneratedEvent(nameof(PlayerLoginEvent))]
public static partial void PlayerLoginEvent(PlayerMobile player);
```
### Subscribing to Generated Events
On any class that handles the event:
```csharp
[OnEvent(nameof(PlayerMobile.PlayerLoginEvent))]
public static void HandlePlayerLogin(PlayerMobile player)
{
// Handle the event
}
```
### Known Generated Events
- `PlayerMobile.PlayerLoginEvent` -- Player logs in
- `PlayerMobile.PlayerDeathEvent` -- Player dies
- `BaseCreature.CreatureDeathEvent` -- Creature dies
---
## Event Args Pooling Pattern
Some EventArgs use object pooling to avoid allocation in hot paths:
```csharp
// System that fires the event:
var args = MovementEventArgs.Create(mobile, direction);
EventSink.InvokeMovement(args);
// Check args.Blocked after invocation
args.Free(); // Return to pool
```
This pattern is used for high-frequency events (movement, combat) to minimize GC pressure.
---
## Best Practices
1. **Subscribe in `Configure()`** -- called automatically during startup
2. **Check player type** -- `Connected` fires for all mobiles; cast to `PlayerMobile` if needed
3. **Keep handlers fast** -- they run on the game loop thread
4. **Use `Handled`/`Blocked`** -- on SpeechEventArgs to consume/block messages
5. **Unsubscribe on disable** -- if your system can be turned off, unsubscribe (`-=`) to prevent leaks
6. **Don't throw exceptions** -- unhandled exceptions in event handlers can crash the server
## Key File References
| File | Description |
|---|---|
| `Projects/Server/Events/EventSink.cs` | Core EventSink (partial) |
| `Projects/Server/Events/SpeechEvent.cs` | Speech event |
| `Projects/Server/Events/MovementEvent.cs` | Movement event (pooled) |
| `Projects/Server/Events/AggressiveActionEvent.cs` | Combat event (pooled) |
| `Projects/Server/Events/AccountLoginEvent.cs` | Account login |
| `Projects/Server/Events/EventSink.cs` | World save/load (WorldLoad, WorldSave, ServerStarted, Shutdown) |
| `Projects/Server/Events/SocketConnectionEvent.cs` | Socket connections |
| `Projects/Server/Events/ServerCrashedEvent.cs` | Crash handling |

507
dev-docs/gump-system.md Normal file
View file

@ -0,0 +1,507 @@
# ModernUO Gump System
This document covers ModernUO's gump (UI dialog) system, including BaseGump, StaticGump, DynamicGump, builders, response handling, and best practices.
## Overview
Gumps are custom UI dialogs displayed to players. ModernUO provides a modern gump system with two main types:
- **StaticGump**: Layout is cached and reused across instances (better performance)
- **DynamicGump**: Layout is rebuilt each time (for variable content)
## Class Hierarchy
```
BaseGump (abstract)
├── StaticGump<TSelf> -- Cached layout, for fixed-structure gumps
└── DynamicGump -- Rebuilt layout, for dynamic-structure gumps
```
All gump classes are in `Projects/UOContent/Gumps/Base/`.
## Sending Gumps
```csharp
using Server.Gumps; // REQUIRED for extension methods
// Send a gump
mobile.SendGump(new MyGump(mobile));
// Check if gump is open
if (mobile.HasGump<MyGump>()) { }
// Find an open gump
var gump = mobile.FindGump<MyGump>();
// Close a gump
mobile.CloseGump<MyGump>();
```
**Important**: `using Server.Gumps;` is required. Without it, `SendGump()`, `HasGump<T>()`, `FindGump<T>()`, and `CloseGump<T>()` extension methods won't resolve.
These methods are also available on `NetState`:
```csharp
mobile.NetState.SendGump(gump);
mobile.NetState.HasGump<MyGump>();
```
## StaticGump
Use for gumps where the layout structure is the same for all instances. The layout is compiled and cached on first use, then reused.
### Template
```csharp
using Server.Gumps;
namespace Server.Gumps;
public class MyStaticGump : StaticGump<MyStaticGump>
{
private readonly Mobile _player;
private readonly string _data;
public override bool Singleton => true; // Only one per player
public MyStaticGump(Mobile player, string data) : base(50, 50)
{
_player = player;
_data = data;
}
protected override void BuildLayout(ref StaticGumpBuilder builder)
{
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
builder.AddAlphaRegion(10, 10, 380, 280);
// Static text (baked into cached layout)
builder.AddHtmlLocalized(15, 15, 370, 20, 1060635, 0x7800); // "Warning"
// Dynamic text (placeholder filled per-instance via BuildStrings)
builder.AddHtmlPlaceholder(15, 45, 370, 200, "content", false, true);
// Buttons
builder.AddButton(100, 265, 4005, 4007, 1); // OK (buttonID=1)
builder.AddHtmlLocalized(135, 267, 100, 20, 1011036); // "OK"
builder.AddButton(250, 265, 4017, 4019, 0); // Cancel (buttonID=0 = close)
builder.AddHtmlLocalized(285, 267, 100, 20, 1011012); // "Cancel"
}
protected override void BuildStrings(ref GumpStringsBuilder builder)
{
builder.SetHtmlText("content", _data, "#FFC000", 4);
}
public override void OnResponse(NetState sender, in RelayInfo info)
{
if (info.ButtonID == 1)
{
_player.SendMessage("Confirmed!");
}
}
}
```
### How Caching Works
1. First instance calls `BuildLayout()` -- the layout bytes are compiled and cached
2. Subsequent instances reuse the cached layout bytes
3. `BuildStrings()` is called per-instance to fill dynamic text placeholders
4. Use `AddLabelPlaceholder`/`AddHtmlPlaceholder` for text that changes per instance
5. Use `AddLabel`/`AddHtml`/`AddHtmlLocalized` for text baked into the cache
### Placeholders
```csharp
// In BuildLayout:
builder.AddLabelPlaceholder(x, y, hue, "slotKey");
builder.AddHtmlPlaceholder(x, y, w, h, "slotKey", background, scrollbar);
builder.AddTextEntryPlaceholder(x, y, w, h, hue, entryId, "slotKey");
// In BuildStrings:
builder.SetStringSlot("slotKey", "text value");
builder.SetHtmlText("slotKey", "html content", "#color", fontSize);
```
## DynamicGump
Use for gumps where the layout structure varies per instance (e.g., lists of items, search results).
### Template
```csharp
using Server.Gumps;
namespace Server.Gumps;
public class MyDynamicGump : DynamicGump
{
private readonly Mobile _player;
private readonly List<Item> _items;
public override bool Singleton => true;
public MyDynamicGump(Mobile player, List<Item> items) : base(50, 50)
{
_player = player;
_items = items;
}
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
var height = 60 + _items.Count * 30;
builder.AddPage();
builder.AddBackground(0, 0, 400, height, 5054);
builder.AddAlphaRegion(10, 10, 380, height - 20);
builder.AddHtml(15, 15, 370, 20, "Select an item:");
for (var i = 0; i < _items.Count; i++)
{
var y = 45 + i * 30;
var item = _items[i];
builder.AddLabel(20, y, 0x480, item.Name ?? "Unknown");
builder.AddButton(350, y, 4005, 4007, i + 1);
}
}
public override void OnResponse(NetState sender, in RelayInfo info)
{
if (info.ButtonID > 0 && info.ButtonID <= _items.Count)
{
var selected = _items[info.ButtonID - 1];
_player.SendMessage($"You selected: {selected.Name}");
}
}
}
```
## Builder Methods Reference
### Layout Structure
```csharp
builder.AddPage(int page = 0); // Add page (0 = all pages)
builder.AddBackground(x, y, w, h, gumpID);
builder.AddAlphaRegion(x, y, w, h); // Transparent background
builder.AddImageTiled(x, y, w, h, gumpID); // Tiled background image
builder.AddGroup(int groupId); // Radio button group
```
### Images
```csharp
builder.AddImage(x, y, gumpID, hue); // Gump art image
builder.AddItem(x, y, itemID, hue); // Item graphic
builder.AddImageTiledButton(x, y, normalID, pressedID, buttonID, type, param, itemID, hue, w, h);
```
### Text
```csharp
builder.AddLabel(x, y, hue, text); // Single-line text
builder.AddLabelCropped(x, y, w, h, hue, text); // Cropped text
builder.AddHtml(x, y, w, h, text, bg, scrollbar); // HTML text
builder.AddHtml(x, y, w, h, text, color, size, fontStyle, align, bg, scrollbar);
builder.AddHtmlLocalized(x, y, w, h, clilocNumber); // Localized text
builder.AddHtmlLocalized(x, y, w, h, clilocNumber, color);
```
### Interactive Elements
```csharp
builder.AddButton(x, y, normalID, pressedID, buttonID);
builder.AddButton(x, y, normalID, pressedID, buttonID, GumpButtonType.Page, pageNum);
builder.AddCheckbox(x, y, inactiveID, activeID, selected, switchID);
builder.AddRadio(x, y, inactiveID, activeID, selected, switchID);
builder.AddTextEntry(x, y, w, h, hue, entryID, initialText);
builder.AddTextEntryLimited(x, y, w, h, hue, entryID, initialText, maxLength);
```
### Modifiers
```csharp
builder.SetNoClose(); // Disable right-click close
builder.SetNoMove(); // Disable dragging
builder.SetNoResize(); // Disable resizing
builder.SetNoDispose(); // Disable dispose
builder.AddTooltip(num); // Tooltip on hover
builder.AddItemProperty(serial); // Item property tooltip
```
## Response Handling
```csharp
public override void OnResponse(NetState sender, in RelayInfo info)
{
var mobile = sender.Mobile;
// Button ID (0 = close/cancel, 1+ = custom buttons)
switch (info.ButtonID)
{
case 0: return; // Closed
case 1:
// Handle button 1
break;
}
// Check checkbox/radio state
bool isChecked = info.IsSwitched(switchID);
// Get text entry value
string text = info.GetTextEntry(entryID);
}
```
### Button ID Convention
- `0` = Close/Cancel (default when player closes gump)
- `1+` = Custom action buttons
- Use `GumpButtonType.Page` for page navigation buttons (don't trigger OnResponse)
## BaseGump Properties
```csharp
public int X { get; set; } // Gump X position
public int Y { get; set; } // Gump Y position
public virtual bool Singleton => false; // Only one instance per player
public int TypeID { get; } // Unique type identifier
public Serial Serial { get; } // Gump serial
```
## Common Gump IDs (Background Art)
| ID | Description |
|---|---|
| 5054 | Dark stone background |
| 9200 | Scroll background |
| 9250 | Light parchment |
| 3600 | Brown wood panel |
| 5120 | Gray stone border |
| 2620 | Ornate gold frame |
## Common Button IDs (Art)
| Normal/Pressed | Description |
|---|---|
| 4005/4007 | Small right arrow (green) |
| 4017/4019 | Small X (red) |
| 4023/4025 | Small left arrow |
| 4020/4022 | Small checkmark |
| 4029/4031 | Large right arrow |
| 247/248 | Large green gem |
| 241/242 | Large red gem |
## Important Properties
### Singleton
```csharp
public override bool Singleton => true;
```
When `true`, the gump system automatically closes any existing instance of this gump type for the player before sending a new one. **Always set this for gumps that shouldn't stack.** Without it, repeated sends create duplicate gumps the player must close individually.
### Cached (StaticGump only)
```csharp
protected virtual bool Cached => true; // default
```
Controls whether `StaticGump<T>` caches its compiled layout. The layout is compiled once on first send, then reused for all subsequent instances.
**Set to `false` during development** to force the layout to rebuild every send — useful for hot-reload iteration and debugging layout changes without restarting the server:
```csharp
// Temporary: disable caching while iterating on layout
protected override bool Cached => false;
```
**Remove the override (or set back to `true`) before committing.** Leaving it `false` in production wastes CPU recompiling identical layouts.
## Empty Gump Rule (CRITICAL)
**NEVER send a gump with no visual components.** An empty gump (no background, no buttons, no content) has no close button and no right-click dismiss — the client cannot close it. This causes a **gump leak** on both the client and server: the gump stays in the tracking list forever, the client renders an invisible undismissable element, and the slot is consumed until the player relogs.
Empty gumps typically happen when a developer short-circuits inside the constructor or `BuildLayout`:
```csharp
// BAD: Short-circuit in constructor creates an empty gump
public MyGump(Mobile from) : base(50, 50)
{
if (!from.Alive)
return; // Gump is already constructed — it's empty but will still be sent!
AddPage(0);
AddBackground(0, 0, 400, 300, 5054);
// ...
}
```
### The Fix: Static DisplayTo Pattern
Use a static entry-point method that validates prerequisites **before** constructing the gump. The constructor is private — the only way to create the gump is through `DisplayTo`, which guarantees the gump is never empty. See `GoGump.cs` for the canonical example:
```csharp
public class MyGump : DynamicGump // or StaticGump<MyGump>, or Gump
{
public override bool Singleton => true;
// Private constructor — can only be called from DisplayTo
private MyGump(Mobile from, SomeData data) : base(50, 50)
{
// Safe to build layout — prerequisites already validated
}
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
// Always produces visual content — DisplayTo guarantees valid state
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
// ...
}
// Static entry point — validates before constructing
public static void DisplayTo(Mobile from)
{
if (!from.Alive || from.NetState == null)
return; // No gump created at all
var data = GetData(from);
if (data == null)
return; // No gump created at all
from.SendGump(new MyGump(from, data));
}
}
```
**Key points:**
- Constructor is `private` — enforces that `DisplayTo` is the only entry point
- All validation/short-circuiting happens in `DisplayTo` before `new MyGump(...)` is called
- If prerequisites fail, no gump is constructed or sent
- The constructor and `BuildLayout` can assume valid state and always produce visual output
- Reference implementation: `Projects/UOContent/Gumps/Go/GoGump.cs`
## Converting Legacy Gump to DynamicGump / StaticGump
The legacy `Gump` class (in `Gumps/Base/Legacy/Gump.cs`) builds layouts by appending `GumpEntry` objects to a list. The modern `DynamicGump` and `StaticGump<T>` use ref struct builders that write directly to buffers — fewer allocations, better performance.
### Step-by-Step Conversion
#### 1. Choose the target type
| If the layout... | Convert to |
|---|---|
| Is the same structure every time (fixed elements, maybe some dynamic text) | `StaticGump<T>` |
| Changes shape based on instance data (loops, conditionals that add/remove elements) | `DynamicGump` |
When in doubt, use `DynamicGump` — it's simpler and still much better than legacy `Gump`.
#### 2. Change the class declaration
```csharp
// Legacy
public class MyGump : Gump
// Modern — pick one:
public class MyGump : DynamicGump
public class MyGump : StaticGump<MyGump>
```
#### 3. Move layout code into BuildLayout
Legacy gumps build their layout in the constructor. Modern gumps build it in `BuildLayout`:
```csharp
// Legacy — layout in constructor
public class OldGump : Gump
{
public OldGump(Mobile from) : base(50, 50)
{
AddPage(0);
AddBackground(0, 0, 400, 300, 5054);
AddLabel(20, 20, 0x480, "Hello");
AddButton(20, 260, 4005, 4007, 1);
}
}
// Modern DynamicGump — layout in BuildLayout
public class NewGump : DynamicGump
{
private readonly Mobile _from;
public override bool Singleton => true;
private NewGump(Mobile from) : base(50, 50)
{
_from = from;
}
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
builder.AddLabel(20, 20, 0x480, "Hello");
builder.AddButton(20, 260, 4005, 4007, 1);
}
public static void DisplayTo(Mobile from)
{
from.SendGump(new NewGump(from));
}
}
```
#### 4. Key API differences
| Legacy `Gump` | Modern builder |
|---|---|
| `AddPage(0)` | `builder.AddPage()` (0 is the default) |
| `AddHtml(x, y, w, h, text, bg, scroll)` | `builder.AddHtml(x, y, w, h, text, background: bg, scrollbar: scroll)` |
| `Closable = false` | `builder.SetNoClose()` |
| `Draggable = false` | `builder.SetNoMove()` |
| `Resizable = false` | `builder.SetNoResize()` |
| `Disposable = false` | `builder.SetNoDispose()` |
| `AddLabel(x, y, hue, string)` | `builder.AddLabel(x, y, hue, ReadOnlySpan<char>)` |
| `Intern(string)` / string list | Not needed — builder handles strings internally |
#### 5. For StaticGump: extract dynamic text into placeholders
If converting to `StaticGump<T>` and some text varies per instance, replace those `AddLabel`/`AddHtml` calls with placeholder versions and fill them in `BuildStrings`:
```csharp
// In BuildLayout:
builder.AddLabelPlaceholder(20, 20, 0x480, "playerName");
builder.AddHtmlPlaceholder(20, 50, 360, 200, "description", false, true);
// In BuildStrings:
protected override void BuildStrings(ref GumpStringsBuilder builder)
{
builder.SetStringSlot("playerName", _from.Name);
builder.SetHtmlText("description", _description, "#FFC000", 4);
}
```
#### 6. Update OnResponse signature
```csharp
// Legacy
public override void OnResponse(NetState sender, RelayInfo info)
// Modern (RelayInfo is passed by ref)
public override void OnResponse(NetState sender, in RelayInfo info)
```
#### 7. Add DisplayTo and make constructor private
Always add a static `DisplayTo` method and make the constructor private to prevent empty gumps (see Empty Gump Rule above).
## When to Use Which
| Scenario | Type | Reason |
|---|---|---|
| Confirmation dialog | StaticGump | Fixed layout, shown frequently |
| Warning prompt | StaticGump | Fixed layout |
| Settings menu | StaticGump | Fixed structure |
| Item list (variable length) | DynamicGump | Layout depends on data |
| Craft menu | DynamicGump | Player-specific recipes |
| Search results | DynamicGump | Variable result count |
| Vendor inventory | DynamicGump | Different items per vendor |
## Key File References
| File | Description |
|---|---|
| `Projects/UOContent/Gumps/Base/BaseGump.cs` | Abstract base class |
| `Projects/UOContent/Gumps/Base/StaticGump.cs` | Cached static gump |
| `Projects/UOContent/Gumps/Base/DynamicGump.cs` | Dynamic gump |
| `Projects/UOContent/Gumps/Base/StaticGumpBuilder.cs` | Static layout builder |
| `Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs` | Dynamic layout builder |
| `Projects/UOContent/Gumps/Base/GumpStringsBuilder.cs` | String slot builder |
| `Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs` | Shared layout methods |
| `Projects/UOContent/Gumps/Base/GumpSystem.cs` | Extension methods |
| `Projects/UOContent/Gumps/StaticWarningGump.cs` | Example static gump |

View file

@ -0,0 +1,412 @@
# ModernUO Networking & Packets
This document covers ModernUO's networking system, including outgoing and incoming packet patterns, SpanWriter/SpanReader, and NetState extensions.
## Overview
ModernUO uses a binary packet protocol for client-server communication. The system is built around:
- **Outgoing packets**: Static `Create*` methods + `Send*` extension methods on `NetState`
- **Incoming packets**: Function pointer handlers registered in `Configure()`
- **SpanWriter/SpanReader**: High-performance binary I/O using `Span<byte>`
## Outgoing Packet Pattern
### Step 1: Define Constants and Create Method
```csharp
public static class OutgoingMyPackets
{
public const int MyPacketLength = 12; // Fixed-size packet
public static void CreateMyPacket(Span<byte> buffer, Serial target, int value)
{
if (buffer[0] != 0) // Already initialized guard
return;
var writer = new SpanWriter(buffer);
writer.Write((byte)0xBF); // Packet ID
writer.Write((ushort)12); // Packet length
writer.Write((ushort)0x99); // Sub-command
writer.Write(target); // Serial (4 bytes)
writer.Write((short)value); // Value (2 bytes)
}
}
```
### Step 2: Define Send Extension Method
```csharp
public static void SendMyPacket(this NetState ns, Serial target, int value)
{
if (ns.CannotSendPackets())
return;
var buffer = stackalloc byte[MyPacketLength].InitializePacket();
CreateMyPacket(buffer, target, value);
ns.Send(buffer);
}
```
### Step 3: Call from Game Code
```csharp
// Send to one player
mobile.NetState.SendMyPacket(target.Serial, 42);
// Send to nearby players
foreach (var ns in mobile.GetClientsInRange(18))
{
ns.SendMyPacket(target.Serial, 42);
}
```
### Variable-Length Outgoing Packets
```csharp
public static void SendMyDynamicPacket(this NetState ns, string name, int[] values)
{
if (ns.CannotSendPackets())
return;
var length = 7 + name.Length * 2 + values.Length * 4;
var writer = new SpanWriter(stackalloc byte[length]);
writer.Write((byte)0x99); // Packet ID
writer.Write((ushort)0); // Length placeholder
writer.WriteBigUniNull(name); // Unicode string
writer.Write((ushort)values.Length);
foreach (var val in values)
writer.Write(val);
writer.WritePacketLength(); // Fill in actual length at position 1-2
ns.Send(writer.Span);
}
```
### Shared Buffer Pattern (Multiple Recipients)
When sending the same packet to multiple players, create the buffer once:
```csharp
public static void SendToNearby(Mobile source, int effectId)
{
Span<byte> buffer = stackalloc byte[EffectPacketLength];
buffer.InitializePacket();
foreach (var ns in source.GetClientsInRange(18))
{
// CreateXxx checks buffer[0] != 0 to avoid re-initializing
CreateEffectPacket(buffer, source.Serial, effectId);
ns.Send(buffer);
}
}
```
---
## Incoming Packet Pattern
### Step 1: Register Handler in Configure()
```csharp
public static class IncomingMyPackets
{
public static unsafe void Configure()
{
// Fixed-length packet (12 bytes, in-game only)
IncomingPackets.Register(0x99, 12, true, &MyHandler);
// Variable-length packet (0 = variable)
IncomingPackets.Register(0x9A, 0, true, &MyDynamicHandler);
// Out-of-game packet
IncomingPackets.Register(0x9B, 10, false, &LoginHandler);
// Encoded packet (sub-command)
IncomingPackets.RegisterEncoded(0x28, true, &EncodedHandler);
}
}
```
### Step 2: Implement Handler
```csharp
public static void MyHandler(NetState state, SpanReader reader)
{
var from = state.Mobile;
if (from == null)
return;
var targetSerial = (Serial)reader.ReadUInt32();
var value = reader.ReadInt16();
var target = World.FindMobile(targetSerial);
if (target == null)
return;
// Process packet...
}
public static void MyDynamicHandler(NetState state, SpanReader reader)
{
var from = state.Mobile;
if (from == null)
return;
var name = reader.ReadBigUniSafe();
var count = reader.ReadUInt16();
for (var i = 0; i < count; i++)
{
var val = reader.ReadInt32();
// Process each value...
}
}
```
### Encoded Packet Handler
```csharp
public static void EncodedHandler(NetState state, IEntity target, EncodedReader reader)
{
// Encoded packets have a different signature
var from = state.Mobile;
if (from == null)
return;
// Process...
}
```
### Registration Parameters
```csharp
IncomingPackets.Register(
int packetID, // Packet identifier (0x00-0xFF)
int length, // Fixed length, or 0 for variable-length
bool inGameOnly, // Requires authenticated player
delegate*<NetState, SpanReader, void> handler // Function pointer
);
```
---
## SpanWriter Reference
High-performance ref struct for writing binary data. Defined in `Projects/Server/Buffers/SpanWriter.cs`.
### Constructors
```csharp
var writer = new SpanWriter(Span<byte> buffer); // Fixed buffer
var writer = new SpanWriter(stackalloc byte[64]); // Stack buffer
var writer = new SpanWriter(int capacity, bool resize = false); // Pooled buffer
```
### Integer Writes (Big-Endian by Default)
```csharp
writer.Write(bool value); // 1 byte
writer.Write(byte value); // 1 byte
writer.Write(sbyte value); // 1 byte
writer.Write(short value); // 2 bytes, big-endian
writer.Write(ushort value); // 2 bytes, big-endian
writer.Write(int value); // 4 bytes, big-endian
writer.Write(uint value); // 4 bytes, big-endian
writer.Write(long value); // 8 bytes, big-endian
writer.Write(ulong value); // 8 bytes, big-endian
writer.Write(Serial serial); // 4 bytes (writes serial.Value)
```
### Little-Endian Variants
```csharp
writer.WriteLE(short value);
writer.WriteLE(ushort value);
writer.WriteLE(int value);
writer.WriteLE(uint value);
```
### String Writes
```csharp
// ASCII (1 byte per char)
writer.WriteAscii(string value);
writer.WriteAsciiNull(string value); // Null-terminated
writer.WriteAscii(string value, int fixedLength);
// Latin-1 (1 byte per char, extended ASCII)
writer.WriteLatin1(string value);
writer.WriteLatin1Null(string value);
writer.WriteLatin1(string value, int fixedLength);
// UTF-16 Big-Endian (UO standard for Unicode)
writer.WriteBigUni(string value);
writer.WriteBigUniNull(string value);
writer.WriteBigUni(string value, int fixedLength);
// UTF-16 Little-Endian
writer.WriteLittleUni(string value);
writer.WriteLittleUniNull(string value);
writer.WriteLittleUni(string value, int fixedLength);
// UTF-8
writer.WriteUTF8(string value);
writer.WriteUTF8Null(string value);
```
### Utilities
```csharp
writer.Write(ReadOnlySpan<byte> data); // Raw bytes
writer.Clear(int count); // Write zeros
writer.Seek(int offset, SeekOrigin origin); // Move position
writer.WritePacketLength(); // Fill length at position 1-2
writer.EnsureCapacity(int capacity); // Grow buffer if needed
writer.Dispose(); // Return pooled buffer
// Properties
writer.Position; // Current write position
writer.Capacity; // Buffer size
writer.Span; // ReadOnlySpan<byte> of written data
writer.RawBuffer; // Mutable Span<byte> of full buffer
```
---
## SpanReader Reference
High-performance ref struct for reading binary data. Defined in `Projects/Server/Buffers/SpanReader.cs`.
### Constructor
```csharp
var reader = new SpanReader(ReadOnlySpan<byte> data);
```
### Integer Reads (Big-Endian by Default)
```csharp
reader.ReadByte(); // 1 byte
reader.ReadBoolean(); // 1 byte (> 0 = true)
reader.ReadSByte(); // 1 byte signed
reader.ReadInt16(); // 2 bytes, big-endian
reader.ReadUInt16(); // 2 bytes, big-endian
reader.ReadInt32(); // 4 bytes, big-endian
reader.ReadUInt32(); // 4 bytes, big-endian
reader.ReadInt64(); // 8 bytes, big-endian
reader.ReadUInt64(); // 8 bytes, big-endian
```
### Little-Endian Variants
```csharp
reader.ReadInt16LE();
reader.ReadUInt16LE();
reader.ReadUInt32LE();
```
### String Reads
```csharp
// Each has a "Safe" variant that filters control characters
reader.ReadAscii(int fixedLength = -1);
reader.ReadAsciiSafe(int fixedLength = -1);
reader.ReadLatin1(int fixedLength = -1);
reader.ReadLatin1Safe(int fixedLength = -1);
reader.ReadBigUni(int fixedLength = -1);
reader.ReadBigUniSafe(int fixedLength = -1);
reader.ReadLittleUni(int fixedLength = -1);
reader.ReadLittleUniSafe(int fixedLength = -1);
reader.ReadUTF8(int fixedLength = -1);
reader.ReadUTF8Safe(int fixedLength = -1);
```
### Utilities
```csharp
reader.Seek(int offset, SeekOrigin origin);
reader.Read(Span<byte> destination);
// Properties
reader.Position; // Current read position
reader.Length; // Total data length
reader.Remaining; // Bytes remaining
reader.Buffer; // ReadOnlySpan<byte> of full data
```
---
## Common Existing Send Methods
### Effects and Sounds
```csharp
ns.SendSoundEffect(int soundID, IPoint3D target);
ns.SendMobileAnimation(Serial mobile, int action, int frames, int repeat, bool forward, bool loop, int delay);
ns.SendNewMobileAnimation(Serial mobile, int action, int frames, int delay);
```
### Mobile Status
```csharp
ns.SendMobileHits(Mobile m, bool normalize = false);
ns.SendMobileMana(Mobile m, bool normalize = false);
ns.SendMobileStam(Mobile m, bool normalize = false);
ns.SendMobileAttributes(Mobile m, bool normalize = false);
ns.SendMobileStatus(Mobile m);
ns.SendMobileName(Mobile m);
ns.SendMobileMoving(Mobile source, Mobile target);
ns.SendBondedStatus(Serial serial, bool bonded);
ns.SendDeathAnimation(Serial killed, Serial corpse);
```
### Damage
```csharp
ns.SendDamage(Serial serial, int amount);
```
### Targeting
```csharp
ns.SendTargetReq(Target target);
ns.SendMovementRej(int sequence, Mobile m);
```
---
## Protocol Notes
- **Endianness**: UO protocol is big-endian by default
- **Packet ID**: First byte identifies the packet type (0x00-0xFF)
- **Length**: For variable-length packets, bytes 1-2 are the total length (big-endian ushort)
- **Serials**: 4-byte identifiers for items (0x40000000+) and mobiles (0x00000001+)
- **Clilocs**: 4-byte localized string IDs
## Best Practices
1. **Always check `ns.CannotSendPackets()`** before sending
2. **Use `stackalloc`** for fixed-size packets (avoids heap allocation)
3. **Use `InitializePacket()`** extension on stackalloc spans
4. **Use `ReadAsciiSafe`/`ReadBigUniSafe`** for incoming strings (filters control chars)
5. **Use `WritePacketLength()`** for variable-length packets
6. **Big-endian by default** -- only use `WriteLE`/`ReadLE` when the protocol requires it
7. **Function pointers** (`&Handler`) for incoming packet registration (no delegate allocation)
## Key File References
| File | Description |
|---|---|
| `Projects/Server/Buffers/SpanWriter.cs` | SpanWriter ref struct |
| `Projects/Server/Buffers/SpanReader.cs` | SpanReader ref struct |
| `Projects/Server/Network/Packets/IncomingPackets.cs` | Packet registration |
| `Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs` | Player packet handlers |
| `Projects/UOContent/Network/Packets/IncomingMovementPackets.cs` | Movement handlers |
| `Projects/UOContent/Network/Packets/IncomingMessagePackets.cs` | Speech handlers |
| `Projects/UOContent/Network/Packets/IncomingItemPackets.cs` | Item handlers |
| `Projects/UOContent/Network/Packets/IncomingTargetingPackets.cs` | Targeting handlers |
| `Projects/Server/Network/Packets/OutgoingMobilePackets.cs` | Mobile packets |
| `Projects/Server/Network/Packets/OutgoingItemPackets.cs` | Item packets |
| `Projects/Server/Network/Packets/OutgoingDamagePackets.cs` | Damage packets |
| `Projects/Server/Network/Packets/OutgoingEffectPackets.cs` | Effect/sound packets |
| `Projects/Server/Network/Packets/OutgoingAccountPackets.cs` | Account packets |
| `Projects/Server/Network/Packets/OutgoingContainerPackets.cs` | Container packets |
| `Projects/Server/Network/PacketHandler.cs` | PacketHandler class |

275
dev-docs/property-lists.md Normal file
View file

@ -0,0 +1,275 @@
# ModernUO Property Lists (Tooltips)
This document covers ModernUO's property list system for item and mobile tooltips, including the IPropertyList interface, ObjectPropertyList internals, and patterns for customizing tooltips.
## Overview
Property lists (also called Object Property Lists or OPL) are the tooltip popups that appear when a player hovers over items and mobiles. They display the item name, stats, charges, and other relevant information.
The system uses cliloc numbers (localized string IDs) with argument substitution to support multiple languages.
## IPropertyList Interface
Defined in `Projects/Server/PropertyList/IPropertyList.cs`:
```csharp
public interface IPropertyList
{
void Add(int number); // Cliloc number only
void Add(int number, string argument); // Cliloc with string argument
void Add(string text); // Raw string
void Add(int number, int value); // Cliloc with int argument
void AddLocalized(int value); // Cliloc number as argument value
void AddLocalized(int number, int value); // Cliloc with localized argument
// String interpolation support
void Add(ref InterpolatedStringHandler handler);
void Add(int number, ref InterpolatedStringHandler handler);
}
```
## GetProperties Override
Override `GetProperties()` to add custom tooltip lines:
```csharp
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list); // ALWAYS call base first
// Add cliloc with value
list.Add(1060741, $"{_charges}"); // "charges: ~1_val~"
// Add raw string
list.Add($"{"Quality: "}{_quality}");
// Add cliloc with multiple tab-separated arguments
list.Add(1060637, $"{_current}\t{_max}"); // "~1_val~ / ~2_val~"
// Add cliloc number only (no arguments)
list.Add(1049644); // "Crafted by a Grandmaster"
// Add int argument
list.Add(1060741, _charges); // "charges: ~1_val~"
}
```
## Cliloc Argument Format
Cliloc strings contain placeholders like `~1_val~`, `~2_val~`, etc. Multiple arguments are separated by tab characters (`\t`):
```csharp
// Cliloc 1060637 = "~1_val~ / ~2_val~"
list.Add(1060637, $"{current}\t{max}");
// Cliloc 1072241 = "Contents: ~1_ITEMS~/~2_MAXITEMS~ items, ~3_WEIGHT~/~4_MAXWEIGHT~ stones"
list.Add(1072241, $"{TotalItems}\t{MaxItems}\t{TotalWeight}\t{MaxWeight}");
```
### String Literals Must Be Holes (CRITICAL)
The `IPropertyList` interpolated string handler distinguishes between **literals** (text between `{}` holes) and **holes** (values inside `{}`). Literals are treated as delimiters (like `\t`). Holes are treated as arguments. The property list system is used beyond just the game client — for example, web rendering — which must be able to tell arguments apart from delimiters.
**This means string constants must always be wrapped as holes using `{"..."}` syntax:**
```csharp
// BAD — "Chances" becomes a literal/delimiter, not an argument
list.Add(1060658, $"Chances\t{_charges}");
// GOOD — "Chances" is a hole, so it's treated as argument ~1_val~
list.Add(1060658, $"{"Chances"}\t{_charges}");
```
Real examples from the codebase (`Teleporter.cs`):
```csharp
// Cliloc 1060658 = "~1_val~: ~2_val~"
list.Add(1060658, $"{"Map"}\t{_mapDest}");
list.Add(1060659, $"{"Coords"}\t{_pointDest}");
list.Add(1060660, $"{"Creatures"}\t{(Creatures ? "Yes" : "No")}");
list.Add(1060661, $"{"Range"}\t{_range}");
```
The compiler generates different calls for each:
- `$"Map\t{value}"``AppendLiteral("Map\t")` then `AppendFormatted(value)` — "Map\t" is a delimiter, only `value` is an argument
- `$"{"Map"}\t{value}"``AppendFormatted("Map")` then `AppendLiteral("\t")` then `AppendFormatted(value)` — both "Map" and `value` are arguments, `\t` is the delimiter
**Rule of thumb**: The only text that should appear as bare literals in the interpolated string is `\t` (the argument separator). Everything else — including string constants — must be inside `{}` holes.
### Cliloc as Argument (Use `:#` Format Specifier)
When a cliloc argument is itself another cliloc number (i.e., the argument should resolve to localized text), use the `:#` format specifier on the integer — **not** a `"#number"` string:
```csharp
// BAD — "#1060000" is a string, not a cliloc reference.
// Other systems (web rendering) will render it as the literal text "#1060000"
list.Add(1050039, $"{m_Amount}\t{"#1060000"}");
// GOOD — 1060000:# tells the handler this argument is a cliloc number to resolve
list.Add(1050039, $"{m_Amount}\t{1060000:#}");
```
The `:#` format specifier is a hint that the value is a cliloc number. The handler calls `AppendFormatted(int value, string? format)` which, when `format == "#"`, resolves the cliloc and appends the localized text. Other consumers of the property list data (like web renderers) can see the `:#` format and know to look up the cliloc text rather than displaying a raw number.
This also works with the `AddLocalized` convenience methods:
```csharp
// These use :# internally
list.AddLocalized(clilocNumber); // Single cliloc value as argument
list.AddLocalized(1050039, clilocNumber); // Cliloc with cliloc argument
```
### Looking Up Cliloc Text
If you don't know what text a cliloc number maps to (and therefore what arguments it expects), you can read the `cliloc.enu` binary file. The loading logic is in `Projects/Server/Localization/Localization.cs`, method `LoadClilocs(string lang, string file)`:
- File format: 6-byte header, then repeating entries of `int number` + `byte flag` + `ushort length` + UTF-8 text
- Placeholders in the text look like `~1_val~`, `~2_AMOUNT~`, etc.
- Ask the user where their `cliloc.enu` file is located (typically in the UO client data directory)
## Common Cliloc Numbers
| Number | Text | Usage |
|---|---|---|
| 1042971 | `~1_val~` | Generic single value |
| 1060741 | `charges: ~1_val~` | Charge count |
| 1060637 | `~1_val~ / ~2_val~` | Current/max values |
| 1060658 | `~1_val~: ~2_val~` | Key: value pair |
| 1050044 | `~1_ITEMS~ items, ~2_WEIGHT~ stones` | Container contents (pre-ML) |
| 1072241 | `Contents: ~1~/~2~ items, ~3~/~4~ stones` | Container contents (ML+) |
| 1060776 | `~1_val~, ~2_val~` | Two comma-separated values |
| 1053099 | `damage ~1_val~ - ~2_val~` | Damage range |
| 1061170 | `animal lore ~1_val~` | Taming info |
| 1049644 | `Crafted by a Grandmaster` | Crafting quality |
| 1042001 | `That must be in your pack...` | Backpack requirement message |
| 1011036 | `OK` | OK button text |
| 1011012 | `CANCEL` | Cancel button text |
| 1060635 | `Warning` | Warning header |
## Auto-Refresh with [InvalidateProperties]
When using `[SerializableField]`, adding `[InvalidateProperties]` automatically calls `InvalidateProperties()` whenever the generated property setter is invoked:
```csharp
[SerializableField(0)]
[InvalidateProperties] // Auto-refreshes tooltip when Charges changes
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
```
The generated setter becomes:
```csharp
public int Charges
{
get => _charges;
set
{
_charges = value;
InvalidateProperties(); // Added by [InvalidateProperties]
this.MarkDirty();
}
}
```
## Manual Refresh
Call `InvalidateProperties()` when non-serialized state changes affect the tooltip:
```csharp
public void UseCharge()
{
_charges--;
InvalidateProperties(); // Force tooltip rebuild
this.MarkDirty();
}
```
## ObjectPropertyList Internals
Defined in `Projects/Server/PropertyList/ObjectPropertyList.cs`:
- **Packet ID**: 0xD6
- **Hash-based updates**: Each property list has a hash. When `InvalidateProperties()` is called, the list is rebuilt and compared. Only if the hash changed is the new list sent to clients.
- **String building**: Uses `STArrayPool<char>` for zero-GC string construction
- **Global toggle**: `ObjectPropertyList.Enabled` can disable the entire system
- **Lazy initialization**: Property lists are built on first access
### Update Flow
1. `InvalidateProperties()` is called
2. If map is valid and world isn't loading:
a. Save old hash
b. Reset and rebuild property list via `GetProperties()`
c. Compare new hash with old hash
d. If changed, queue delta update to clients via `Delta(ItemDelta.Properties)`
## Era-Conditional Properties
```csharp
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
if (Core.ML)
{
if (ParentsContain<BankBox>())
list.Add(1073841, $"{TotalItems}\t{MaxItems}\t{TotalWeight}");
else
list.Add(1072241, $"{TotalItems}\t{MaxItems}\t{TotalWeight}\t{MaxWeight}");
}
else
{
list.Add(1050044, $"{TotalItems}\t{TotalWeight}");
}
}
```
## Mobile Properties
Mobiles can also have property lists:
```csharp
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
if (Core.AOS && Faction != null)
{
list.Add(1060776, $"{Rank.Title}\t{Faction.Definition.PropName}");
}
if (_guildTitle != null)
list.Add($"[{_guildTitle}]");
}
```
## Item Built-in Property Methods
`Item` provides several helper methods called during property list building:
```csharp
public virtual void AddNameProperty(IPropertyList list) // Item name
public virtual void AddLootTypeProperty(IPropertyList list) // Blessed/Cursed/etc.
public virtual void AddResistanceProperties(IPropertyList list) // Resistance values
public virtual void AddWeightProperty(IPropertyList list) // Weight display
public virtual void AddQuestItemProperty(IPropertyList list) // Quest item marker
public virtual void AddSecureProperty(IPropertyList list) // Secure container marker
```
## Best Practices
1. **Always call `base.GetProperties(list)` first** -- it adds the item name and standard properties
2. **Use cliloc numbers** over raw strings when possible for localization support
3. **Use string interpolation** with `$""` for clean argument formatting
4. **Use tab (`\t`) to separate** multiple arguments in a single cliloc
5. **Don't call `InvalidateProperties()` in tight loops** -- it triggers hash computation and potential network sends
6. **Use `[InvalidateProperties]`** on serialized fields to automate refresh
7. **Check era** when properties differ between expansions
## Key File References
| File | Description |
|---|---|
| `Projects/Server/PropertyList/IPropertyList.cs` | Interface definition |
| `Projects/Server/PropertyList/ObjectPropertyList.cs` | Implementation |
| `Projects/Server/PropertyList/IObjectPropertyListEntity.cs` | Entity interface |
| `Projects/Server/Items/Item.cs` | Item.GetProperties, InvalidateProperties |
| `Projects/UOContent/Mobiles/PlayerMobile.cs` | Mobile property example |
| `Projects/Server/Items/Container.cs` | Era-conditional properties |

567
dev-docs/regions.md Normal file
View file

@ -0,0 +1,567 @@
# ModernUO Region System
This document covers ModernUO's region system: spatial areas on the map that control gameplay rules, spawning, spell restrictions, housing, combat, lighting, and more.
## Overview
Regions are named, polygonal volumes on a map. When a mobile enters a region, the server calls that region's virtual hooks to control behavior. Regions are hierarchical — a child region inherits its parent's behavior and can selectively override it.
Regions are loaded from `Data/regions.json` at startup via polymorphic JSON deserialization, but they can also be created **dynamically** at runtime (e.g., house regions, champion spawn areas).
## Class Hierarchy
```
Region (Server) ← Engine-level base, 30+ virtual hooks
└─ BaseRegion (UOContent) ← Game-level base: CheckTravel, spawn weights, RuneName
├─ GuardedRegion ← NPC guards, vendor access, spell restrictions in town
│ ├─ NoHousingGuardedRegion ← GuardedRegion that also blocks housing overlap checks
│ └─ TownRegion ← Type marker for towns (adds Entrance property)
├─ DungeonRegion ← Dungeon lighting, no housing, young not protected
│ └─ NoTravelSpellsAllowedRegion ← Blocks all travel spells for players
│ └─ MondainRegion ← Mondain's Legacy dungeon base
│ ├─ CrystalFieldRegion, IcyRiverRegion, ... (damage boost regions)
├─ NoHousingRegion ← Blocks housing placement (manual overlap check)
├─ GreenAcresRegion ← No housing, no travel, no Mark spell
├─ JailRegion ← Full lockdown: no skills, spells, travel, combat
├─ HouseRegion ← Dynamic: one per house, access/ban/lockdown control
├─ ChampionSpawnRegion ← Dynamic: champion spawn area effects
└─ (many more specialized regions)
```
## Region (Server Engine)
`Server.Region` is the engine-level base class in `Projects/Server/Regions/Region.cs`. It provides:
### Key Properties
| Property | Type | Description |
|---|---|---|
| `Name` | `string` | Region identifier (unique per map for JSON regions) |
| `Map` | `Map` | The map this region belongs to |
| `Parent` | `Region` | Parent region (null for top-level) |
| `Children` | `List<Region>` | Child regions nested inside this one |
| `Area` | `Rectangle3D[]` | Spatial bounds (one or more 3D rectangles) |
| `Priority` | `int` | Sort priority (default 50); higher = checked first |
| `ChildLevel` | `int` | Nesting depth (0 for top-level) |
| `Dynamic` | `bool` | True if created via constructor (not from JSON) |
| `Registered` | `bool` | Whether the region is active on the map |
| `GoLocation` | `Point3D` | Recall/bind target location |
| `Music` | `MusicName` | Music played for players in this region |
### Sort Order
Regions in each map sector are sorted by:
1. **Dynamic regions first** — runtime regions take precedence over JSON-defined ones
2. **Higher priority first**`DefaultPriority` is 50
3. **Higher child level first** — more deeply nested regions win
This means a child region always takes precedence over its parent, and dynamic regions (like `HouseRegion`) override static JSON regions.
### Static Lookup Methods
```csharp
// Find the most specific region at a world location (fast — uses sector index)
Region region = Region.Find(point3D, map);
// Find a named region on a map (slow — linear scan, use sparingly)
Region region = Region.Find("Britain", Map.Felucca);
```
### Hierarchy Traversal
```csharp
// Walk up the parent chain looking for a specific type
var dungeon = region.GetRegion<DungeonRegion>(); // null if not in a dungeon
var guarded = region.GetRegion<GuardedRegion>();
// Check if this region is a child of (or equal to) a specific region/type
bool inDungeon = region.IsPartOf<DungeonRegion>();
bool inBritain = region.IsPartOf("Britain");
// Check two types at once (avoids double traversal)
bool inDungeonOrGuarded = region.IsPartOf<DungeonRegion, GuardedRegion>();
```
### Registration / Unregistration
```csharp
region.Register(); // Adds to map sectors, Region.Regions list, parent's Children
region.Unregister(); // Removes from all of the above
```
Both are **idempotent** — calling `Register()` on an already-registered region is a no-op.
`Register()` does:
1. Calls `OnRegister()` virtual
2. Adds self to `Parent.Children` (if parent exists)
3. Adds self to `Region.Regions` global list
4. Adds self to relevant `Map.Sector` lists
5. Stores sector references in `Sectors` array
`Unregister()` does the reverse. **Warning**: unregistering a region that still has children logs a warning — unregister children first.
### Querying Region Contents
```csharp
// Get all players in this region (pooled — zero-alloc)
using var players = region.GetPlayersPooled();
// Get all mobiles in this region (pooled)
using var mobiles = region.GetMobilesPooled();
// Get count without allocating
int count = region.GetPlayerCount();
```
## Virtual Hooks
Region provides 30+ virtual methods. Each default implementation **delegates to Parent** (or returns a default if no parent). This means child regions automatically inherit parent behavior — you only override what you need.
### Lifecycle
| Method | Called when |
|---|---|
| `OnRegister()` | Region is registered on the map |
| `OnUnregister()` | Region is unregistered |
| `OnChildAdded(Region)` | A child region registers under this parent |
| `OnChildRemoved(Region)` | A child region unregisters |
### Movement & Entry
| Method | Called when |
|---|---|
| `OnMoveInto(Mobile, Direction, newLoc, oldLoc)` | Mobile attempts to enter; return false to block |
| `OnEnter(Mobile)` | Mobile enters this region |
| `OnExit(Mobile)` | Mobile leaves this region |
| `OnLocationChanged(Mobile, oldLoc)` | Mobile moves within the region |
### Combat & Interaction
| Method | Called when |
|---|---|
| `AllowHarmful(Mobile from, Mobile target)` | PvP/PvM harm attempt |
| `AllowBeneficial(Mobile from, Mobile target)` | Healing/buffing attempt |
| `OnAggressed(Mobile aggressor, Mobile aggressed, bool criminal)` | Aggression committed |
| `OnDidHarmful(Mobile harmer, Mobile harmed)` | Harmful action completed |
| `OnCriminalAction(Mobile, bool message)` | Criminal act committed |
| `OnCombatantChange(Mobile, old, new)` | Target change; return false to block |
| `SpellDamageScalar(Mobile caster, Mobile target, ref double damage)` | Modify spell damage |
### Spells & Skills
| Method | Called when |
|---|---|
| `OnBeginSpellCast(Mobile, ISpell)` | Spell cast attempt; return false to block |
| `OnSpellCast(Mobile, ISpell)` | Spell successfully cast |
| `OnSkillUse(Mobile, int skill)` | Skill use attempt; return false to block |
| `AllowGain(Mobile, Skill, object)` | Skill gain attempt; return false to block |
### World Rules
| Method | Called when |
|---|---|
| `AllowHousing(Mobile, Point3D)` | Housing placement check; return false to block |
| `AllowSpawn()` | Creature spawn check |
| `AcceptsSpawnsFrom(Region)` | Whether spawns from another region are accepted |
| `OnDecay(Item)` | Item decay; return false to prevent |
| `CheckAccessibility(Item, Mobile)` | Item access check; return false to deny |
| `GetResource(Type)` | Alter resource type for mining/harvesting |
| `MakeGuard(Mobile focus)` | Spawn a guard at a location |
### Environment
| Method | Called when |
|---|---|
| `AlterLightLevel(Mobile, ref int global, ref int personal)` | Modify light levels |
| `GetLogoutDelay(Mobile)` | Get logout timer duration |
| `CanUseStuckMenu(Mobile)` | Whether stuck menu is available |
| `OnSpeech(SpeechEventArgs)` | Speech in region (used for guard calls, house commands) |
| `OnResurrect(Mobile)` | Resurrection attempt; return false to block |
| `OnBeforeDeath(Mobile)` / `OnDeath(Mobile)` | Death handling |
## BaseRegion (UOContent)
`Server.Regions.BaseRegion` in `Projects/UOContent/Regions/BaseRegion.cs` extends `Region` with game-specific features:
### Additional Properties
| Property | Type | Default | Description |
|---|---|---|---|
| `YoungProtected` | `virtual bool` | `true` | Young players get warning gump on entry |
| `YoungMayEnter` | `virtual bool` | `true` | Young players allowed to enter |
| `MountsAllowed` | `virtual bool` | `true` | Mounts allowed in region |
| `DeadMayEnter` | `virtual bool` | `true` | Dead players can enter |
| `ResurrectionAllowed` | `virtual bool` | `true` | Resurrection permitted |
| `LogoutAllowed` | `virtual bool` | `true` | Logout permitted |
| `ExcludeFromParentSpawns` | `bool` | `false` | Blocks parent region spawners from placing here |
| `RuneName` | `string` | `null` | Name shown on recall runes |
| `NoLogoutDelay` | `bool` | `false` | Zero logout delay if not in combat |
### CheckTravel
`BaseRegion.CheckTravel` is the key hook for spell travel restrictions:
```csharp
public virtual bool CheckTravel(
Mobile m,
Point3D newLocation,
TravelCheckType travelType,
out TextDefinition message)
```
`TravelCheckType` values: `RecallFrom`, `RecallTo`, `GateFrom`, `GateTo`, `Mark`, `TeleportFrom`, `TeleportTo`.
Both the **current** region and the **destination** region are checked by `SpellHelper.CheckTravel`. If either returns false, the spell is blocked.
### Spawn Distribution
`BaseRegion` provides `InitRectangles()` which breaks overlapping `Area` rectangles into non-overlapping pieces and calculates weights for uniform spawn distribution. Used by `RegionSpawner`.
## JSON Region Loading
Regions are defined in `Distribution/Data/regions.json` and loaded at startup by `RegionJsonSerializer.LoadRegions()`.
### JSON Schema
```json
{
"$type": "DungeonRegion",
"Name": "Shame",
"Map": "Felucca",
"Priority": 50,
"Parent": { "Name": "Felucca", "Map": "Felucca" },
"Area": [
{ "x1": 5369, "y1": 1, "x2": 5627, "y2": 127 }
],
"GoLocation": { "x": 511, "y": 1565, "z": 0 },
"Music": "Dungeon9",
"Entrance": { "x": 511, "y": 1565, "z": 0 },
"MinExpansion": "None",
"MaxExpansion": "EJ"
}
```
Key fields:
- `$type` — Polymorphic type discriminator (must be a registered type name)
- `Parent` — Resolved by `RegionByNameConverter` via `Region.Find(name, map)`
- `MinExpansion` / `MaxExpansion` — Region only loads if server expansion is within range
- `Area` — Array of 2D or 3D rectangles
### Type Registration
All region types that appear in JSON must be registered in `RegionJsonRegistration.Configure()`:
```csharp
RegionJsonSerializer.Register<BaseRegion>();
RegionJsonSerializer.Register<TownRegion>();
RegionJsonSerializer.Register<DungeonRegion>();
RegionJsonSerializer.Register<GuardedRegion>();
// ... 30+ more types
```
On deserialization, each region's `Register()` is called automatically if the server expansion is in range.
## Child Regions
Child regions are regions with a `Parent`. They form a hierarchy:
```
Felucca (default region)
└─ Britain (TownRegion, guarded)
└─ Britain Bank (BaseRegion, NoLogoutDelay)
```
### How Children Work
1. **Behavior inheritance**: Every virtual hook delegates to `Parent` by default. A child only needs to override what it changes.
2. **Priority**: Children have higher `ChildLevel` than parents, so they're checked first in sector lookups.
3. **Entry/exit events**: When a mobile moves from parent to child (or vice versa), `OnExit` is called on regions being left and `OnEnter` on regions being entered, walking the hierarchy.
4. **`IsPartOf<T>()`**: Walks up the parent chain. A region inside "Britain Bank" returns true for `IsPartOf<GuardedRegion>()` because `TownRegion` (Britain) is a `GuardedRegion`.
### When to Use Child Regions
Use a child region when you need to **modify behavior within an existing region** without replacing it:
- **Dungeon sub-areas**: A treasure room inside a dungeon that has different light or spawn rules
- **Town districts**: A bank area with no logout delay inside a guarded town
- **Boss arenas**: A champion spawn area inside a dungeon that adds lighting effects and player ejection
- **Restricted zones**: A no-spell zone within a larger dungeon
**Example — dungeon chest with a localized effect zone**:
If you need a dungeon chest that creates a "cursed area" around it (e.g., damage over time, spell restrictions), create a child region of the dungeon:
```csharp
public class CursedChestRegion : BaseRegion
{
private readonly CursedChest _chest;
public CursedChestRegion(CursedChest chest)
: base(null, chest.Map, Region.Find(chest.Location, chest.Map), // parent = dungeon
new Rectangle2D(chest.X - 5, chest.Y - 5, 11, 11))
{
_chest = chest;
}
// Inherits DungeonRegion behavior (lighting, no housing, etc.)
// Only adds curse-specific effects
public override void OnEnter(Mobile m)
{
base.OnEnter(m);
if (m.Player)
m.SendMessage("You feel a dark presence...");
}
public override void SpellDamageScalar(Mobile caster, Mobile target, ref double damage)
{
base.SpellDamageScalar(caster, target, ref damage);
damage *= 1.25; // 25% more spell damage in cursed area
}
}
```
Because this is a **child** of the dungeon region, it inherits all dungeon rules (lighting, no housing, young protection = false) and only adds the curse effect on top.
## Dynamic Regions
Dynamic regions are created at runtime by game code rather than loaded from JSON. They're identified by `Dynamic = true` (set automatically in the constructor) and sort before static regions.
### Pattern 1: Item-Tracked Region (Register/Unregister)
The most common pattern — an item creates a region around itself and manages its lifecycle:
```csharp
public partial class ChampionSpawn : Item
{
private ChampionSpawnRegion m_Region;
public void UpdateRegion()
{
m_Region?.Unregister(); // Remove old region
if (!Deleted && Map != Map.Internal)
{
m_Region = GetRegion(); // Create new region
m_Region.Register(); // Add to map
}
}
public override void OnLocationChange(Point3D oldLoc)
{
// ... update spawn area coordinates ...
UpdateRegion(); // Re-register at new location
}
public override void OnMapChange()
{
// ... update child items' maps ...
UpdateRegion(); // Re-register on new map
}
public override void OnAfterDelete()
{
base.OnAfterDelete();
// ... cleanup child items ...
UpdateRegion(); // Unregisters (Deleted == true)
}
[AfterDeserialization(false)]
private void AfterDeserialization()
{
// Defer registration to next tick (world may not be fully loaded)
Timer.StartTimer(TimeSpan.Zero, UpdateRegion);
}
}
```
**Key points:**
- Call `UpdateRegion()` on `OnLocationChange`, `OnMapChange`, and `OnAfterDelete`
- In `AfterDeserialization`, defer registration with `Timer.StartTimer(TimeSpan.Zero, ...)` — regions rely on the map and parent regions being loaded first
- The `UpdateRegion()` method is idempotent: unregisters old, creates new if not deleted
### Pattern 2: House Region
Houses create a region with priority `DefaultPriority + 1` (higher than normal regions):
```csharp
public virtual void UpdateRegion()
{
m_Region?.Unregister();
if (Map != null)
{
m_Region = new HouseRegion(this);
m_Region.Register();
}
else
{
m_Region = null;
}
}
```
### Pattern 3: Parent-Aware Dynamic Child
When creating a dynamic region that should be a child of whatever region exists at a location:
```csharp
// Find the existing region at the item's location and use it as parent
var parent = Region.Find(item.Location, item.Map);
var childRegion = new MyCustomRegion(item, parent);
childRegion.Register();
```
The `ChampionSpawnRegion` constructor does exactly this:
```csharp
public ChampionSpawnRegion(ChampionSpawn spawn)
: base(null, spawn.Map, Region.Find(spawn.Location, spawn.Map), spawn.SpawnArea)
```
This makes the champion spawn region a child of whatever region it's placed in (dungeon, wilderness, etc.), inheriting that region's rules.
## Existing Region Types Quick Reference
### Base Types (Use for Most Tasks)
| Type | Inherits | Key Behavior |
|---|---|---|
| `BaseRegion` | `Region` | Game-level base; CheckTravel, RuneName, spawn weights |
| `GuardedRegion` | `BaseRegion` | NPC guards, no housing, town spell restrictions |
| `TownRegion` | `GuardedRegion` | Town marker (adds Entrance); inherits guard behavior |
| `DungeonRegion` | `BaseRegion` | Dungeon lighting, no housing, young not protected, no stuck menu on Felucca |
| `NoHousingRegion` | `BaseRegion` | Blocks housing placement (manual overlap check) |
| `NoHousingGuardedRegion` | `GuardedRegion` | Guards + no housing overlap check |
| `NoTravelSpellsAllowedRegion` | `DungeonRegion` | Blocks all travel spells for players |
| `GreenAcresRegion` | `BaseRegion` | No housing, no travel, no Mark |
| `JailRegion` | `BaseRegion` | Full lockdown: no skills, spells, combat, travel, or gain |
| `HouseRegion` | `BaseRegion` | Dynamic; per-house access control, lockdown, secure items |
### Choosing a Base for Your Region
| You Need | Inherit From |
|---|---|
| Standard overworld area with custom rules | `BaseRegion` |
| Town/city with guards | `TownRegion` or `GuardedRegion` |
| Dungeon area (dark, no housing) | `DungeonRegion` |
| Dungeon + no travel spells | `NoTravelSpellsAllowedRegion` |
| No housing only | `NoHousingRegion` |
| Full spell/skill lockdown | `JailRegion` (or custom `BaseRegion`) |
| Item-controlled dynamic area | `BaseRegion` (with parent = `Region.Find(...)`) |
### Specialized Regions (Registered for JSON)
These are used in `regions.json` for specific areas and can serve as references for custom regions:
| Type | Purpose |
|---|---|
| `MondainRegion` | Mondain's Legacy dungeon base (no travel) |
| `CrystalFieldRegion` | Cold damage boost zone |
| `IcyRiverRegion` | Cold damage boost zone |
| `AcidRiverRegion` | Poison damage boost zone |
| `PoisonedTreeRegion` | Poison damage boost zone |
| `PoisonedCemeteryRegion` | Poison damage boost zone |
| `LostCityEntranceRegion` | Special dungeon entrance |
| `BlackthornDungeonRegion` | Blackthorn-specific rules |
| `ExodusDungeonRegion` | Exodus dungeon rules |
| `DoomGuardianRegion` | Doom gauntlet area |
| `UnderwaterRegion` | Underwater mechanics |
| `ApprenticeRegion` | Apprentice quest zone |
| `SeaMarketRegion` | Sea market mechanics |
| `BattleRegion` | Myrmidex battle zone |
| `NewMaginciaRegion` | New Magincia rules |
| `TokunoDocksRegion` | Tokuno docks mechanics |
| `TombOfKingsRegion` / `ToKBridgeRegion` | Tomb of Kings areas |
| `WrongLevel3Region` / `WrongJailRegion` | Wrong dungeon jail |
| `CousteauPerronHouseRegion` | Special house region |
## Common Patterns
### Blocking Travel Spells
```csharp
public override bool CheckTravel(
Mobile m, Point3D newLocation, TravelCheckType travelType, out TextDefinition message)
{
message = null; // null = use default "Thy spell doth not appear to work"
return m.AccessLevel > AccessLevel.Player; // Staff can always travel
}
```
### Blocking Specific Spells (e.g., Mark)
```csharp
public override bool OnBeginSpellCast(Mobile m, ISpell s)
{
if (m.AccessLevel == AccessLevel.Player && s is MarkSpell)
{
m.SendLocalizedMessage(501802); // Thy spell doth not appear to work...
return false;
}
return base.OnBeginSpellCast(m, s);
}
```
### Blocking Housing
```csharp
public override bool AllowHousing(Mobile from, Point3D p) => false;
```
### Custom Light Level
```csharp
public override void AlterLightLevel(Mobile m, ref int global, ref int personal)
{
global = LightCycle.DungeonLevel; // Dungeon darkness
}
```
### No Logout Delay (Safe Zone)
Set `NoLogoutDelay = true` on a `BaseRegion`. The delay is zero only if the mobile has no aggressors and is not criminal.
### Damage Modification
```csharp
public override void SpellDamageScalar(Mobile caster, Mobile target, ref double damage)
{
base.SpellDamageScalar(caster, target, ref damage);
damage *= 1.5; // 50% more spell damage
}
```
## Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Not calling `Unregister()` before `Register()` | Duplicate region entries | Always unregister old before creating new |
| Registering in deserialization constructor | Map/parent may not exist yet | Use `[AfterDeserialization]` with `Timer.StartTimer(TimeSpan.Zero, ...)` |
| Forgetting to unregister on item delete | Ghost region remains on map | Call `UpdateRegion()` or `Unregister()` in `OnAfterDelete()` |
| Creating a child without finding the parent | Region has no parent hierarchy benefits | Use `Region.Find(location, map)` as parent |
| Not registering type for JSON | Deserialization fails silently | Add `RegionJsonSerializer.Register<T>()` in `RegionJsonRegistration.Configure()` |
| Using `Region.Find(string, Map)` in hot paths | Linear scan, O(n) | Use `Region.Find(Point3D, Map)` which uses sector index |
| Unregistering parent before children | Warning logged, orphaned children | Unregister children first |
## Key File References
| File | Description |
|---|---|
| `Projects/Server/Regions/Region.cs` | Engine-level base class (30+ virtual hooks) |
| `Projects/Server/Regions/RegionJsonSerializer.cs` | JSON loading, type registration |
| `Projects/Server/Json/Converters/RegionByNameConverter.cs` | Parent resolution from JSON |
| `Projects/UOContent/Regions/BaseRegion.cs` | Game-level base: CheckTravel, spawn weights |
| `Projects/UOContent/Regions/GuardedRegion.cs` | NPC guards, town mechanics |
| `Projects/UOContent/Regions/DungeonRegion.cs` | Dungeon lighting and rules |
| `Projects/UOContent/Regions/TownRegion.cs` | Town type marker |
| `Projects/UOContent/Regions/NoHousingRegion.cs` | Housing block |
| `Projects/UOContent/Regions/NoHousingGuardedRegion.cs` | Guarded + housing block |
| `Projects/UOContent/Regions/NoTravelSpellsAllowedRegion.cs` | Travel spell block |
| `Projects/UOContent/Regions/GreenAcresRegion.cs` | Multi-restriction region |
| `Projects/UOContent/Regions/HouseRegion.cs` | Dynamic per-house region |
| `Projects/UOContent/Regions/RegionJsonRegistration.cs` | All registered JSON types |
| `Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs` | Dynamic region lifecycle example |
| `Projects/UOContent/Multis/Houses/BaseHouse.cs` | House region lifecycle example |
| `Projects/UOContent/Spells/Base/SpellHelper.cs` | Travel check dispatch |
| `Distribution/Data/regions.json` | Static region definitions |

534
dev-docs/serialization.md Normal file
View file

@ -0,0 +1,534 @@
# ModernUO Serialization System
ModernUO uses a source generator-based serialization system that automatically generates `Serialize()` and `Deserialize()` methods from attribute-decorated fields and properties.
## Overview
The serialization system is provided by two NuGet packages:
- `ModernUO.Serialization.Annotations` - Defines attributes
- `ModernUO.Serialization.Generator` - C# source generator that produces serialization code
Source: https://github.com/modernuo/SerializationGenerator
## Quick Start
### Minimal Serializable Item
```csharp
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0, false)]
public partial class SimpleItem : Item
{
[Constructible]
public SimpleItem() : base(0x1234)
{
Weight = 1.0;
}
public override string DefaultName => "a simple item";
}
```
Key requirements:
1. `using ModernUO.Serialization;` for the attributes
2. `[SerializationGenerator(0, false)]` on the class
3. `partial` class declaration
4. `[Constructible]` on the parameterless constructor
### Item with Serialized Fields
```csharp
[SerializationGenerator(0, false)]
public partial class ChargedGem : Item
{
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
[SerializableField(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private Mobile _owner;
private TimerExecutionToken _glowTimer; // NOT serialized
[Constructible]
public ChargedGem() : base(0x1EA7)
{
_charges = Utility.RandomMinMax(5, 15);
Light = LightType.Circle150;
Timer.StartTimer(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2), Glow, out _glowTimer);
}
[AfterDeserialization]
private void AfterDeserialization()
{
Timer.StartTimer(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2), Glow, out _glowTimer);
}
public override void OnAfterDelete()
{
_glowTimer.Cancel();
base.OnAfterDelete();
}
private void Glow()
{
if (_charges > 0)
Effects.SendLocationParticles(this, 0x376A, 9, 10, 5042);
}
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
list.Add(1060741, $"{_charges}"); // "charges: ~1_val~"
}
}
```
---
## Attribute Reference
### [SerializationGenerator(version, encodedVersion)]
**Target**: Class declaration
**Required**: Yes, for any serializable type
| Parameter | Type | Default | Description |
|---|---|---|---|
| `version` | `int` | Required | Current serialization version number |
| `encodedVersion` | `bool` | `true` | `false` for Item/Mobile subclasses; `true` for standalone serializable types |
The version number determines which `Deserialize` overload is called. When you add, remove, or reorder fields, increment the version.
```csharp
[SerializationGenerator(3, false)] // Version 3, Item/Mobile subclass
public partial class MyItem : Item { }
[SerializationGenerator(0)] // Version 0, standalone type (encodedVersion=true)
public partial class MyData { }
```
### [SerializableField(index, setter, saveIf)]
**Target**: Private field (`_camelCase`)
**Generates**: Public `PascalCase` property with get/set
| 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 |
```csharp
[SerializableField(0)] // Public property
private int _charges;
[SerializableField(1, setter: "private")] // Private setter
private string _name;
[SerializableField(2, setter: "internal")] // Internal setter
private DateTime _created;
```
The generated property for `_charges` would be:
```csharp
public int Charges
{
get => _charges;
set { _charges = value; this.MarkDirty(); }
}
```
### [SerializableProperty(index, useField)]
**Target**: Property with custom get/set logic
**Use when**: You need non-trivial getter/setter logic
| Parameter | Type | Default | Description |
|---|---|---|---|
| `index` | `int` | Required | Serialization order |
| `useField` | `string` | `null` | Explicit backing field name |
```csharp
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int MaxItems
{
get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
set
{
_maxItems = value;
InvalidateProperties();
this.MarkDirty(); // REQUIRED in custom setters
}
}
```
### [InvalidateProperties]
**Target**: `[SerializableField]`-decorated field
**Effect**: Calls `InvalidateProperties()` when the property setter is invoked, refreshing the client tooltip.
```csharp
[SerializableField(0)]
[InvalidateProperties]
private int _charges;
// Generated setter calls InvalidateProperties() automatically
```
### [SerializedCommandProperty(accessLevel)]
**Target**: `[SerializableField]`-decorated field
**Effect**: Exposes the generated property to the `[Props` gump for in-game editing.
```csharp
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
// GMs can view/edit via [Props command
```
Overloads:
- `[SerializedCommandProperty(AccessLevel.GameMaster)]` - Same read/write level
- `[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]` - Different read/write levels
### [EncodedInt]
**Target**: `int` field or property
**Effect**: Uses variable-length encoding. 1 byte for 0-127, 2 bytes for 128-16383, etc.
Best for fields that are usually small values (counts, IDs, indexes).
### [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.
```csharp
[DeltaDateTime]
[SerializableField(0)]
private DateTime _expireTime;
```
### [InternString]
**Target**: `string` field
**Effect**: Calls `string.Intern()` on deserialization to deduplicate identical strings in memory.
Best for frequently repeated strings (usernames, template names).
### [Tidy]
**Target**: Collection field (`List<T>`, `Dictionary<K,V>`, etc.)
**Effect**: Removes null and deleted entries from the collection after deserialization.
```csharp
[Tidy]
[SerializableField(0)]
private List<Mobile> _followers;
// After loading, any deleted/null mobiles are removed
```
### [CanBeNull]
**Target**: Any reference-type field
**Effect**: Allows the field to be null during deserialization without error.
```csharp
[CanBeNull]
[SerializableField(0)]
private Mobile _target;
```
### [AfterDeserialization(skipOnDelete)]
**Target**: Parameterless private method
**Effect**: Called after all fields are deserialized.
| Parameter | Type | Default | Description |
|---|---|---|---|
| `skipOnDelete` | `bool` | `true` | Skip if entity is being deleted |
Common uses:
- Restart timers
- Set up object relationships
- Calculate derived values
- Clean up empty collections
```csharp
[AfterDeserialization]
private void AfterDeserialization()
{
// Restart timers
Timer.StartTimer(TimeSpan.FromMinutes(1), CheckExpiry, out _timerToken);
// Set up relationships
if (_owner != null)
_owner.OwnedItems.Add(this);
// Clean up
if (_entries?.Count == 0)
_entries = null;
}
```
### [DeserializeTimerField(fieldIndex)]
**Target**: Method taking `TimeSpan` parameter
**Effect**: Custom deserialization for Timer fields. The timer is saved as remaining delay.
```csharp
[SerializableField(0, setter: "private")]
private Timer _decayTimer;
[DeserializeTimerField(0)]
private void DeserializeDecayTimer(TimeSpan delay)
{
_decayTimer = Timer.DelayCall(delay, Delete);
_decayTimer.Start();
}
```
### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)]
**Conditional serialization** -- skip fields that have their default value.
```csharp
[EncodedInt]
[SerializableProperty(0)]
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;
```
### [TypeAlias(params string[] aliases)]
**Target**: Class declaration
**Effect**: Maps old type names to this class for deserialization of old saves.
```csharp
[TypeAlias("Server.Mobiles.Bear")]
[SerializationGenerator(0, false)]
public partial class BlackBear : BaseCreature { }
```
### [Constructible(accessLevel)]
**Target**: Constructor
**Effect**: Marks constructor as available for the `[add` command.
```csharp
[Constructible] // Any player can [add
public MyItem() : base(0x1234) { }
[Constructible(AccessLevel.Administrator)] // Only admins can [add
public SpecialItem() : base(0x5678) { }
```
---
## Version Migration
### When to Increment Version
Increment the version number when you:
- Add a new serialized field
- Remove a serialized field
- Reorder fields (change indexes)
- Change a field's type
### Migration Schema Files
Located in `Projects/Server/Migrations/` and `Projects/UOContent/Migrations/`.
Format: `Namespace.TypeName.vN.json`
Example: `Server.Accounting.Account.v6.json`
```json
{
"version": 6,
"type": "Server.Accounting.Account",
"properties": [
{
"name": "Username",
"type": "string",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": ["InternString"]
},
{
"name": "Mobiles",
"type": "Server.Mobile[]",
"rule": "ArrayMigrationRule",
"ruleArguments": ["Server.Mobile", "SerializableInterfaceMigrationRule"]
}
]
}
```
### Migration Rule Types
| Rule | Description |
|---|---|
| `PrimitiveTypeMigrationRule` | Basic types: int, string, bool, DateTime, etc. |
| `EnumMigrationRule` | Enum values |
| `ListMigrationRule` | `List<T>` |
| `ArrayMigrationRule` | `T[]` |
| `DictionaryMigrationRule` | `Dictionary<K,V>` |
| `SerializableInterfaceMigrationRule` | Objects implementing ISerializable |
| `SerializationMethodSignatureMigrationRule` | Objects with custom Deserialize methods |
---
## Extension Methods
From `Projects/Server/Serialization/ISerializableExtensions.cs`:
```csharp
// Mark entity as dirty (must be called in custom property setters)
entity.MarkDirty();
// Collection operations (auto-mark dirty)
entity.Add(list, value);
entity.Add(dict, key, value);
entity.Remove(list, value);
entity.Clear(list);
// Timer operations (auto-mark dirty)
entity.Stop(timer);
entity.Start(timer);
entity.Restart(timer, delay, interval);
entity.Stop(ref timer); // Stops and nulls the reference
```
---
## Complete Example: Versioned Item
```csharp
using ModernUO.Serialization;
using Server.Targeting;
namespace Server.Items;
public enum GemQuality
{
Rough,
Cut,
Flawless
}
[SerializationGenerator(1, false)] // Version 1 (added Quality in v1)
public partial class MagicGem : Item
{
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
[SerializableField(1)] // Added in version 1
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private GemQuality _quality;
private TimerExecutionToken _pulseTimer;
[Constructible]
public MagicGem() : base(0x1EA7)
{
_charges = Utility.RandomMinMax(5, 15);
_quality = GemQuality.Rough;
Weight = 1.0;
Light = LightType.Circle150;
StartPulse();
}
public override string DefaultName => "a magic gem";
private void StartPulse()
{
Timer.StartTimer(
TimeSpan.FromSeconds(3),
TimeSpan.FromSeconds(3),
Pulse,
out _pulseTimer
);
}
[AfterDeserialization]
private void AfterDeserialization()
{
StartPulse();
}
public override void OnAfterDelete()
{
_pulseTimer.Cancel();
base.OnAfterDelete();
}
private void Pulse()
{
if (_charges <= 0)
{
_pulseTimer.Cancel();
return;
}
Effects.SendLocationParticles(this, 0x376A, 9, 10, 5042);
}
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
list.Add(1060741, $"{_charges}"); // charges: ~1_val~
list.Add($"{"Quality: "}{_quality}");
}
public override void OnDoubleClick(Mobile from)
{
if (!IsChildOf(from.Backpack))
{
from.SendLocalizedMessage(1042001); // Must be in backpack
return;
}
if (_charges <= 0)
{
from.SendMessage("The gem is depleted.");
return;
}
_charges--;
InvalidateProperties();
this.MarkDirty();
from.SendMessage("The gem pulses with energy!");
}
}
```
---
## Key File Locations
| File | Description |
|---|---|
| `Projects/Server/Serialization/ISerializableExtensions.cs` | MarkDirty(), collection helpers |
| `Projects/Server/Migrations/*.v*.json` | Server migration schemas |
| `Projects/UOContent/Migrations/*.v*.json` | Content migration schemas |
| `Projects/UOContent/Mobiles/Animals/Bears/BlackBear.cs` | Simple creature example |
| `Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs` | Fields + timer token |
| `Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs` | Custom properties |
| `Projects/UOContent/Accounting/Account.cs` | Complex versioned type |
| `Projects/UOContent/Items/Aquarium/Aquarium.cs` | Timer deserialization |
| `Projects/Server/Items/Container.cs` | Conditional serialization |

238
dev-docs/threading-model.md Normal file
View file

@ -0,0 +1,238 @@
# ModernUO Threading Model
This document covers ModernUO's single-threaded game loop architecture, the EventLoopContext synchronization context, memory pooling, and rules for safe concurrent code.
## Core Principle: Single-Threaded Game Logic
All game logic in ModernUO runs on a single thread. There are no exceptions for code under `Projects/UOContent/`.
This means:
- No locks, mutexes, or synchronization primitives needed
- No concurrent collections needed
- No volatile fields needed
- No race conditions possible in game code
- `await` is safe because continuations route through EventLoopContext
## Game Loop
The game loop in `Projects/Server/Main.cs` runs continuously:
```csharp
public static void RunEventLoop()
{
while (!Closing)
{
_tickCount = GetTimestamp();
_now = DateTime.UtcNow;
Mobile.ProcessDeltaQueue(); // Send mobile state changes to clients
Item.ProcessDeltaQueue(); // Send item state changes to clients
Timer.Slice(_tickCount); // Execute due timers
NetState.Slice(); // Process network I/O
LoopContext.ExecuteTasks(); // Run async continuations
Timer.CheckTimerPool(); // Refill timer pool if needed
// World save handling
if (_performSnapshot)
{
World.Snapshot(_snapshotPath);
_performSnapshot = false;
}
}
}
```
Each iteration:
1. Updates timestamp
2. Sends pending mobile/item updates to clients
3. Fires due timers
4. Processes incoming network packets
5. Runs async continuations (from `await`)
6. Checks timer pool health
7. Handles world save snapshots if requested
## EventLoopContext
`EventLoopContext` implements `SynchronizationContext` to ensure all `await` continuations run on the game thread.
Defined in `Projects/Server/EventLoopTasks.cs`:
```csharp
public sealed class EventLoopContext : SynchronizationContext
{
public enum Priority { Normal, High }
private readonly ConcurrentQueue<Action> _queue;
private readonly ConcurrentQueue<Action> _priorityQueue;
private readonly Thread _mainThread;
private readonly int _maxPerFrame; // Default: 128
// Post: queues action for next ExecuteTasks() call
public void Post(Action d, Priority priority = Priority.Normal);
// SynchronizationContext.Post: used by await
public override void Post(SendOrPostCallback d, object state);
// Send: immediate if on main thread, blocks if on other thread
public override void Send(SendOrPostCallback d, object state);
// Called once per game loop tick
public void ExecuteTasks();
}
```
### How await Works
```csharp
// Safe in game code:
await Timer.Pause(TimeSpan.FromMilliseconds(100));
// After the pause, execution continues on the game thread
```
Flow:
1. `await` captures `EventLoopContext` as the current `SynchronizationContext`
2. When the awaited task completes, the continuation is posted to `_queue`
3. `LoopContext.ExecuteTasks()` runs the continuation on the main thread
4. Game state is safely accessible
### Task Limits
- Maximum 128 tasks per frame by default (configurable)
- High-priority tasks (`_priorityQueue`) are always processed first
- Normal tasks are processed up to the per-frame limit
## Forbidden Patterns
### In Game Code (Projects/UOContent/)
| Pattern | Problem | Alternative |
|---|---|---|
| `Task.Run(...)` | Runs on thread pool, races with game state | `Timer.StartTimer()` |
| `new Thread(...)` | Manual thread, races with game state | `Timer.StartTimer()` |
| `ThreadPool.QueueUserWorkItem(...)` | Thread pool, same issue | `Timer.StartTimer()` |
| `lock(obj) { ... }` | Unnecessary overhead, no contention | Remove lock |
| `Monitor.Enter(obj)` | Same as lock | Remove |
| `volatile int _field` | Memory barriers not needed | Plain field |
| `ConcurrentDictionary<K,V>` | Lock-free overhead, unnecessary | `Dictionary<K,V>` |
| `ConcurrentQueue<T>` | Same | `Queue<T>` or `List<T>` |
| `ConcurrentBag<T>` | Same | `List<T>` |
| `Interlocked.Increment(...)` | Atomic operations unnecessary | `_field++` |
| `Mutex` / `Semaphore` | OS-level sync, unnecessary | Remove |
| `ReaderWriterLockSlim` | Lock overhead, unnecessary | Remove |
| `Thread.Sleep(ms)` | Blocks entire game loop | `await Timer.Pause(ms)` |
### Exceptions: Server Infrastructure
These files in `Projects/Server/` MAY use threading because they handle I/O outside the game loop:
- `Main.cs` -- Event loop setup, thread configuration
- `World/World.cs` -- World save disk I/O (serialization on main thread, writes on background)
- `Network/` -- Network I/O processing
- `Timer/Timer.Pool.cs` -- Async pool refill
- `EventLoopTasks.cs` -- The synchronization context itself
## Memory Pooling
### STArrayPool<T>
Single-threaded array pool optimized for game code (no locks):
```csharp
// Defined in Projects/Server/Buffers/STArrayPool.cs
public class STArrayPool<T> : ArrayPool<T>
{
public static new STArrayPool<T> Shared { get; }
public override T[] Rent(int minimumLength);
public override void Return(T[]? array, bool clearArray = false);
}
```
Usage:
```csharp
var buffer = STArrayPool<byte>.Shared.Rent(1024);
try
{
// Use buffer (may be larger than requested)
}
finally
{
STArrayPool<byte>.Shared.Return(buffer);
}
```
Architecture:
- 27 buckets covering sizes 16 to 1GB+
- Per-bucket cache (1 array) + stack storage (32 arrays)
- Trim callbacks on Gen2 GC to reduce memory pressure
- Formula: bucket index = `Log2(size - 1 | 15) - 3`
**Use `STArrayPool<T>.Shared`** in game code, **not** `ArrayPool<T>.Shared` (which uses locks).
### PooledRefList<T>
Stack-allocated list using pooled arrays:
```csharp
// Defined in Projects/Server/Collections/PooledRefList.cs
public ref struct PooledRefList<T>
{
public static PooledRefList<T> Create(int capacity = 32, bool mt = false);
public static PooledRefList<T> CreateMT(int capacity = 32); // Multi-threaded
public void Add(T item);
public bool Remove(T item);
public void Clear();
public int Count { get; }
public T this[int index] { get; set; }
public void Dispose(); // Returns array to pool
}
```
Usage:
```csharp
using var list = PooledRefList<Mobile>.Create();
list.Add(mobile);
// list is stack-allocated, zero GC pressure
// Dispose() returns backing array to STArrayPool
```
Key properties:
- `ref struct` -- stack-allocated, cannot escape to heap
- Uses `STArrayPool<T>` by default, `ArrayPool<T>.Shared` with `CreateMT()`
- Auto-grows when capacity exceeded
- Must be disposed (use `using` pattern)
## World Save Threading
World saves involve both threads:
1. **`World.Save()`** -- Called on main thread, queues preserialize to thread pool
2. **`Preserialize()`** -- Thread pool: allocates serialization heaps, wakes workers
3. **`Snapshot()`** -- Main thread: serializes all game state (safe access), blocks game loop briefly
4. **`WriteFiles()`** -- Thread pool: writes serialized data to disk (no game state access)
```
Main Thread: Save() → ... → Snapshot() → ... → continue loop
Thread Pool: Preserialize() → ... → WriteFiles()
```
The main thread blocks during `Snapshot()` to ensure consistent state, then the disk I/O happens asynchronously.
## Best Practices
1. **Never use concurrency primitives in game code** -- they add overhead for no benefit
2. **Use `STArrayPool<T>.Shared`** instead of `ArrayPool<T>.Shared`
3. **Use `PooledRefList<T>`** instead of `new List<T>()` in hot paths
4. **Use `await Timer.Pause()`** instead of `Thread.Sleep()`
5. **Use `Timer.StartTimer()`** instead of `Task.Run()` for delayed work
6. **Trust single-threaded invariants** -- no need to protect shared state
## Key File References
| File | Description |
|---|---|
| `Projects/Server/Main.cs` | Game loop (RunEventLoop) |
| `Projects/Server/EventLoopTasks.cs` | EventLoopContext |
| `Projects/Server/Buffers/STArrayPool.cs` | Single-threaded array pool |
| `Projects/Server/Collections/PooledRefList.cs` | Pooled ref list |
| `Projects/Server/World/World.cs` | World save system |

287
dev-docs/timers.md Normal file
View file

@ -0,0 +1,287 @@
# ModernUO Timer System
This document covers ModernUO's timer system: the timer wheel scheduler, delay calls, timer execution tokens, and patterns for timer usage in game content.
## Overview
ModernUO uses a 3-layer hierarchical timer wheel for scheduling delayed and recurring actions. The system is single-threaded and processes timers during each game loop tick.
> **Timer vs EventScheduler**: The timer wheel is for **game-tick** delays and repeats (8ms precision, sub-second to ~16 days). For **wall-clock/calendar** scheduling — daily resets, weekly events, holiday seasons — use `EventScheduler` instead (1-second granularity, timezone-aware, calendar recurrence patterns). See `dev-docs/event-scheduler.md`.
## Architecture
### Timer Wheel
3-layer wheel with 4096 slots per layer:
| Layer | Resolution | Range |
|---|---|---|
| 0 | 8ms | ~32.8 seconds |
| 1 | ~32.8s | ~22 minutes |
| 2 | ~22m | ~16 days |
- Tick rate: 8ms (minimum precision)
- All delays are rounded up to nearest 8ms boundary
- O(1) insert and remove operations
### Execution Flow
1. `Timer.Slice(tickCount)` called each game loop iteration
2. Wheel rotates to current slot
3. All timers in the slot are executed via `OnTick()`
4. Repeating timers are re-inserted at next interval
5. Finished timers call `OnDetach()` for cleanup
## API Reference
### Timer.StartTimer (Fire-and-Forget with Pooling)
Preferred for most use cases. Timers are automatically pooled for reuse.
```csharp
// Immediate execution
Timer.StartTimer(callback);
// Delayed execution
Timer.StartTimer(TimeSpan.FromSeconds(5), callback);
// Repeating
Timer.StartTimer(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1), callback);
// Repeating with count limit
Timer.StartTimer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1), 10, callback);
// Delayed start, then repeating
Timer.StartTimer(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(1), callback);
```
### Timer.StartTimer with Token (Cancellable)
Use when you need to cancel the timer later.
```csharp
private TimerExecutionToken _token;
// Start with token
Timer.StartTimer(TimeSpan.FromSeconds(5), DoWork, out _token);
// Check state
if (_token.Running) { }
var remaining = _token.RemainingCount; // int.MaxValue if infinite
var next = _token.Next; // DateTime of next tick
var index = _token.Index; // Times ticked so far
// Cancel (safe to call multiple times)
_token.Cancel();
```
### Timer.DelayCall (Returns Timer Object)
Legacy-style API that returns the timer object directly.
```csharp
var timer = Timer.DelayCall(TimeSpan.FromSeconds(5), DoWork);
timer.Stop(); // Cancel
// With state parameters (avoids lambda allocation)
Timer.DelayCall(TimeSpan.FromSeconds(2), ProcessTarget, mobile, item);
Timer.DelayCall(TimeSpan.FromSeconds(1), DoWork, arg1, arg2, arg3);
// Supports up to 5 state parameters
```
### Timer.Pause (Awaitable)
For async/await patterns:
```csharp
await Timer.Pause(TimeSpan.FromMilliseconds(100));
await Timer.Pause(500); // Milliseconds overload
```
Safe because `EventLoopContext` routes continuations to the main thread.
## TimerExecutionToken
Lightweight struct for tracking fire-and-forget timers:
```csharp
public struct TimerExecutionToken
{
public bool Running { get; } // Is timer still active?
public int Index { get; } // How many times OnTick fired
public int RemainingCount { get; } // Ticks remaining (int.MaxValue if infinite)
public DateTime Next { get; } // When next tick fires
public void Cancel(); // Stop and return to pool
}
```
Key behaviors:
- `Cancel()` is safe to call multiple times
- Default value (`default(TimerExecutionToken)`) has `Running = false`
- NOT serializable -- restore in `[AfterDeserialization]`
## Patterns
### Pattern 1: Simple Delayed Action
```csharp
// Delete item after 10 seconds
Timer.StartTimer(TimeSpan.FromSeconds(10), Delete);
```
### Pattern 2: Cancellable Recurring Timer
```csharp
private TimerExecutionToken _checkTimer;
public MyItem() : base(0x1234)
{
Timer.StartTimer(
TimeSpan.FromSeconds(5), // Initial delay
TimeSpan.FromSeconds(5), // Repeat interval
CheckExpiry, // Callback
out _checkTimer // Token for cancellation
);
}
public override void OnAfterDelete()
{
_checkTimer.Cancel();
base.OnAfterDelete();
}
private void CheckExpiry()
{
if (Core.Now >= _expireTime)
Delete();
}
```
### Pattern 3: Timer Restoration After Deserialization
```csharp
[SerializationGenerator(0, false)]
public partial class TimedItem : Item
{
private TimerExecutionToken _timer; // NOT serialized
[SerializableField(0)]
[DeltaDateTime]
private DateTime _expireTime;
[Constructible]
public TimedItem() : base(0x1234)
{
_expireTime = Core.Now + TimeSpan.FromHours(1);
StartTimer();
}
private void StartTimer()
{
Timer.StartTimer(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), Check, out _timer);
}
[AfterDeserialization]
private void AfterDeserialization() => StartTimer();
public override void OnAfterDelete()
{
_timer.Cancel();
base.OnAfterDelete();
}
}
```
### Pattern 4: Serializable Timer Field
```csharp
[SerializableField(0, setter: "private")]
private Timer _decayTimer;
[DeserializeTimerField(0)]
private void DeserializeDecayTimer(TimeSpan delay)
{
_decayTimer = Timer.DelayCall(delay, Delete);
_decayTimer.Start();
}
public void BeginDecay(TimeSpan delay)
{
_decayTimer?.Stop();
_decayTimer = new InternalTimer(this, delay);
_decayTimer.Start();
}
public override void OnAfterDelete()
{
_decayTimer?.Stop();
_decayTimer = null;
base.OnAfterDelete();
}
```
### Pattern 5: Custom Timer Class
```csharp
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();
}
}
```
### Pattern 6: State-Carrying Delay (No Lambda)
```csharp
// Instead of lambda (allocates closure):
Timer.StartTimer(TimeSpan.FromSeconds(2), () => ProcessTarget(from, target));
// Use state parameters (no allocation):
Timer.DelayCall(TimeSpan.FromSeconds(2), ProcessTarget, from, target);
private static void ProcessTarget(Mobile from, Mobile target)
{
// Process...
}
```
## Timer Pool
Timers created via `Timer.StartTimer()` are pooled for reuse:
- Initial pool: 1024 timers (configurable: `timer.initialPoolCapacity`)
- Max pool: 16x initial (configurable: `timer.maxPoolCapacity`)
- Pool refills asynchronously when depleted
- `Timer.CheckTimerPool()` called each game loop to monitor
## ISerializableExtensions for Timers
```csharp
// Extension methods on ISerializable:
entity.Stop(timer); // Stop + MarkDirty
entity.Start(timer); // Start + MarkDirty
entity.Restart(timer, delay, interval); // Stop + reconfigure + Start + MarkDirty
entity.Stop(ref timer); // Stop + null + MarkDirty
```
## Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Serializing `TimerExecutionToken` | Build error / data corruption | Leave unserialized, use `[AfterDeserialization]` |
| Not cancelling on delete | Timer fires on deleted entity | Cancel in `OnDelete()`/`OnAfterDelete()` |
| Using `Thread.Sleep` | Blocks game loop | Use `await Timer.Pause()` |
| Creating timer in deserialization | Timer starts before world is ready | Use `[AfterDeserialization]` |
| Lambda in hot-path timer | Allocates closure every time | Use state parameters |
## Key File References
- Timer base class: `Projects/Server/Timer/Timer.cs`
- DelayCall + StartTimer: `Projects/Server/Timer/Timer.DelayCall.cs`
- Timer wheel: `Projects/Server/Timer/Timer.TimerWheel.cs`
- Pool management: `Projects/Server/Timer/Timer.Pool.cs`
- State timers: `Projects/Server/Timer/Timer.DelayStateCall.cs`
- Token: `Projects/Server/Timer/TimerExecutionToken.cs`
- Serializable extensions: `Projects/Server/Serialization/ISerializableExtensions.cs`