## Summary Captures the durable learnings from the message-interpolation work (PRs #2434, #2436, #2437, #2438, #2440) as reference documentation. **Doc-only PR — no code changes.** The original Phase 2 audit (PR #2435) was development scaffolding and was closed unmerged once Phase 3 consumed it. This PR replaces it with proper reference docs that future authors can consult. ## What's added ### `dev-docs/string-handling.md` - Promote `RawInterpolatedStringHandler` from a one-line note to a proper section listing all APIs that accept it (messages, OPL, gumps, packets). - Document the `:L` lowercase format specifier. - New comprehensive **"Interpolation Anti-Patterns"** section covering 8 patterns with before/after examples — applies to any handler-aware API: 1. Ternary with interpolated branches 2. Switch expression with interpolated arms 3. Pre-built local typed as `string` 4. `.ToString()` (or any string-returning method) inside a hole 5. String concatenation inside a hole 6. `string.Format` feeding a handler-aware API 7. LINQ-built strings inside a hole 8. Pre-built concat var ### `dev-docs/networking-packets.md` - Add **"Player-Facing Message APIs"** section listing `Mobile` / `Item` / `NetState` message methods with their handler overloads. - Note the `IBroadcastFilter` pattern for new spatial-broadcast helpers. ### `dev-docs/property-lists.md`, `dev-docs/gump-system.md` - Cross-reference the new anti-patterns section. - Add explicit `.ToString()` inside holes warning to property-lists (it had no such guidance before). ### `dev-docs/claude-skills/` - Mirror the same content (condensed) in `modernuo-string-handling.md`, `modernuo-networking.md`, `modernuo-property-lists.md`, `modernuo-gump-system.md`. - Add audit rule #17 to `modernuo-code-audit.md` covering all 8 anti-patterns with severity WARNING, plus the `:L` format spec. ### `CLAUDE.md` - Add audit rule #18 summarizing the interpolation anti-patterns + `:L`, pointing to `dev-docs/string-handling.md` for details. ## Why this matters Before this PR there was no documentation explaining when an interpolated string call site silently allocates a string despite the receiving API providing a handler overload. The Phase 3 cleanup (PRs #2436/#2437/#2438) discovered ~28 such sites in the codebase; without these docs the same patterns would re-emerge. The new audit rule + CLAUDE.md entry will catch them at write time.
316 lines
11 KiB
Markdown
316 lines
11 KiB
Markdown
---
|
||
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
|
||
```
|
||
|
||
## Player-Facing Message APIs
|
||
|
||
For chat / system messages / overhead text, use the convenience methods on `Mobile` and `Item` rather than building packets manually. They handle stackalloc sizing, spatial queries, and visibility filtering, and each has a `ref RawInterpolatedStringHandler` overload for zero-allocation interpolation.
|
||
|
||
### Mobile
|
||
|
||
```csharp
|
||
// Self-message
|
||
mob.SendMessage(int hue, ROS<char> text);
|
||
mob.SendLocalizedMessage(int number, ROS<char> args = default, int hue = 0x3B2);
|
||
mob.SendAsciiMessage(int hue, ROS<char> text);
|
||
|
||
// Speech (broadcast in range)
|
||
mob.Say(ROS<char> text); // SpeechHue
|
||
mob.Emote(ROS<char> text); // EmoteHue
|
||
mob.Whisper(ROS<char> text); // WhisperHue, short range
|
||
mob.Yell(ROS<char> text); // YellHue, long range
|
||
|
||
// Overhead (3 visibility variants × text/localized × affix)
|
||
mob.PublicOverheadMessage(MessageType, int hue, bool ascii, ROS<char> text, ...);
|
||
mob.PrivateOverheadMessage(MessageType, int hue, int number, ROS<char> args, NetState);
|
||
mob.LocalOverheadMessage(MessageType, int hue, bool ascii, ROS<char> text);
|
||
mob.NonlocalOverheadMessage(MessageType, int hue, int number, ROS<char> args = default);
|
||
```
|
||
|
||
### Item
|
||
|
||
```csharp
|
||
item.PublicOverheadMessage(MessageType, int hue, bool ascii, ROS<char> text);
|
||
item.PublicOverheadMessage(MessageType, int hue, int number, ROS<char> args = default);
|
||
item.SendLocalizedMessageTo(Mobile to, int number, ROS<char> args = default);
|
||
item.SendLocalizedMessageTo(Mobile to, int number, int hue, ROS<char> args = default);
|
||
item.SendMessageTo(Mobile to, ROS<char> text, int hue = 0x3B2);
|
||
```
|
||
|
||
### Zero-allocation interpolation
|
||
|
||
```csharp
|
||
// Compiler picks the handler overload — no string allocated
|
||
mob.SendMessage($"You have {gold:N0} gold");
|
||
mob.Say($"Hello, {target.Name}!");
|
||
item.SendLocalizedMessageTo(player, cliloc, $"{a}\t{b}");
|
||
```
|
||
|
||
Several call-site shapes (ternaries with interpolated branches, `.ToString()` inside holes, pre-built `var msg = $"..."` locals, etc.) silently defeat handler binding and allocate a `string`. See `dev-docs/string-handling.md` § "Interpolation Anti-Patterns" for the list and fixes.
|
||
|
||
For lowercase output, use `:L`:
|
||
```csharp
|
||
mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold" not "Gold"
|
||
```
|
||
|
||
### Implementation note
|
||
|
||
The 7 spatial-broadcast variants (`Public/NonlocalOverheadMessage` × text/localized/affix) route through generic `OutgoingMessagePackets.Broadcast*<TFilter>` helpers parameterized over a `private readonly struct` filter that encapsulates the visibility predicate. The `where TFilter : struct, IBroadcastFilter` constraint specializes per filter type and keeps dispatch zero-alloc. If you add a new spatial-message API, add a filter struct and call the existing helper rather than duplicating the loop.
|
||
|
||
---
|
||
|
||
## 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
|