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

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