## 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.
16 KiB
String Handling in ModernUO
This document covers the string building utilities in Projects/Server/Text/ and Projects/Server/Buffers/, when to use each, and how to avoid common allocation pitfalls.
Table of Contents
- ValueStringBuilder
- RawInterpolatedStringHandler
- Interpolation Anti-Patterns
- StringHelpers
- TextEncoding
- Decision Guide
ValueStringBuilder
Location: Projects/Server/Buffers/ValueStringBuilder.cs
Namespace: Server.Text
A ref struct string builder that avoids heap allocations entirely when backed by stackalloc. This is the preferred string builder for all ModernUO code — do not use System.Text.StringBuilder.
Construction Patterns
Stackalloc (preferred for bounded output):
// Best: zero heap allocation, zero pool overhead
using var sb = new ValueStringBuilder(stackalloc char[128]);
sb.Append($"Hello {name}, score: {score}");
return sb.ToString();
Pooled (for unbounded or large output):
// Rents from STArrayPool — returned on Dispose
using var sb = ValueStringBuilder.Create(256);
// or with default capacity (64):
using var sb = ValueStringBuilder.Create();
Choosing Capacity
| Output size | Pattern |
|---|---|
| Known, <=256 chars | new ValueStringBuilder(stackalloc char[N]) |
| Known, >256 chars | ValueStringBuilder.Create(N) |
| Unbounded/unknown | ValueStringBuilder.Create() (grows automatically) |
If the stackalloc buffer is too small, the builder automatically grows to a pooled array. This is safe but costs a pool rent — size the stackalloc to fit the expected output.
String Interpolation ($"...")
ValueStringBuilder supports $"..." syntax via a copy-and-reconcile InterpolationHandler. This writes directly into the builder's buffer — no intermediate allocation, even with stackalloc.
// Works with stackalloc — zero allocation
using var sb = new ValueStringBuilder(stackalloc char[64]);
sb.Append($"Player {name} has {kills} kills");
sb.Append($" and {bounty} gold bounty");
How it works: The compiler passes a value copy of the builder to the handler. The copy shares the same underlying buffer (Span points to the same memory), so writes go to the original buffer. Append() reconciles by copying the handler's updated state back to the original. If the handler triggers a Grow(), the reconciliation updates the buffer reference.
Reusing a Builder
Use Reset() to clear the builder for reuse instead of creating a new one:
using var sb = new ValueStringBuilder(stackalloc char[128]);
foreach (var item in items)
{
sb.Reset(); // clear for next iteration
sb.Append($"{item.Name}: {item.Value}");
Process(sb.ToString());
}
Reading the Result
| Method | Use when |
|---|---|
sb.ToString() |
You need a string (allocates) |
sb.AsSpan() |
You can consume a ReadOnlySpan<char> (zero-alloc) |
sb.AsSpan(terminate: true) |
You need a null-terminated span |
Disposal
Always use using var for automatic disposal:
using var sb = new ValueStringBuilder(stackalloc char[64]);
If using var is not possible (e.g., the builder is passed by ref to extension methods, or goto case in switch blocks), use manual Dispose():
var sb = new ValueStringBuilder(stackalloc char[64]);
sb.AppendSpaceWithArticle(text, articleAn); // takes ref ValueStringBuilder
var result = sb.ToString();
sb.Dispose();
For stackalloc-only builders that never grow, Dispose() is a no-op. But always call it defensively — if a future change triggers growth, the pooled array needs returning.
Limitations
-
No
AppendFormat: Use$"..."interpolation instead — it's more readable and zero-allocation:// StringBuilder (old): sb.AppendFormat("{0:N0} points, {1:N0} kills", score, kills); // ValueStringBuilder: sb.Append($"{score:N0} points, {kills:N0} kills");There is no
object[] paramsequivalent for format strings. All formatting goes through$"..."interpolation which usesISpanFormattable.TryFormatdirectly — zero boxing, zero intermediate strings. -
No chained Append:
Append()returnsvoid, notthis. Writesb.Append(a); sb.Append(b);instead ofsb.Append(a).Append(b). -
Ref struct constraints: Cannot be stored in fields, captured by lambdas, or used in
asyncmethods. Scoped to the declaring method. -
using var+refconflict: Ausingvariable cannot be passed byref. If extension methods takeref ValueStringBuilder, use manualDispose()instead.
RawInterpolatedStringHandler
Location: Projects/Server/Buffers/RawInterpolatedStringHandler.cs
Namespace: Server.Buffers
A [InterpolatedStringHandler] ref struct that renders an interpolated string directly into a char[] rented from STArrayPool<char>.Shared. Used as a parameter type to make zero-allocation interpolation overloads possible — when the caller writes $"...", the compiler synthesizes the handler, fills it with the formatted chars, and the receiving method passes handler.Text to its underlying span path.
You normally don't construct this directly: it's used as a parameter type. Most ModernUO APIs that accept formatted text already provide a ref RawInterpolatedStringHandler overload alongside the string / ReadOnlySpan<char> overload. The compiler picks the handler overload automatically when the argument is a $"..." literal.
APIs that accept ref RawInterpolatedStringHandler
SpanWriter.WriteAscii,WriteLatin1,Write(Encoding, …)— packet buildingMobile.SendMessage,SendLocalizedMessage,SendAsciiMessage,Public/Local/Nonlocal/PrivateOverheadMessage,Say,Emote,Whisper,Yell— player-facing chatItem.PublicOverheadMessage,SendLocalizedMessageTo,SendMessageTo— item-attributed messagesOutgoingMessagePackets.SendMessageLocalized,SendMessageLocalizedAffix,SendMessage— direct NetState extensionsHtml.Center,Html.Color,Html.Right— gump HTML helpers
When in doubt, just write $"..." at the call site — if a handler overload exists, the compiler picks it.
:L Lowercase Format Specifier
RawInterpolatedStringHandler recognizes :L as a custom format specifier that lowercases the value's output in-place using char.ToLowerInvariant. Handles surrogate pairs correctly via the BCL's vectorized MemoryExtensions.ToLowerInvariant.
mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold" instead of "Gold"
mob.SendMessage($"Welcome, {playerName:L}"); // lowercased name
mob.SendMessage($"{count:L} kills"); // ints unchanged ("42")
This eliminates the value.ToString().ToLowerInvariant() two-allocation idiom. Works for any type that goes through the handler (enums, strings, anything ISpanFormattable).
The format string is case-sensitive — :l is not recognized. Match the convention of standard format specifiers (:N0, :F2, etc.) and use uppercase :L.
Pooled buffer lifecycle
RawInterpolatedStringHandler rents a char[] from STArrayPool<char>.Shared on construction (sized by the literal length + an estimate of formatted chars per hole). Methods that take ref RawInterpolatedStringHandler are responsible for calling handler.Clear() after consuming handler.Text, which returns the buffer to the pool. The rent is single-threaded and lock-free (~tens of nanoseconds), so the cost is negligible compared to the string allocation it replaces.
Interpolation Anti-Patterns
ModernUO has many APIs with ref RawInterpolatedStringHandler overloads (messages, gumps, packets, OPL — see the list above). The handler overload is only selected when the call-site argument is a $"..." literal directly in position. Several patterns silently defeat handler binding and fall back to a string-allocating path. Each pattern below has a "before" and "after" — apply the "after" form when writing or reviewing code that interpolates into any handler-aware API.
1. Ternary with interpolated branches
// BAD — the ternary unifies branches as `string`; handler overload not selected
mob.SendMessage(cond ? $"a {x}" : $"b {y}");
// GOOD — each branch is a separate call, each binds to the handler overload
if (cond)
{
mob.SendMessage($"a {x}");
}
else
{
mob.SendMessage($"b {y}");
}
RawInterpolatedStringHandler is a ref struct and cannot appear in a conditional expression result type — the C# compiler unifies the ternary branches to string, and the call binds to the string / ROS<char> overload, allocating the message text per call.
2. Switch expression with interpolated arms
// BAD — switch expression branches unify as `string`
mob.SendMessage(thing switch
{
1 => $"a {x}",
_ => $"b"
});
// GOOD — switch statement, each arm calls the handler-aware API directly
switch (thing)
{
case 1:
mob.SendMessage($"a {x}");
break;
default:
mob.SendMessage($"b");
break;
}
Same root cause as the ternary case.
3. Pre-built local typed as string
// BAD — `msg` is a `string`; ROS<char> overload picked, not the handler
var msg = $"foo {x}";
mob.SendMessage(msg);
// GOOD — inline at the call site so the compiler sees the literal
mob.SendMessage($"foo {x}");
If the local is reused (multiple calls, multiple branches), keep the local — pre-building avoids re-interpolating per send. Inline only when the local is single-use.
4. .ToString() (or any string-returning method) inside a hole
// BAD — .ToString() allocates a string before the handler copies the chars
mob.SendMessage($"You are now {accessLevel.ToString()}.");
mob.SendMessage($"Your guild is {td.String()}.");
// GOOD — drop the .ToString() and let the handler format the value directly
mob.SendMessage($"You are now {accessLevel}.");
mob.SendMessage($"Your guild is {td}.");
The handler's AppendFormatted<T> calls ISpanFormattable.TryFormat on the value directly, with zero intermediate string. An explicit .ToString() defeats this. The same applies to .String() (TextDefinition), .GetValue(), .AsHexString(), and any other method that returns a freshly allocated string.
For values that don't implement ISpanFormattable, the handler falls back to value.ToString() internally — same allocation as the explicit call, but at least the call site is consistent.
For lowercase output, use the :L format specifier instead of .ToString().ToLowerInvariant() (see RawInterpolatedStringHandler).
5. String concatenation inside a hole
// BAD — `+` on strings allocates an intermediate string
mob.SendMessage($"Total: {a + b}");
mob.SendMessage($"Title: {string.Concat(prefix, name)}");
// GOOD — multiple holes, each formatted directly into the buffer
mob.SendMessage($"Total: {a}{b}");
mob.SendMessage($"Title: {prefix}{name}");
Note: int + int inside a hole is arithmetic, not concatenation — that's fine. The anti-pattern is string + string or string + value.
6. string.Format feeding a handler-aware API
// BAD — string.Format allocates a string the handler then re-buffers
mob.SendMessage(string.Format("You earned {0:N0} gold", amount));
// GOOD — the handler formats `amount` directly into its buffer
mob.SendMessage($"You earned {amount:N0} gold");
7. LINQ-built strings inside a hole
// BAD — Select/Aggregate/Join on strings allocates a chain of intermediates
mob.SendMessage($"Allies: {names.Aggregate((a, b) => $"{a}, {b}")}");
// GOOD — build via ValueStringBuilder, pass the span
using var sb = new ValueStringBuilder(stackalloc char[256]);
sb.Append("Allies: ");
for (var i = 0; i < names.Count; i++)
{
if (i > 0)
{
sb.Append(", ");
}
sb.Append(names[i]);
}
mob.SendMessage(sb.AsSpan());
For unbounded inputs, use ValueStringBuilder.Create() and call mob.SendMessage(sb.ToString()) if the consumer needs a string (one allocation, vs LINQ's many).
8. Pre-built concat var
// BAD — concatenation allocates, then the local picks the ROS overload
var s = obj.Name + " says hi";
mob.SendMessage(s);
// GOOD — interpolation literal at the call site
mob.SendMessage($"{obj.Name} says hi");
Why these matter
These patterns aren't bugs — they produce correct output. But they each leak a string per call, and message/gump/OPL APIs are called constantly during gameplay. The handler overload exists specifically to eliminate that allocation, but only when the call-site argument is a direct $"..." literal.
When in doubt, ask: "is the handler overload selected here?" — and if the argument is anything other than a top-level $"..." literal in the parameter slot, the answer is no.
StringHelpers
Location: Projects/Server/Text/StringHelpers.cs
Namespace: Server.Text
Extension methods for common string operations:
| Method | Description |
|---|---|
Wrap(string, int perLine, int maxLines) |
Word-wrap text into lines |
AppendSpaceWithArticle(ref ValueStringBuilder, string, bool) |
Append with "a"/"an" article prefix |
Remove(ReadOnlySpan<char>, ...) |
Filter substrings from spans |
Capitalize(string) |
Title-case with "the" handling |
TrimMultiline(string) |
Trim each line in multiline text |
TextEncoding
Location: Projects/Server/Text/TextEncoding.cs
Namespace: Server.Text
UTF-8/Unicode encoding utilities used by the networking layer:
| Method | Description |
|---|---|
GetBytesUtf8(string, Span<byte>) |
Encode string to UTF-8 in buffer |
GetBytesUtf8(ReadOnlySpan<char>, Span<byte>) |
Encode char span to UTF-8 |
GetStringUtf8(Span<byte>) |
Decode UTF-8 bytes to string (with filtering) |
Decision Guide
When to use what
Need to build a string?
├── In a hot path (packets, ticks, spatial queries)?
│ └── ValueStringBuilder with stackalloc
├── In game content (gumps, messages, commands)?
│ └── ValueStringBuilder with stackalloc (bounded) or Create() (unbounded)
├── Building an ObjectPropertyList tooltip?
│ └── Use IPropertyList.Add($"...") — has its own handler
├── In async/multi-threaded code (rare)?
│ └── ValueStringBuilder.CreateMT() or System.Text.StringBuilder
└── Never → System.Text.StringBuilder
Do NOT use System.Text.StringBuilder
ValueStringBuilder replaces StringBuilder in all game code. It avoids:
- GC pressure from
StringBuilder's internalchar[]allocations - The
StringBuilderobject allocation itself (24+ bytes on heap) - Thread-safe overhead in
ArrayPool<char>.Shared(VSB uses lock-freeSTArrayPool)
Common patterns
Instead of string concatenation:
// BAD: allocates intermediate strings
var msg = "Player " + name + " has " + kills + " kills";
// GOOD: zero allocation with stackalloc
using var sb = new ValueStringBuilder(stackalloc char[64]);
sb.Append($"Player {name} has {kills} kills");
var msg = sb.ToString(); // single allocation for the final string
Instead of StringBuilder:
// BAD: StringBuilder allocates on heap
var sb = new StringBuilder();
sb.Append(name);
sb.Append(": ");
sb.Append(value);
return sb.ToString();
// GOOD: ValueStringBuilder with stackalloc
using var sb = new ValueStringBuilder(stackalloc char[64]);
sb.Append($"{name}: {value}");
return sb.ToString();
For packet string construction (hot path):
// Use AsSpan() to avoid ToString() allocation when the consumer accepts spans
using var sb = new ValueStringBuilder(stackalloc char[32]);
sb.Append(bounty);
sb.Append(" gold");
writer.WriteString(sb.AsSpan(), textBuffer); // zero-copy