ModernUO/dev-docs/string-handling.md
Kamron Batman 61e41df00c
feat: Add zero-alloc interpolation handler to ValueStringBuilder, replace all StringBuilder usage (#2387)
## 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
2026-03-22 14:23:44 -07:00

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

  1. ValueStringBuilder
  2. RawInterpolatedStringHandler
  3. StringHelpers
  4. TextEncoding
  5. 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[] params equivalent for format strings. All formatting goes through $"..." interpolation which uses ISpanFormattable.TryFormat directly — zero boxing, zero intermediate strings.

  • No chained Append: Append() returns void, not this. Write sb.Append(a); sb.Append(b); instead of sb.Append(a).Append(b).

  • Ref struct constraints: Cannot be stored in fields, captured by lambdas, or used in async methods. Scoped to the declaring method.

  • using var + ref conflict: A using variable cannot be passed by ref. If extension methods take ref ValueStringBuilder, use manual Dispose() 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 internal char[] allocations
  • The StringBuilder object allocation itself (24+ bytes on heap)
  • Thread-safe overhead in ArrayPool<char>.Shared (VSB uses lock-free STArrayPool)

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