## Summary
- **Add a self-referencing `InterpolationHandler` to `ValueStringBuilder`** that writes directly into the builder's buffer — zero intermediate allocation, works with `stackalloc`-backed builders
- **Replace all `System.Text.StringBuilder` usage** across the codebase with `ValueStringBuilder`
- **Convert `ValueStringBuilder.Create()` to `stackalloc`** at 10 sites where output length is provably bounded
- **Convert manual `Dispose()` to `using var`** where possible, and hoist loop-scoped builders outside loops with `Reset()`
- **Convert verbose `Append()` chains to `Append($"...")`** interpolation for readability
- **Add comprehensive documentation** for string handling patterns
## InterpolationHandler Design
`ValueStringBuilder` is a `ref struct`, which creates challenges for C#'s interpolated string handler pattern:
- **`ref` fields to ref structs are not allowed** (CS9050)
- **`[InterpolatedStringHandlerArgument("")]` passes struct receivers by value**, not by ref
- **`ISelfInterpolatedStringHandler` requires boxing** ref structs into interface fields
**Solution: Copy-and-reconcile pattern.** The handler receives a value copy of the builder. The copy shares the same underlying `char` buffer (`Span` points to the same `stackalloc`/pooled memory), so writes go to the original buffer. `Append()` reconciles by `this = handler._builder`, updating `_length` and any buffer references changed by `Grow()`.
This is safe because:
- The game loop is single-threaded — no concurrent access between handler construction and reconciliation
- If `Grow()` occurs in the copy, the original's stale buffer isn't accessed until `Append()` replaces it
- `Dispose()` correctly returns the reconciled buffer to the pool
## Changes by Category
### ValueStringBuilder (`Projects/Server/Buffers/ValueStringBuilder.cs`)
- Added nested `InterpolationHandler` ref struct with copy-and-reconcile pattern
- Added `Append([InterpolatedStringHandlerArgument("")] scoped ref InterpolationHandler)` method
- Removed `RawInterpolatedStringHandler` overloads (new handler replaces them)
- All `AppendFormatted` overloads delegate to existing `Append` methods (no code duplication)
- Alignment support via direct private field access (nested type privilege)
### StringBuilder → ValueStringBuilder (15 files)
Replaced all `new StringBuilder()` with `ValueStringBuilder.Create()` or `stackalloc`:
- ConPVP games: KingOfTheHill, DoubleDom, CTF, BombingRun, TourneyMatch
- ConPVP infrastructure: Tournament, Participant, TourneyParticipant
- ConPVP gumps: ArenaGump, TournamentBracketGump, AcceptTeamGump, ConfirmSignupGump
- Commands: Handlers, Logging, Add
- Other: TownCrier, SpeechLogGump, TestCenter
Key patterns:
- `sb = new StringBuilder()` reassignment → `sb.Reset()`
- `sb.AppendFormat("{0:N0}", value)` → `sb.Append($"{value:N0}")`
- `sb.Append(x).Append(y)` chains → separate statements (VSB returns void)
### Create() → stackalloc (10 files)
Converted heap-allocated builders to stackalloc where output is bounded:
- ClientVersion (32), MapSelection (160), HouseRaffleStone (48)
- HolySense (96), UnholySense (96), ClientVerification (192)
- AcceptTeamGump (64), ConfirmSignupGump (64)
- BaseWeapon (160), BaseArmor (128)
### Loop optimizations (2 files)
Hoisted `ValueStringBuilder` creation outside loops with `Reset()` per iteration:
- TourneyMatch.cs: `using var` inside for loop → stackalloc before loop
- ArenaGump.cs: `Create()` + `Dispose()` per iteration → stackalloc before loop
### Append chain → interpolation (5 files)
Converted multi-line `Append()` chains to `Append($"...")`:
- BountyMessage.cs: title switch (6 cases), paragraph (15→1 Append), description lines, closing
- AcceptTeamGump, ConfirmSignupGump, TournamentBracketGump: tournament type strings
- AdminGump: comment/tag formatting in loops
### Documentation
- `dev-docs/string-handling.md`: Full reference — construction, interpolation, disposal, decision guide
- `dev-docs/claude-skills/modernuo-string-handling.md`: Claude skill with quick reference
- `CLAUDE.md`: Added rule 17 (no StringBuilder), dev-docs table entry, skills table entry
- `dev-docs/code-standards.md`: Updated memory management section
## Test Plan
- [x] `dotnet build` — 0 errors, 0 warnings
- [x] `dotnet test` — 940/940 tests pass
- [x] 28 ValueStringBuilder tests covering all reconciliation scenarios:
- Stackalloc no-grow, stackalloc with grow (→pool transition)
- Heap no-grow, heap with grow, heap double grow
- Pre-existing content with and without grow
- Sequential multiple `Append($"...")` calls
- Mixed plain + interpolated Append
- Empty interpolation, literal-only, format specifiers
- Null string holes, ISpanFormattable types
- Dispose after stackalloc→pool grow
7.8 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
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 used internally by ValueStringBuilder's interpolation support. You should not need to use this directly — use sb.Append($"...") instead.
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