ModernUO/dev-docs/claude-skills/modernuo-networking.md
Kamron Batman 9ea1b54758
docs(messages): document interpolation anti-patterns and :L format spec (#2441)
## 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.
2026-05-03 18:23:50 -07:00

11 KiB
Raw Blame History

name description
modernuo-networking 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

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

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

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()

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

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

// 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

// 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

// 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

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

// 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:

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

ns.SendSoundEffect(0x1E5, target);

Mobile Animation

ns.SendMobileAnimation(mobile.Serial, action, frameCount, repeatCount, forward, repeat, delay);

Damage

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