perf: Zero-alloc interpolation for SendMessage/Overhead APIs (#2434)

## Summary

Phase 1 of a multi-phase optimization to eliminate intermediate string allocations between `$"..."` interpolation and the packet text region for ModernUO's player-facing message APIs.

- Adds `[InterpolatedStringHandler]` overloads to every `Send*`/`Public/Local/Private/NonlocalOverheadMessage`/`Say`/`Emote`/`Whisper`/`Yell`/`SendLocalizedMessageTo` API in `OutgoingMessagePackets`, `Mobile`, and `Item`. Each overload is a 3-line shim that forwards `handler.Text` to the existing span-based path then calls `handler.Clear()` to return the rented `STArrayPool<char>` buffer (matches the established `SpanWriter.WriteAscii(ref RawInterpolatedStringHandler)` precedent).
- Converts `string text/args/affix/name` parameters to `ReadOnlySpan<char>` for consistency with the handler path. `lang` intentionally stays `string` (it's never interpolated and the `??= "ENU"` fallback stays cleaner).
- Adds `int charCount` overloads of the three `GetMaxMessage*Length` helpers so stackalloc sizing can avoid the redundant `ROS<char>` round-trip.
- Moves `Mobile` (17 methods) and `Item` (4 methods) message methods into new partial-class files (`Mobile.Messages.cs`, `Item.Messages.cs`) for organization.

No UOContent call sites change in this PR — existing `string`/`ROS<char>` calls compile unchanged via implicit conversion. Phase 2 (intermediate-string audit) and Phase 3 (cleanup PRs) follow.

## Files

- `Projects/Server/Network/Packets/OutgoingMessagePackets.cs` — `string` → `ROS<char>` for text params, `int charCount` length helpers added, class made `partial`
- `Projects/Server/Network/Packets/OutgoingMessagePackets.Interpolated.cs` (new) — 3 `ref RawInterpolatedStringHandler` extension overloads
- `Projects/Server/Mobiles/Mobile.cs` — message methods extracted (-262 lines)
- `Projects/Server/Mobiles/Mobile.Messages.cs` (new, 463 lines) — moved + ROS-converted methods + 25 handler overloads
- `Projects/Server/Items/Item.cs` — message methods extracted (-93 lines)
- `Projects/Server/Items/Item.Messages.cs` (new, 142 lines) — moved + ROS-converted methods + 4 handler overloads
- `Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs` — 3 new regression tests verifying byte-equivalence for the handler overloads
This commit is contained in:
Kamron Batman 2026-05-03 18:04:02 -07:00 committed by GitHub
parent b150c48328
commit 5f9fa88220
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 904 additions and 409 deletions

View file

@ -0,0 +1,21 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: IBroadcastFilter.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
namespace Server.Network;
public interface IBroadcastFilter
{
bool Allow(NetState state);
}

View file

@ -0,0 +1,182 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: OutgoingMessagePackets.Broadcast.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
namespace Server.Network;
public static partial class OutgoingMessagePackets
{
public static void BroadcastMessageLocalized<TFilter>(
Map map, Point3D location,
Serial serial, int graphic, MessageType type, int hue, int font,
int number, ReadOnlySpan<char> name, ReadOnlySpan<char> args,
TFilter filter
) where TFilter : struct, IBroadcastFilter
{
if (map == null)
{
return;
}
var buffer = stackalloc byte[GetMaxMessageLocalizedLength(args.Length)].InitializePacket();
foreach (var state in map.GetClientsInRange(location))
{
if (filter.Allow(state))
{
var length = CreateMessageLocalized(
buffer, serial, graphic, type, hue, font, number, name, args
);
if (length != buffer.Length)
{
buffer = buffer[..length];
}
state.Send(buffer);
}
}
}
public static void BroadcastMessageLocalized<TFilter>(
Map map, Point3D location, int range,
Serial serial, int graphic, MessageType type, int hue, int font,
int number, ReadOnlySpan<char> name, ReadOnlySpan<char> args,
TFilter filter
) where TFilter : struct, IBroadcastFilter
{
if (map == null)
{
return;
}
var buffer = stackalloc byte[GetMaxMessageLocalizedLength(args.Length)].InitializePacket();
foreach (var state in map.GetClientsInRange(location, range))
{
if (filter.Allow(state))
{
var length = CreateMessageLocalized(
buffer, serial, graphic, type, hue, font, number, name, args
);
if (length != buffer.Length)
{
buffer = buffer[..length];
}
state.Send(buffer);
}
}
}
public static void BroadcastMessageLocalizedAffix<TFilter>(
Map map, Point3D location,
Serial serial, int graphic, MessageType type, int hue, int font,
int number, ReadOnlySpan<char> name, AffixType affixType,
ReadOnlySpan<char> affix, ReadOnlySpan<char> args,
TFilter filter
) where TFilter : struct, IBroadcastFilter
{
if (map == null)
{
return;
}
var buffer = stackalloc byte[GetMaxMessageLocalizedAffixLength(affix.Length, args.Length)].InitializePacket();
foreach (var state in map.GetClientsInRange(location))
{
if (filter.Allow(state))
{
var length = CreateMessageLocalizedAffix(
buffer, serial, graphic, type, hue, font, number, name, affixType, affix, args
);
if (length != buffer.Length)
{
buffer = buffer[..length];
}
state.Send(buffer);
}
}
}
public static void BroadcastMessage<TFilter>(
Map map, Point3D location,
Serial serial, int graphic, MessageType type, int hue, int font, bool ascii,
string lang, ReadOnlySpan<char> name, ReadOnlySpan<char> text,
TFilter filter
) where TFilter : struct, IBroadcastFilter
{
if (map == null)
{
return;
}
var buffer = stackalloc byte[GetMaxMessageLength(text.Length)].InitializePacket();
foreach (var state in map.GetClientsInRange(location))
{
if (filter.Allow(state))
{
var length = CreateMessage(
buffer, serial, graphic, type, hue, font, ascii, lang, name, text
);
if (length != buffer.Length)
{
buffer = buffer[..length];
}
state.Send(buffer);
}
}
}
public static void BroadcastMessage<TFilter>(
Map map, Point3D location, int range,
Serial serial, int graphic, MessageType type, int hue, int font, bool ascii,
string lang, ReadOnlySpan<char> name, ReadOnlySpan<char> text,
TFilter filter
) where TFilter : struct, IBroadcastFilter
{
if (map == null)
{
return;
}
var buffer = stackalloc byte[GetMaxMessageLength(text.Length)].InitializePacket();
foreach (var state in map.GetClientsInRange(location, range))
{
if (filter.Allow(state))
{
var length = CreateMessage(
buffer, serial, graphic, type, hue, font, ascii, lang, name, text
);
if (length != buffer.Length)
{
buffer = buffer[..length];
}
state.Send(buffer);
}
}
}
}

View file

@ -0,0 +1,57 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: OutgoingMessagePackets.Interpolated.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using Server.Buffers;
namespace Server.Network;
public static partial class OutgoingMessagePackets
{
public static void SendMessageLocalized(
this NetState ns,
Serial serial, int graphic, MessageType type, int hue, int font, int number,
ReadOnlySpan<char> name,
ref RawInterpolatedStringHandler args
)
{
ns.SendMessageLocalized(serial, graphic, type, hue, font, number, name, args.Text);
args.Clear();
}
public static void SendMessageLocalizedAffix(
this NetState ns,
Serial serial, int graphic, MessageType type, int hue, int font, int number,
ReadOnlySpan<char> name, AffixType affixType, ReadOnlySpan<char> affix,
ref RawInterpolatedStringHandler args
)
{
ns.SendMessageLocalizedAffix(
serial, graphic, type, hue, font, number, name, affixType, affix, args.Text
);
args.Clear();
}
public static void SendMessage(
this NetState ns,
Serial serial, int graphic, MessageType type, int hue, int font, bool ascii,
string lang, ReadOnlySpan<char> name,
ref RawInterpolatedStringHandler text
)
{
ns.SendMessage(serial, graphic, type, hue, font, ascii, lang, name, text.Text);
text.Clear();
}
}

View file

@ -29,11 +29,12 @@ public enum AffixType : byte
System = 0x02
}
public static class OutgoingMessagePackets
public static partial class OutgoingMessagePackets
{
public static void SendMessageLocalized(
this NetState ns,
Serial serial, int graphic, MessageType type, int hue, int font, int number, string name = "", string args = ""
Serial serial, int graphic, MessageType type, int hue, int font, int number,
ReadOnlySpan<char> name = default, ReadOnlySpan<char> args = default
)
{
if (ns.CannotSendPackets())
@ -41,7 +42,7 @@ public static class OutgoingMessagePackets
return;
}
var buffer = stackalloc byte[GetMaxMessageLocalizedLength(args)].InitializePacket();
var buffer = stackalloc byte[GetMaxMessageLocalizedLength(args.Length)].InitializePacket();
var length = CreateMessageLocalized(
buffer, serial, graphic, type, hue, font, number, name, args
);
@ -50,11 +51,16 @@ public static class OutgoingMessagePackets
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int GetMaxMessageLocalizedLength(string args) => 50 + (args?.Length ?? 0) * 2;
public static int GetMaxMessageLocalizedLength(int argsCharCount) => 50 + argsCharCount * 2;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int GetMaxMessageLocalizedLength(ReadOnlySpan<char> args) =>
GetMaxMessageLocalizedLength(args.Length);
public static int CreateMessageLocalized(
Span<byte> buffer,
Serial serial, int graphic, MessageType type, int hue, int font, int number, string name = "", string args = ""
Serial serial, int graphic, MessageType type, int hue, int font, int number,
ReadOnlySpan<char> name = default, ReadOnlySpan<char> args = default
)
{
if (buffer[0] != 0)
@ -62,9 +68,6 @@ public static class OutgoingMessagePackets
return buffer.Length;
}
name ??= "";
args ??= "";
if (hue == 0)
{
hue = 0x3B2;
@ -88,8 +91,9 @@ public static class OutgoingMessagePackets
public static void SendMessageLocalizedAffix(
this NetState ns,
Serial serial, int graphic, MessageType type, int hue, int font, int number, string name,
AffixType affixType, string affix = "", string args = ""
Serial serial, int graphic, MessageType type, int hue, int font, int number,
ReadOnlySpan<char> name, AffixType affixType,
ReadOnlySpan<char> affix = default, ReadOnlySpan<char> args = default
)
{
if (ns.CannotSendPackets())
@ -97,7 +101,7 @@ public static class OutgoingMessagePackets
return;
}
var buffer = stackalloc byte[GetMaxMessageLocalizedAffixLength(affix, args)].InitializePacket();
var buffer = stackalloc byte[GetMaxMessageLocalizedAffixLength(affix.Length, args.Length)].InitializePacket();
var length = CreateMessageLocalizedAffix(
buffer, serial, graphic, type, hue, font, number, name, affixType, affix, args
);
@ -106,13 +110,18 @@ public static class OutgoingMessagePackets
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int GetMaxMessageLocalizedAffixLength(string affix, string args) =>
52 + (affix?.Length ?? 0) + (args?.Length ?? 0) * 2;
public static int GetMaxMessageLocalizedAffixLength(int affixCharCount, int argsCharCount) =>
52 + affixCharCount + argsCharCount * 2;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int GetMaxMessageLocalizedAffixLength(ReadOnlySpan<char> affix, ReadOnlySpan<char> args) =>
GetMaxMessageLocalizedAffixLength(affix.Length, args.Length);
public static int CreateMessageLocalizedAffix(
Span<byte> buffer,
Serial serial, int graphic, MessageType type, int hue, int font, int number, string name,
AffixType affixType, string affix = "", string args = ""
Serial serial, int graphic, MessageType type, int hue, int font, int number,
ReadOnlySpan<char> name, AffixType affixType,
ReadOnlySpan<char> affix = default, ReadOnlySpan<char> args = default
)
{
if (buffer[0] != 0)
@ -120,10 +129,6 @@ public static class OutgoingMessagePackets
return buffer.Length;
}
name ??= "";
affix ??= "";
args ??= "";
if (hue == 0)
{
hue = 0x3B2;
@ -149,7 +154,8 @@ public static class OutgoingMessagePackets
public static void SendMessage(
this NetState ns,
Serial serial, int graphic, MessageType type, int hue, int font, bool ascii, string lang, string name, string text
Serial serial, int graphic, MessageType type, int hue, int font, bool ascii,
string lang, ReadOnlySpan<char> name, ReadOnlySpan<char> text
)
{
if (ns.CannotSendPackets())
@ -157,7 +163,7 @@ public static class OutgoingMessagePackets
return;
}
var buffer = stackalloc byte[GetMaxMessageLength(text)].InitializePacket();
var buffer = stackalloc byte[GetMaxMessageLength(text.Length)].InitializePacket();
var length = CreateMessage(
buffer,
serial,
@ -175,7 +181,10 @@ public static class OutgoingMessagePackets
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int GetMaxMessageLength(string text) => 50 + (text?.Length ?? 0) * 2;
public static int GetMaxMessageLength(int textCharCount) => 50 + textCharCount * 2;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int GetMaxMessageLength(ReadOnlySpan<char> text) => GetMaxMessageLength(text.Length);
public static int CreateMessage(
Span<byte> buffer,
@ -186,8 +195,8 @@ public static class OutgoingMessagePackets
int font,
bool ascii,
string lang,
string name,
string text
ReadOnlySpan<char> name,
ReadOnlySpan<char> text
)
{
if (buffer[0] != 0)
@ -195,8 +204,6 @@ public static class OutgoingMessagePackets
return buffer.Length;
}
name ??= "";
text ??= "";
lang ??= "ENU";
if (hue == 0)