diff --git a/CLAUDE.md b/CLAUDE.md index 0a7a456e0..eca813754 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 14. **PropertyList string literals must be holes** — `$"{"Map"}\t{value}"` not `$"Map\t{value}"`. The handler treats bare text as delimiters, `{}` holes as arguments. Only `\t` should be a bare literal → `dev-docs/property-lists.md` 15. **Braces required on all control flow** — `if`, `else`, `for`, `foreach`, `while`, `do`, `switch` must always have braces, even for single-line bodies → `dev-docs/code-standards.md` 16. **Prefer switch expressions and switch-when** — use switch expressions for value mapping and switch-when for pattern matching where they improve readability. Exception: skip if unreadable or cold path → `dev-docs/code-standards.md` +17. **No `System.Text.StringBuilder`** — use `ValueStringBuilder` with `stackalloc` (bounded output) or `ValueStringBuilder.Create()` (unbounded). Supports `$"..."` interpolation directly. Always use `using var` for disposal. Use `Reset()` instead of reassigning → `dev-docs/string-handling.md` ## Dev-Docs Reference @@ -45,6 +46,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. | Configuration system | `dev-docs/configuration.md` | | Networking & packets | `dev-docs/networking-packets.md` | | Region system | `dev-docs/regions.md` | +| String handling & ValueStringBuilder | `dev-docs/string-handling.md` | | RunUO migration (overview) | `dev-docs/runuo-migration-docs/00-overview.md` | | RunUO migration (all docs) | `dev-docs/runuo-migration-docs/` | @@ -73,6 +75,7 @@ Then copy only the relevant skill files based on the task: | Timer work | `modernuo-timers`, `modernuo-serialization` | | Config system | `modernuo-configuration` | | Era-conditional code | `modernuo-era-expansion` | +| String building / formatting | `modernuo-string-handling` | | Code review / audit | `modernuo-code-audit` | | Any `.cs` file edit | `modernuo-code-audit` (always offer for code changes) | | **RunUO Migration** | | diff --git a/Projects/Server.Tests/Tests/Buffers/ValueStringBuilderTests.cs b/Projects/Server.Tests/Tests/Buffers/ValueStringBuilderTests.cs index 0cc89d821..7299a94c4 100644 --- a/Projects/Server.Tests/Tests/Buffers/ValueStringBuilderTests.cs +++ b/Projects/Server.Tests/Tests/Buffers/ValueStringBuilderTests.cs @@ -40,10 +40,255 @@ public class ValueStringBuilderTests public void TestAppendInterpolation(object value) { var sb = ValueStringBuilder.Create(); - sb.Append( $"Hi, this is {value}."); + sb.Append($"Hi, this is {value}."); sb.Append(" I am a string."); Assert.Equal($"Hi, this is {value}. I am a string.", sb.ToString()); sb.Dispose(); } + + // --- InterpolationHandler reconciliation tests --- + // These validate the copy-and-reconcile pattern: the handler receives a VALUE COPY of the builder, + // writes into the shared buffer, and Append() reconciles via `this = handler._builder`. + // Critical to detect if C# compiler codegen changes break this assumption. + + [Fact] + public void Interpolation_Stackalloc_NoGrow() + { + // Stackalloc buffer large enough — no Grow needed. + // Validates: copy's Span shares same stackalloc memory, _length is reconciled. + var sb = new ValueStringBuilder(stackalloc char[64]); + sb.Append($"Hello {42} world"); + + Assert.Equal("Hello 42 world", sb.ToString()); + Assert.Equal(14, sb.Length); + sb.Dispose(); + } + + [Fact] + public void Interpolation_Stackalloc_WithGrow() + { + // Tiny stackalloc forces Grow inside the handler's copy. + // Validates: after Grow, copy moves to pooled array; reconciliation updates + // _chars, _arrayToReturnToPool, and _length on the original. + var sb = new ValueStringBuilder(stackalloc char[4]); + sb.Append($"This string is much longer than 4 chars: {12345}"); + + var expected = "This string is much longer than 4 chars: 12345"; + Assert.Equal(expected, sb.ToString()); + Assert.Equal(expected.Length, sb.Length); + // After grow, capacity should have expanded beyond 4 + Assert.True(sb.Capacity >= 47); + sb.Dispose(); + } + + [Fact] + public void Interpolation_Stackalloc_PreExistingContent_NoGrow() + { + // Append plain text first, then interpolation. Buffer large enough. + // Validates: interpolation appends after existing content, doesn't overwrite. + var sb = new ValueStringBuilder(stackalloc char[64]); + sb.Append("prefix-"); + sb.Append($"value={99}"); + + Assert.Equal("prefix-value=99", sb.ToString()); + Assert.Equal(15, sb.Length); + sb.Dispose(); + } + + [Fact] + public void Interpolation_Stackalloc_PreExistingContent_WithGrow() + { + // Fill most of a small stackalloc, then interpolate enough to force Grow. + // Validates: existing content is preserved through the Grow, new content is appended. + var sb = new ValueStringBuilder(stackalloc char[16]); + sb.Append("0123456789"); // 10 chars, 6 remaining + sb.Append($"abcdefghij{42}"); // 12 chars, exceeds remaining — Grow + + Assert.Equal("0123456789abcdefghij42", sb.ToString()); + Assert.Equal(22, sb.Length); + Assert.True(sb.Capacity >= 22); + sb.Dispose(); + } + + [Fact] + public void Interpolation_Heap_NoGrow() + { + // Heap-allocated (Create) with sufficient capacity. + // Validates: handler works with pooled backing, _length is reconciled. + var sb = ValueStringBuilder.Create(64); + sb.Append($"Score: {100}, Name: {"Test"}"); + + Assert.Equal("Score: 100, Name: Test", sb.ToString()); + Assert.Equal(22, sb.Length); + sb.Dispose(); + } + + [Fact] + public void Interpolation_Heap_WithGrow() + { + // Small heap allocation forces Grow. + // Validates: after Grow, copy's new pooled array replaces original's + // (original's old array was returned to pool by copy's Grow). + var sb = ValueStringBuilder.Create(4); + sb.Append($"This exceeds the initial 4 char capacity: {67890}"); + + var expected = "This exceeds the initial 4 char capacity: 67890"; + Assert.Equal(expected, sb.ToString()); + Assert.Equal(expected.Length, sb.Length); + Assert.True(sb.Capacity >= expected.Length); + sb.Dispose(); + } + + [Fact] + public void Interpolation_Heap_PreExistingContent_WithGrow() + { + // Heap with pre-existing content, then Grow via interpolation. + // Validates: existing content preserved, pooled array properly transitioned. + var sb = ValueStringBuilder.Create(8); + sb.Append("ABCD"); + sb.Append($"EFGHIJKLMNOP{42}"); + + Assert.Equal("ABCDEFGHIJKLMNOP42", sb.ToString()); + Assert.Equal(18, sb.Length); + sb.Dispose(); + } + + [Fact] + public void Interpolation_Sequential_MultipleAppends() + { + // Multiple sequential Append($"...") calls. + // Validates: each reconciliation correctly advances _length, subsequent calls + // see the updated state from prior reconciliations. + var sb = new ValueStringBuilder(stackalloc char[128]); + sb.Append($"A={1}"); + sb.Append($" B={2}"); + sb.Append($" C={3}"); + sb.Append($" D={4}"); + + var expected = "A=1 B=2 C=3 D=4"; + Assert.Equal(expected, sb.ToString()); + Assert.Equal(expected.Length, sb.Length); + sb.Dispose(); + } + + [Fact] + public void Interpolation_Sequential_GrowOnSecondAppend() + { + // First Append fits, second triggers Grow. + // Validates: reconciliation after first Append leaves builder in a state + // that the second Append (with Grow) works correctly. + var sb = new ValueStringBuilder(stackalloc char[16]); + sb.Append($"Fits: {1}"); // 7 chars, fits in 16 + sb.Append($" - Now this is a much longer string that forces growth: {999}"); + + Assert.Equal("Fits: 1 - Now this is a much longer string that forces growth: 999", sb.ToString()); + sb.Dispose(); + } + + [Fact] + public void Interpolation_MixedAppendStyles() + { + // Mix plain Append with interpolated Append. + // Validates: reconciliation is compatible with non-interpolated Append calls. + var sb = new ValueStringBuilder(stackalloc char[64]); + sb.Append("plain-"); + sb.Append($"interp={42}-"); + sb.Append("plain2-"); + sb.Append($"interp2={99}"); + + Assert.Equal("plain-interp=42-plain2-interp2=99", sb.ToString()); + sb.Dispose(); + } + + [Fact] + public void Interpolation_EmptyInterpolation() + { + // Empty interpolation expression. + // Validates: handler with zero holes still reconciles correctly. + var sb = new ValueStringBuilder(stackalloc char[32]); + sb.Append("before"); + sb.Append($""); + sb.Append("after"); + + Assert.Equal("beforeafter", sb.ToString()); + sb.Dispose(); + } + + [Fact] + public void Interpolation_OnlyLiteral() + { + // Interpolation with no holes (just a literal). + // Validates: AppendLiteral-only path reconciles _length. + var sb = new ValueStringBuilder(stackalloc char[32]); + sb.Append($"just a literal"); + + Assert.Equal("just a literal", sb.ToString()); + sb.Dispose(); + } + + [Fact] + public void Interpolation_FormatSpecifiers() + { + // Format specifiers in interpolation holes. + // Validates: AppendFormatted with format string works through handler. + var sb = new ValueStringBuilder(stackalloc char[64]); + sb.Append($"pi={3.14159:F2}, hex={255:X4}, date={new System.DateTime(2025, 1, 15):yyyy-MM-dd}"); + + Assert.Equal("pi=3.14, hex=00FF, date=2025-01-15", sb.ToString()); + sb.Dispose(); + } + + [Fact] + public void Interpolation_SpanFormattableTypes() + { + // Various ISpanFormattable types. + // Validates: int, double, DateTime all format directly via TryFormat (no ToString allocation). + var sb = new ValueStringBuilder(stackalloc char[64]); + sb.Append($"{42}{3.14}{true}"); + + Assert.Equal("423.14True", sb.ToString()); + sb.Dispose(); + } + + [Fact] + public void Interpolation_NullString() + { + // Null string in interpolation hole. + // Validates: AppendFormatted(string?) handles null without crash. + string? name = null; + var sb = new ValueStringBuilder(stackalloc char[32]); + sb.Append($"Name: {name}!"); + + Assert.Equal("Name: !", sb.ToString()); + sb.Dispose(); + } + + [Fact] + public void Interpolation_Stackalloc_DisposeAfterGrow() + { + // After Grow moves stackalloc to pool, Dispose should return the pooled array. + // Validates: _arrayToReturnToPool is correctly reconciled so Dispose works. + var sb = new ValueStringBuilder(stackalloc char[4]); + sb.Append($"grow beyond stackalloc: {12345}"); + // If _arrayToReturnToPool wasn't reconciled, Dispose would either + // not return the pooled array (leak) or return null (no-op when it shouldn't be). + sb.Dispose(); + // No assertion needed — if _arrayToReturnToPool was wrong, pool corruption + // would surface as test failures elsewhere. The test passing = no crash. + } + + [Fact] + public void Interpolation_Heap_DoubleGrow() + { + // Force two consecutive Grows via two interpolated appends on a tiny buffer. + // Validates: reconciliation after first Grow leaves builder in valid state + // for the second Grow to succeed. + var sb = ValueStringBuilder.Create(4); + sb.Append($"First grow: {"ABCDEFGHIJ"}"); // forces first grow + sb.Append($"Second grow: {"KLMNOPQRSTUVWXYZ0123456789"}"); // forces second grow + + Assert.Equal("First grow: ABCDEFGHIJSecond grow: KLMNOPQRSTUVWXYZ0123456789", sb.ToString()); + sb.Dispose(); + } } diff --git a/Projects/Server/Buffers/ValueStringBuilder.cs b/Projects/Server/Buffers/ValueStringBuilder.cs index 5e4b9d2dd..5ea01fa46 100644 --- a/Projects/Server/Buffers/ValueStringBuilder.cs +++ b/Projects/Server/Buffers/ValueStringBuilder.cs @@ -210,15 +210,6 @@ public ref struct ValueStringBuilder } } - // Compiler generated - public void Append(ref RawInterpolatedStringHandler handler) => Append(handler.Text); - - // Compiler generated - public void Append( - IFormatProvider? formatProvider, - [InterpolatedStringHandlerArgument("formatProvider")] - ref RawInterpolatedStringHandler handler - ) => Append(handler.Text); [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -463,4 +454,128 @@ public ref struct ValueStringBuilder _length -= length; } + + // Copy-and-reconcile interpolation handler. + // The handler receives a VALUE COPY of the builder (C# limitation: [InterpolatedStringHandlerArgument("")] + // passes struct receivers by value). The copy shares the same underlying char buffer (Span points to + // the same stackalloc/pooled memory), so writes go to the original buffer. If Grow() happens, the copy + // gets a new buffer. Append() reconciles by copying the handler's state back to the original. + // Safe because the game loop is single-threaded — no concurrent access between handler construction + // and reconciliation. + + public void Append( + [InterpolatedStringHandlerArgument("")] + scoped ref InterpolationHandler handler) + { + // Reconcile: the handler's copy has the updated _length (and possibly new buffer from Grow). + this = handler._builder; + } + + [InterpolatedStringHandler] + public ref struct InterpolationHandler + { + internal ValueStringBuilder _builder; + + public InterpolationHandler(int literalLength, int formattedCount, ValueStringBuilder builder) + { + _builder = builder; + _builder.EnsureCapacity(_builder._length + literalLength + formattedCount * 11); + } + + public void AppendLiteral(string value) => _builder.Append(value); + + public void AppendFormatted(T value) => _builder.Append(value); + + public void AppendFormatted(T value, string? format) => _builder.Append(value, format); + + public void AppendFormatted(T value, int alignment) + { + var startingPos = _builder._length; + _builder.Append(value); + if (alignment != 0) + { + AppendOrInsertAlignmentIfNeeded(startingPos, alignment); + } + } + + public void AppendFormatted(T value, int alignment, string? format) + { + var startingPos = _builder._length; + _builder.Append(value, format); + if (alignment != 0) + { + AppendOrInsertAlignmentIfNeeded(startingPos, alignment); + } + } + + public void AppendFormatted(scoped ReadOnlySpan value) => _builder.Append(value); + + public void AppendFormatted(scoped ReadOnlySpan value, int alignment = 0, string? format = null) + { + var leftAlign = false; + if (alignment < 0) + { + leftAlign = true; + alignment = -alignment; + } + + var paddingRequired = alignment - value.Length; + if (paddingRequired <= 0) + { + _builder.Append(value); + return; + } + + _builder.EnsureCapacity(_builder._length + value.Length + paddingRequired); + if (leftAlign) + { + _builder.Append(value); + _builder.AppendSpan(paddingRequired).Fill(' '); + } + else + { + _builder.AppendSpan(paddingRequired).Fill(' '); + _builder.Append(value); + } + } + + public void AppendFormatted(string? value) => _builder.Append(value); + + public void AppendFormatted(string? value, int alignment = 0, string? format = null) => + AppendFormatted(value, alignment, format); + + public void AppendFormatted(object? value, int alignment = 0, string? format = null) => + AppendFormatted(value, alignment, format); + + private void AppendOrInsertAlignmentIfNeeded(int startingPos, int alignment) + { + var charsWritten = _builder._length - startingPos; + + var leftAlign = false; + if (alignment < 0) + { + leftAlign = true; + alignment = -alignment; + } + + var paddingNeeded = alignment - charsWritten; + if (paddingNeeded <= 0) + { + return; + } + + if (leftAlign) + { + _builder.AppendSpan(paddingNeeded).Fill(' '); + } + else + { + // Shift content right, fill padding on left + _builder.AppendSpan(paddingNeeded); + _builder._chars.Slice(startingPos, charsWritten) + .CopyTo(_builder._chars[(startingPos + paddingNeeded)..]); + _builder._chars.Slice(startingPos, paddingNeeded).Fill(' '); + } + } + } } diff --git a/Projects/Server/Client/ClientVersion.cs b/Projects/Server/Client/ClientVersion.cs index 798fd1353..bd093e9e9 100644 --- a/Projects/Server/Client/ClientVersion.cs +++ b/Projects/Server/Client/ClientVersion.cs @@ -210,7 +210,7 @@ public class ClientVersion : IComparable, IComparer.Create(); var hasMatch = false; @@ -81,8 +81,6 @@ public class LocalizationEntry textSlices = queue.ToArray(); stringFormatter = hasMatch ? sb.ToString() : null; - - sb.Dispose(); } /// diff --git a/Projects/Server/Maps/MapSelection.cs b/Projects/Server/Maps/MapSelection.cs index ccde5063d..01a7e0704 100644 --- a/Projects/Server/Maps/MapSelection.cs +++ b/Projects/Server/Maps/MapSelection.cs @@ -42,7 +42,7 @@ public static class MapSelection public static string ToCommaDelimitedString(this MapSelectionFlags flags) { - using var builder = ValueStringBuilder.Create(); + using var builder = new ValueStringBuilder(stackalloc char[160]); foreach (var flag in flags.GetEnumerable()) { diff --git a/Projects/Server/Text/ISelfInterpolatedStringHandler.cs b/Projects/Server/Text/ISelfInterpolatedStringHandler.cs index 8a5cbbfea..2a72b1af5 100644 --- a/Projects/Server/Text/ISelfInterpolatedStringHandler.cs +++ b/Projects/Server/Text/ISelfInterpolatedStringHandler.cs @@ -20,21 +20,21 @@ namespace Server.Text; public interface ISelfInterpolatedStringHandler { - public void Add([InterpolatedStringHandlerArgument("")] ref InterpolatedStringHandler handler); - public void InitializeInterpolation(int literalLength, int formattedCount); - public void AppendLiteral(string value); - public void AppendFormatted(T value); - public void AppendFormatted(T value, string? format); - public void AppendFormatted(T value, int alignment); - public void AppendFormatted(T value, int alignment, string? format); - public void AppendFormatted(ReadOnlySpan value); - public void AppendFormatted(ReadOnlySpan value, int alignment, string? format = null); - public void AppendFormatted(object? value, int alignment = 0, string? format = null); - public void AppendFormatted(string? value); - public void AppendFormatted(string? value, int alignment, string? format = null); + void Add([InterpolatedStringHandlerArgument("")] ref InterpolatedStringHandler handler); + void InitializeInterpolation(int literalLength, int formattedCount); + void AppendLiteral(string value); + void AppendFormatted(T value); + void AppendFormatted(T value, string? format); + void AppendFormatted(T value, int alignment); + void AppendFormatted(T value, int alignment, string? format); + void AppendFormatted(ReadOnlySpan value); + void AppendFormatted(ReadOnlySpan value, int alignment, string? format = null); + void AppendFormatted(object? value, int alignment = 0, string? format = null); + void AppendFormatted(string? value); + void AppendFormatted(string? value, int alignment, string? format = null); [InterpolatedStringHandler] - public ref struct InterpolatedStringHandler + ref struct InterpolatedStringHandler { private ISelfInterpolatedStringHandler _parent; diff --git a/Projects/UOContent/Commands/Handlers.cs b/Projects/UOContent/Commands/Handlers.cs index cfeb8bd5a..0918ac87d 100644 --- a/Projects/UOContent/Commands/Handlers.cs +++ b/Projects/UOContent/Commands/Handlers.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -using System.Text; using Server.Commands.Generic; +using Server.Text; using Server.Engines.Help; using Server.Gumps; using Server.Items; @@ -88,7 +88,7 @@ namespace Server.Commands if (!reg.IsDefault) { - var builder = new StringBuilder(); + using var builder = ValueStringBuilder.Create(256); builder.Append(reg); reg = reg.Parent; @@ -99,7 +99,7 @@ namespace Server.Commands reg = reg.Parent; } - from.SendMessage($"Your region is {builder}."); + from.SendMessage($"Your region is {builder.ToString()}."); } } } @@ -594,7 +594,7 @@ namespace Server.Commands list.Sort(); - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(256); if (list.Count > 0) { @@ -608,7 +608,7 @@ namespace Server.Commands if (sb.Length + 1 + v.Length >= 256) { m.SendAsciiMessage(0x482, sb.ToString()); - sb = new StringBuilder(); + sb.Reset(); sb.Append(v); } else diff --git a/Projects/UOContent/Commands/Logging.cs b/Projects/UOContent/Commands/Logging.cs index 0b4111d3a..f505ae1d9 100644 --- a/Projects/UOContent/Commands/Logging.cs +++ b/Projects/UOContent/Commands/Logging.cs @@ -1,6 +1,6 @@ using System.IO; -using System.Text; using Server.Accounting; +using Server.Text; namespace Server.Commands { @@ -110,11 +110,12 @@ namespace Server.Commands return ip; } - var sb = new StringBuilder(ip); + using var sb = ValueStringBuilder.Create(ip.Length); + sb.Append(ip); for (var i = 0; i < m_NotSafe.Length; ++i) { - sb.Replace(m_NotSafe[i], '_'); + sb.Replace(m_NotSafe[i], '_', 0, sb.Length); } return sb.ToString(); diff --git a/Projects/UOContent/Commands/Object Creation/Add.cs b/Projects/UOContent/Commands/Object Creation/Add.cs index b34af5bac..bfa6e9d1f 100644 --- a/Projects/UOContent/Commands/Object Creation/Add.cs +++ b/Projects/UOContent/Commands/Object Creation/Add.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Reflection; -using System.Text; using Server.Gumps; using Server.Items; using Server.Text; @@ -355,7 +354,7 @@ public static class Add var sendError = true; - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(); sb.Append("Serials: "); if (packs != null) diff --git a/Projects/UOContent/Compression/TarArchive.cs b/Projects/UOContent/Compression/TarArchive.cs index ecab56bed..206c7eaa2 100755 --- a/Projects/UOContent/Compression/TarArchive.cs +++ b/Projects/UOContent/Compression/TarArchive.cs @@ -96,7 +96,7 @@ namespace Server.Compression new FileInfo(destinationArchiveFileName).EnsureDirectory(); - var sb = ValueStringBuilder.Create(); + using var sb = ValueStringBuilder.Create(); var i = 0; foreach (var path in paths) { @@ -111,7 +111,6 @@ namespace Server.Compression } } var pathsToCompress = sb.ToString(); - sb.Dispose(); var tarFlags = compressCommand == null ? "-acf" : "-cf"; var useExternalCompression = compressCommand != null ? $"--use-compress-program \"{compressCommand}\" " : ""; diff --git a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs index 452193270..d0ed57bad 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; -using System.Text; using ModernUO.Serialization; using Server.Gumps; using Server.Items; using Server.Mobiles; using Server.Targeting; +using Server.Text; namespace Server.Engines.ConPVP; @@ -1692,7 +1692,7 @@ public sealed class BRGame : EventGame var tourney = m_Context.m_Tournament; - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(256); if (tourney != null) { @@ -1731,7 +1731,8 @@ public sealed class BRGame : EventGame if (Controller != null) { - sb.Append(' ').Append(Controller.Title); + sb.Append(' '); + sb.Append(Controller.Title); } var title = sb.ToString(); @@ -1758,7 +1759,7 @@ public sealed class BRGame : EventGame continue; } - sb = new StringBuilder(); + sb.Reset(); sb.Append(title); diff --git a/Projects/UOContent/Engines/ConPVP/Games/CTF.cs b/Projects/UOContent/Engines/ConPVP/Games/CTF.cs index 967c97474..120d261ab 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/CTF.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/CTF.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; -using System.Text; using ModernUO.Serialization; using Server.Gumps; using Server.Items; using Server.Mobiles; using Server.Network; using Server.Targeting; +using Server.Text; namespace Server.Engines.ConPVP; @@ -1072,7 +1072,7 @@ public sealed class CTFGame : EventGame var tourney = m_Context.m_Tournament; - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(256); if (tourney != null) { @@ -1111,7 +1111,8 @@ public sealed class CTFGame : EventGame if (Controller != null) { - sb.Append(' ').Append(Controller.Title); + sb.Append(' '); + sb.Append(Controller.Title); } var title = sb.ToString(); @@ -1144,7 +1145,7 @@ public sealed class CTFGame : EventGame // "Red v Blue CTF Champion" - sb = new StringBuilder(); + sb.Reset(); sb.Append(title); diff --git a/Projects/UOContent/Engines/ConPVP/Games/DoubleDom.cs b/Projects/UOContent/Engines/ConPVP/Games/DoubleDom.cs index b37d4ab1d..0bf0dccc9 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/DoubleDom.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/DoubleDom.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; -using System.Text; using ModernUO.Serialization; using Server.Gumps; using Server.Items; using Server.Mobiles; +using Server.Text; namespace Server.Engines.ConPVP; @@ -648,7 +648,7 @@ public sealed class DDGame : EventGame var tourney = m_Context.m_Tournament; - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(256); if (tourney != null) { @@ -687,7 +687,8 @@ public sealed class DDGame : EventGame if (Controller != null) { - sb.Append(' ').Append(Controller.Title); + sb.Append(' '); + sb.Append(Controller.Title); } var title = sb.ToString(); @@ -720,7 +721,7 @@ public sealed class DDGame : EventGame // "Red v Blue DD Champion" - sb = new StringBuilder(); + sb.Reset(); sb.Append(title); diff --git a/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs b/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs index e570c4f22..ea7093404 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; -using System.Text; using ModernUO.Serialization; using Server.Gumps; using Server.Items; using Server.Mobiles; +using Server.Text; namespace Server.Engines.ConPVP; @@ -993,7 +993,7 @@ public sealed class KHGame : EventGame var tourney = m_Context.m_Tournament; - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(256); if (tourney != null) { @@ -1027,7 +1027,8 @@ public sealed class KHGame : EventGame if (Controller != null) { - sb.Append(' ').Append(Controller.Title); + sb.Append(' '); + sb.Append(Controller.Title); } var title = sb.ToString(); @@ -1058,7 +1059,7 @@ public sealed class KHGame : EventGame continue; } - sb = new StringBuilder(); + sb.Reset(); sb.Append(title); diff --git a/Projects/UOContent/Engines/ConPVP/Games/TourneyMatch.cs b/Projects/UOContent/Engines/ConPVP/Games/TourneyMatch.cs index 2925e28a3..76e1afa92 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/TourneyMatch.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/TourneyMatch.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -using System.Text; using Server.Network; +using Server.Text; namespace Server.Engines.ConPVP { @@ -11,21 +11,18 @@ namespace Server.Engines.ConPVP { Participants = participants; + using var sb = new ValueStringBuilder(stackalloc char[256]); + for (var i = 0; i < participants.Count; ++i) { var part = participants[i]; - var sb = new StringBuilder(); - + sb.Reset(); sb.Append("Matched in a duel against "); if (participants.Count > 2) { - sb.AppendFormat( - "{0} other {1}: ", - participants.Count - 1, - part.Players.Count == 1 ? "players" : "teams" - ); + sb.Append($"{participants.Count - 1} other {(part.Players.Count == 1 ? "players" : "teams")}: "); } var hasAppended = false; diff --git a/Projects/UOContent/Engines/ConPVP/Gumps/AcceptTeamGump.cs b/Projects/UOContent/Engines/ConPVP/Gumps/AcceptTeamGump.cs index 4d9d6d7cd..c10190dba 100644 --- a/Projects/UOContent/Engines/ConPVP/Gumps/AcceptTeamGump.cs +++ b/Projects/UOContent/Engines/ConPVP/Gumps/AcceptTeamGump.cs @@ -82,7 +82,7 @@ namespace Server.Engines.ConPVP AddImage(215, -43, 0xEE40); - using var sb = ValueStringBuilder.Create(128); + using var sb = new ValueStringBuilder(stackalloc char[64]); if (tourney.TourneyType == TourneyType.FreeForAll) { @@ -90,13 +90,11 @@ namespace Server.Engines.ConPVP } else if (tourney.TourneyType == TourneyType.RandomTeam) { - sb.Append(tourney.ParticipantsPerMatch); - sb.Append("-Team"); + sb.Append($"{tourney.ParticipantsPerMatch}-Team"); } else if (tourney.TourneyType == TourneyType.Faction) { - sb.Append(tourney.ParticipantsPerMatch); - sb.Append("-Team Faction"); + sb.Append($"{tourney.ParticipantsPerMatch}-Team Faction"); } else if (tourney.TourneyType == TourneyType.RedVsBlue) { @@ -117,8 +115,7 @@ namespace Server.Engines.ConPVP if (tourney.EventController != null) { - sb.Append(' '); - sb.Append(tourney.EventController.Title); + sb.Append($" {tourney.EventController.Title}"); } sb.Append(" Tournament Invitation"); diff --git a/Projects/UOContent/Engines/ConPVP/Gumps/ArenaGump.cs b/Projects/UOContent/Engines/ConPVP/Gumps/ArenaGump.cs index 814b2de0c..46473beb3 100644 --- a/Projects/UOContent/Engines/ConPVP/Gumps/ArenaGump.cs +++ b/Projects/UOContent/Engines/ConPVP/Gumps/ArenaGump.cs @@ -1,9 +1,9 @@ using System.Collections.Generic; -using System.Text; using ModernUO.Serialization; using Server.Gumps; using Server.Mobiles; using Server.Network; +using Server.Text; namespace Server.Engines.ConPVP; @@ -97,6 +97,8 @@ public class ArenaGump : Gump AddButton(499 + 40 - 12 - 63 - 4 - 63, height - 12 - 24, 247, 248, 1); AddButton(499 + 40 - 12 - 63, height - 12 - 24, 241, 242, 2); + var sb = new ValueStringBuilder(stackalloc char[256]); + for (var i = 0; i < list.Count; ++i) { var ar = list[i]; @@ -112,7 +114,7 @@ public class ArenaGump : Gump AddBorderedText(x + 5, y + 5, 115 - 5, ar.Name ?? "(no name)", color, 0); x += 115; - var sb = new StringBuilder(); + sb.Reset(); if (ar.Players.Count > 0) { @@ -154,10 +156,10 @@ public class ArenaGump : Gump } } - Append(sb, p1); - Append(sb, p2); - Append(sb, p3); - Append(sb, p4); + Append(ref sb, p1); + Append(ref sb, p2); + Append(ref sb, p3); + Append(ref sb, p4); if (ar.Players.Count > 4) { @@ -174,9 +176,11 @@ public class ArenaGump : Gump AddBorderedText(x, y + 5, 40, Html.Center($"{ar.Spectators}"), color, 0); } + + sb.Dispose(); } - private void Append(StringBuilder sb, LadderEntry le) + private void Append(ref ValueStringBuilder sb, LadderEntry le) { if (le == null) { diff --git a/Projects/UOContent/Engines/ConPVP/Gumps/ConfirmSignupGump.cs b/Projects/UOContent/Engines/ConPVP/Gumps/ConfirmSignupGump.cs index a0b7f55c1..cb779fe34 100644 --- a/Projects/UOContent/Engines/ConPVP/Gumps/ConfirmSignupGump.cs +++ b/Projects/UOContent/Engines/ConPVP/Gumps/ConfirmSignupGump.cs @@ -92,7 +92,7 @@ namespace Server.Engines.ConPVP AddImage(215, -43, 0xEE40); // AddImage( 330, 141, 0x8BA ); - using var sb = ValueStringBuilder.Create(128); + using var sb = new ValueStringBuilder(stackalloc char[64]); if (tourney.TourneyType == TourneyType.FreeForAll) { @@ -100,13 +100,11 @@ namespace Server.Engines.ConPVP } else if (tourney.TourneyType == TourneyType.RandomTeam) { - sb.Append(tourney.ParticipantsPerMatch); - sb.Append("-Team"); + sb.Append($"{tourney.ParticipantsPerMatch}-Team"); } else if (tourney.TourneyType == TourneyType.Faction) { - sb.Append(tourney.ParticipantsPerMatch); - sb.Append("-Team Faction"); + sb.Append($"{tourney.ParticipantsPerMatch}-Team Faction"); } else if (tourney.TourneyType == TourneyType.RedVsBlue) { @@ -127,8 +125,7 @@ namespace Server.Engines.ConPVP if (tourney.EventController != null) { - sb.Append(' '); - sb.Append(tourney.EventController.Title); + sb.Append($" {tourney.EventController.Title}"); } sb.Append(" Tournament Signup"); diff --git a/Projects/UOContent/Engines/ConPVP/Gumps/TournamentBracketGump.cs b/Projects/UOContent/Engines/ConPVP/Gumps/TournamentBracketGump.cs index 436832d72..7b43aff7d 100644 --- a/Projects/UOContent/Engines/ConPVP/Gumps/TournamentBracketGump.cs +++ b/Projects/UOContent/Engines/ConPVP/Gumps/TournamentBracketGump.cs @@ -1,7 +1,6 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Text; using Server.Gumps; using Server.Mobiles; using Server.Network; @@ -60,8 +59,7 @@ namespace Server.Engines.ConPVP } else if (tourney.TourneyType == TourneyType.RandomTeam) { - sb.Append(tourney.ParticipantsPerMatch); - sb.Append("-Team"); + sb.Append($"{tourney.ParticipantsPerMatch}-Team"); } else if (tourney.TourneyType == TourneyType.RedVsBlue) { @@ -69,8 +67,7 @@ namespace Server.Engines.ConPVP } else if (tourney.TourneyType == TourneyType.Faction) { - sb.Append(tourney.ParticipantsPerMatch); - sb.Append("-Team Faction"); + sb.Append($"{tourney.ParticipantsPerMatch}-Team Faction"); } else { @@ -87,8 +84,7 @@ namespace Server.Engines.ConPVP if (tourney.EventController != null) { - sb.Append(' '); - sb.Append(tourney.EventController.Title); + sb.Append($" {tourney.EventController.Title}"); } sb.Append(" Tournament Bracket"); @@ -461,7 +457,7 @@ namespace Server.Engines.ConPVP color = 0x666666; } - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(512); if (m_Tournament.TourneyType == TourneyType.Standard) { diff --git a/Projects/UOContent/Engines/ConPVP/Participant.cs b/Projects/UOContent/Engines/ConPVP/Participant.cs index 26d68a569..aaa9059cb 100644 --- a/Projects/UOContent/Engines/ConPVP/Participant.cs +++ b/Projects/UOContent/Engines/ConPVP/Participant.cs @@ -1,6 +1,6 @@ using System; -using System.Text; using Server.Mobiles; +using Server.Text; namespace Server.Engines.ConPVP { @@ -74,7 +74,7 @@ namespace Server.Engines.ConPVP { get { - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(256); for (var i = 0; i < Players.Length; ++i) { diff --git a/Projects/UOContent/Engines/ConPVP/Tournament.cs b/Projects/UOContent/Engines/ConPVP/Tournament.cs index e2cea6352..82b9b8d9d 100644 --- a/Projects/UOContent/Engines/ConPVP/Tournament.cs +++ b/Projects/UOContent/Engines/ConPVP/Tournament.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; -using System.Text; using Server.Factions; using Server.Items; using Server.Regions; +using Server.Text; namespace Server.Engines.ConPVP { @@ -237,7 +237,7 @@ namespace Server.Engines.ConPVP return; } - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(512); sb.Append("The match has ended in a tie "); @@ -284,7 +284,7 @@ namespace Server.Engines.ConPVP { case TieType.FullAdvancement: { - sb.AppendFormat("In accordance with the rules, {0} parties are advanced.", whole); + sb.Append($"In accordance with the rules, {whole} parties are advanced."); break; } case TieType.FullElimination: @@ -294,7 +294,7 @@ namespace Server.Engines.ConPVP Undefeated.Remove(remaining[j]); } - sb.AppendFormat("In accordance with the rules, {0} parties are eliminated.", whole); + sb.Append($"In accordance with the rules, {whole} parties are eliminated."); break; } case TieType.Random: @@ -311,11 +311,7 @@ namespace Server.Engines.ConPVP if (advanced != null) { - sb.AppendFormat( - "In accordance with the rules, {0} {1} advanced.", - advanced.NameList, - advanced.Players.Count == 1 ? "is" : "are" - ); + sb.Append($"In accordance with the rules, {advanced.NameList} {(advanced.Players.Count == 1 ? "is" : "are")} advanced."); } break; @@ -344,11 +340,7 @@ namespace Server.Engines.ConPVP if (advanced != null) { - sb.AppendFormat( - "In accordance with the rules, {0} {1} advanced.", - advanced.NameList, - advanced.Players.Count == 1 ? "is" : "are" - ); + sb.Append($"In accordance with the rules, {advanced.NameList} {(advanced.Players.Count == 1 ? "is" : "are")} advanced."); } break; @@ -377,11 +369,7 @@ namespace Server.Engines.ConPVP if (advanced != null) { - sb.AppendFormat( - "In accordance with the rules, {0} {1} advanced.", - advanced.NameList, - advanced.Players.Count == 1 ? "is" : "are" - ); + sb.Append($"In accordance with the rules, {advanced.NameList} {(advanced.Players.Count == 1 ? "is" : "are")} advanced."); } break; @@ -432,7 +420,7 @@ namespace Server.Engines.ConPVP public void HandleWon(Arena arena, TourneyMatch match, TourneyParticipant winner) { - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(512); sb.Append("The match is complete. "); sb.Append(winner.NameList); @@ -448,11 +436,7 @@ namespace Server.Engines.ConPVP if (match.Participants.Count > 2) { - sb.AppendFormat( - "{0} other {1}: ", - match.Participants.Count - 1, - winner.Players.Count == 1 ? "players" : "teams" - ); + sb.Append($"{match.Participants.Count - 1} other {(winner.Players.Count == 1 ? "players" : "teams")}: "); } var hasAppended = false; @@ -592,7 +576,7 @@ namespace Server.Engines.ConPVP cash /= 1000; cash *= 1000; - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(256); if (TourneyType == TourneyType.FreeForAll) { @@ -628,7 +612,8 @@ namespace Server.Engines.ConPVP if (EventController != null) { - sb.Append(' ').Append(EventController.Title); + sb.Append(' '); + sb.Append(EventController.Title); } sb.Append(" Champion"); diff --git a/Projects/UOContent/Engines/ConPVP/TourneyParticipant.cs b/Projects/UOContent/Engines/ConPVP/TourneyParticipant.cs index fa4a87a97..c401e409e 100644 --- a/Projects/UOContent/Engines/ConPVP/TourneyParticipant.cs +++ b/Projects/UOContent/Engines/ConPVP/TourneyParticipant.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using System.Text; +using Server.Text; namespace Server.Engines.ConPVP { @@ -56,7 +56,7 @@ namespace Server.Engines.ConPVP { get { - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(256); for (var i = 0; i < Players.Count; ++i) { diff --git a/Projects/UOContent/Engines/Ethics/Evil/Powers/UnholySense.cs b/Projects/UOContent/Engines/Ethics/Evil/Powers/UnholySense.cs index 56cfc3f84..3ddbe1bec 100644 --- a/Projects/UOContent/Engines/Ethics/Evil/Powers/UnholySense.cs +++ b/Projects/UOContent/Engines/Ethics/Evil/Powers/UnholySense.cs @@ -45,7 +45,7 @@ public sealed class UnholySense : Power ++enemyCount; } - using var sb = ValueStringBuilder.Create(); + using var sb = new ValueStringBuilder(stackalloc char[96]); sb.Append($"You sense {(enemyCount == 0 ? "no" : enemyCount.ToString())} {(enemyCount == 1 ? "enemy" : "enemies")}"); if (primary != null) diff --git a/Projects/UOContent/Engines/Ethics/Hero/Powers/HolySense.cs b/Projects/UOContent/Engines/Ethics/Hero/Powers/HolySense.cs index b6bcea48e..d0dbc09d9 100644 --- a/Projects/UOContent/Engines/Ethics/Hero/Powers/HolySense.cs +++ b/Projects/UOContent/Engines/Ethics/Hero/Powers/HolySense.cs @@ -45,7 +45,7 @@ public sealed class HolySense : Power ++enemyCount; } - using var sb = ValueStringBuilder.Create(); + using var sb = new ValueStringBuilder(stackalloc char[96]); sb.Append($"You sense {(enemyCount == 0 ? "no" : enemyCount.ToString())} {(enemyCount == 1 ? "enemy" : "enemies")}"); if (primary != null) diff --git a/Projects/UOContent/Engines/Help/SpeechLogGump.cs b/Projects/UOContent/Engines/Help/SpeechLogGump.cs index eae679656..edd5b3fc4 100644 --- a/Projects/UOContent/Engines/Help/SpeechLogGump.cs +++ b/Projects/UOContent/Engines/Help/SpeechLogGump.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -using System.Text; using Server.Accounting; +using Server.Text; using Server.Gumps; using Server.Network; @@ -62,7 +62,7 @@ namespace Server.Engines.Help var max = log.Count - (lastPage - page) * MaxEntriesPerPage; var min = Math.Max(max - MaxEntriesPerPage, 0); - var builder = new StringBuilder(); + using var builder = ValueStringBuilder.Create(); for (var i = min; i < max; i++) { diff --git a/Projects/UOContent/Engines/Player Murder System/BountyMessage.cs b/Projects/UOContent/Engines/Player Murder System/BountyMessage.cs index 3863f63c3..46c6b0ab0 100644 --- a/Projects/UOContent/Engines/Player Murder System/BountyMessage.cs +++ b/Projects/UOContent/Engines/Player Murder System/BountyMessage.cs @@ -163,43 +163,27 @@ public static class BountyMessage { case 0: { - lineBuilder.Append("Bounty for"); - lineBuilder.Append(player.RawName); - lineBuilder.Append("!"); - break; + lineBuilder.Append($"Bounty for {player.RawName}!"); break; } case 1: { - lineBuilder.Append(player.RawName); - lineBuilder.Append(" must die!"); - break; + lineBuilder.Append($"{player.RawName} must die!"); break; } case 2: { - lineBuilder.Append("A price on "); - lineBuilder.Append(player.RawName); - lineBuilder.Append('!'); - break; + lineBuilder.Append($"A price on {player.RawName}!"); break; } case 3: { - lineBuilder.Append(player.RawName); - lineBuilder.Append(" outlawed!"); - break; + lineBuilder.Append($"{player.RawName} outlawed!"); break; } case 4: { - lineBuilder.Append("Execute "); - lineBuilder.Append(player.RawName); - lineBuilder.Append('!'); - break; + lineBuilder.Append($"Execute {player.RawName}!"); break; } default: { - lineBuilder.Append("WANTED: "); - lineBuilder.Append(player.RawName); - lineBuilder.Append('!'); - break; + lineBuilder.Append($"WANTED: {player.RawName}!"); break; } } @@ -248,21 +232,7 @@ public static class BountyMessage _ => " Lord British's bounty " }; - paraBuilder.Append("The foul scum known as "); - paraBuilder.Append(player.RawName); - paraBuilder.Append(' '); - paraBuilder.Append(verb); - paraBuilder.Append(" For "); - paraBuilder.Append(pronoun); - paraBuilder.Append(" is responsible for "); - paraBuilder.Append(player.Kills); - paraBuilder.Append(" murders. "); - paraBuilder.Append(intro); - paraBuilder.Append(" of "); - paraBuilder.Append(bounty); - paraBuilder.Append(" gold pieces for "); - paraBuilder.Append(possessive); - paraBuilder.Append(" head!"); + paraBuilder.Append($"The foul scum known as {player.RawName} {verb} For {pronoun} is responsible for {player.Kills} murders. {intro} of {bounty} gold pieces for {possessive} head!"); // Word-wrap at 28 chars and write each line directly (matching uo98 bountyboard.m CONST:28) WriteWordWrappedLines(ref writer, paraBuilder.AsSpan(), 28, textBuffer, ref lineCount); @@ -276,22 +246,17 @@ public static class BountyMessage lineCount++; lineBuilder.Reset(); - lineBuilder.Append(" - "); - lineBuilder.Append(GetHairStyle(player.HairItemID)); + lineBuilder.Append($" - {GetHairStyle(player.HairItemID)}"); writer.WriteString(lineBuilder.AsSpan(), textBuffer, true); lineCount++; lineBuilder.Reset(); - lineBuilder.Append(" - "); - lineBuilder.Append(GetHairColor(player.HairHue)); - lineBuilder.Append(" hair"); + lineBuilder.Append($" - {GetHairColor(player.HairHue)} hair"); writer.WriteString(lineBuilder.AsSpan(), textBuffer, true); lineCount++; lineBuilder.Reset(); - lineBuilder.Append(" - "); - lineBuilder.Append(GetSkinTone(player.Hue)); - lineBuilder.Append(" skin"); + lineBuilder.Append($" - {GetSkinTone(player.Hue)} skin"); writer.WriteString(lineBuilder.AsSpan(), textBuffer, true); lineCount++; @@ -300,9 +265,7 @@ public static class BountyMessage lineCount++; lineBuilder.Reset(); - lineBuilder.Append("If you kill "); - lineBuilder.Append(objective); - lineBuilder.Append(", remove the"); + lineBuilder.Append($"If you kill {objective}, remove the"); writer.WriteString(lineBuilder.AsSpan(), textBuffer, true); lineCount++; diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index 870a99bb2..7e3b4043d 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -1119,12 +1119,7 @@ namespace Server.Gumps } var c = a.Comments[i]; - sb.Append('['); - sb.Append(c.AddedBy); - sb.Append(" on "); - sb.Append(c.LastModified.ToString()); - sb.Append("]
"); - sb.Append(c.Content); + sb.Append($"[{c.AddedBy} on {c.LastModified}]
{c.Content}"); } AddHtml(20, 180, 380, 190, sb.ToString(), true, true); @@ -1159,9 +1154,7 @@ namespace Server.Gumps var tag = a.Tags[i]; - sb.Append(tag.Name); - sb.Append(" = "); - sb.Append(tag.Value); + sb.Append($"{tag.Name} = {tag.Value}"); } AddHtml(20, 180, 380, 190, sb.ToString(), true, true); diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index 2ca6eefe8..60d7c8bf9 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -1561,7 +1561,7 @@ namespace Server.Items if (isMagicItem) { - var builder = ValueStringBuilder.Create(128); + var builder = new ValueStringBuilder(stackalloc char[128]); var durabilityText = DurabilityText(out var articleAnDurability); if (durabilityText != null) diff --git a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs index 5d745e92b..11cd4994e 100644 --- a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs +++ b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs @@ -305,7 +305,7 @@ public partial class HouseRaffleStone : Item public static string FormatLocation(Point3D loc, Map map, bool displayMap) { - using var result = ValueStringBuilder.Create(); + using var result = new ValueStringBuilder(stackalloc char[48]); var xLong = 0; var yLat = 0; diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index b3397a59c..c746a83a2 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -3451,7 +3451,7 @@ public abstract partial class BaseWeapon if (isMagicItem) { - var builder = ValueStringBuilder.Create(128); + var builder = new ValueStringBuilder(stackalloc char[160]); var durabilityText = DurabilityText(out var articleAnDurability); if (durabilityText != null) diff --git a/Projects/UOContent/Misc/ClientVerification.cs b/Projects/UOContent/Misc/ClientVerification.cs index cbcc61360..0c02c7585 100644 --- a/Projects/UOContent/Misc/ClientVerification.cs +++ b/Projects/UOContent/Misc/ClientVerification.cs @@ -94,7 +94,7 @@ namespace Server.Misc return "There are no clients supported at this time."; } - using var builder = ValueStringBuilder.Create(); + using var builder = new ValueStringBuilder(stackalloc char[192]); builder.Append("Please connect with a "); uint flags = 0; var i = 0; @@ -115,7 +115,7 @@ namespace Server.Misc public static void ClientVersionReceived(NetState state, ClientVersion version) { - var sb = ValueStringBuilder.Create(); + using var sb = ValueStringBuilder.Create(); if (!_enable || state.Mobile?.AccessLevel != AccessLevel.Player) { @@ -202,8 +202,6 @@ namespace Server.Misc } } } - - sb.Dispose(); } private static void OnKick(NetState ns) diff --git a/Projects/UOContent/Mobiles/Townfolk/TownCrier.cs b/Projects/UOContent/Mobiles/Townfolk/TownCrier.cs index 44eadf509..849f44f6e 100644 --- a/Projects/UOContent/Mobiles/Townfolk/TownCrier.cs +++ b/Projects/UOContent/Mobiles/Townfolk/TownCrier.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -using System.Text; using ModernUO.Serialization; +using Server.Text; using Server.Gumps; using Server.Items; using Server.Network; @@ -231,7 +231,7 @@ public class TownCrierGump : Gump var toExpire = Utility.Max(tce.ExpireTime - Core.Now, TimeSpan.Zero); - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(512); sb.Append("[Expires: "); diff --git a/Projects/UOContent/Skills/ForensicEval.cs b/Projects/UOContent/Skills/ForensicEval.cs index 32a04a2f4..622b4941a 100644 --- a/Projects/UOContent/Skills/ForensicEval.cs +++ b/Projects/UOContent/Skills/ForensicEval.cs @@ -71,7 +71,7 @@ namespace Server.SkillHandlers if (c.Looters.Count > 0) { - var sb = ValueStringBuilder.Create(128); + using var sb = new ValueStringBuilder(stackalloc char[128]); var i = 0; foreach (var looter in c.Looters) { @@ -93,7 +93,6 @@ namespace Server.SkillHandlers // This body has been disturbed by ~1_PLAYER_NAMES~ from.SendLocalizedMessage(1042752, sb.ToString()); - sb.Dispose(); } else { diff --git a/Projects/UOContent/Special Systems/Engines/TestCenter.cs b/Projects/UOContent/Special Systems/Engines/TestCenter.cs index 23480d38b..e7105575d 100644 --- a/Projects/UOContent/Special Systems/Engines/TestCenter.cs +++ b/Projects/UOContent/Special Systems/Engines/TestCenter.cs @@ -1,5 +1,4 @@ using System; -using System.Text; using CommunityToolkit.HighPerformance; using Server.Commands; using Server.Factions; @@ -7,6 +6,7 @@ using Server.Gumps; using Server.Items; using Server.Mobiles; using Server.Network; +using Server.Text; namespace Server.Misc { @@ -703,7 +703,7 @@ namespace Server.Misc Array.Sort(strings); - var sb = new StringBuilder(); + using var sb = ValueStringBuilder.Create(256); if (strings.Length > 0) { @@ -728,7 +728,7 @@ namespace Server.Misc sb.ToString() ); - sb = new StringBuilder(); + sb.Reset(); sb.Append(v); } else diff --git a/dev-docs/claude-skills/modernuo-string-handling.md b/dev-docs/claude-skills/modernuo-string-handling.md new file mode 100644 index 000000000..0a9371895 --- /dev/null +++ b/dev-docs/claude-skills/modernuo-string-handling.md @@ -0,0 +1,133 @@ +# ModernUO String Handling Skill + +## When This Skill Applies +- Any code that builds strings dynamically (concatenation, formatting, interpolation) +- Converting `System.Text.StringBuilder` to `ValueStringBuilder` +- Packet string construction +- Gump/message text building + +## Core Rule +**Never use `System.Text.StringBuilder`**. Use `Server.Text.ValueStringBuilder` everywhere. + +## Quick Reference + +### Construction +```csharp +// Bounded output (preferred): zero heap allocation +using var sb = new ValueStringBuilder(stackalloc char[128]); + +// Unbounded output: rents from STArrayPool +using var sb = ValueStringBuilder.Create(256); +using var sb = ValueStringBuilder.Create(); // default 64 chars +``` + +### String Interpolation +Works with stackalloc — writes directly into the builder's buffer: +```csharp +using var sb = new ValueStringBuilder(stackalloc char[64]); +sb.Append($"Player {name} has {kills} kills"); +``` + +### Reuse via Reset +Use `Reset()` instead of creating a new builder: +```csharp +using var sb = new ValueStringBuilder(stackalloc char[128]); +foreach (var item in items) +{ + sb.Reset(); + sb.Append($"{item.Name}: {item.Value}"); + Process(sb.ToString()); +} +``` + +### Reading Results +- `sb.ToString()` — when you need a string (allocates) +- `sb.AsSpan()` — when consumer accepts `ReadOnlySpan` (zero-alloc) + +## Common Mistakes + +### 1. Using StringBuilder +```csharp +// BAD +var sb = new StringBuilder(); +sb.Append(name); +return sb.ToString(); + +// GOOD +using var sb = new ValueStringBuilder(stackalloc char[64]); +sb.Append(name); +return sb.ToString(); +``` + +### 2. Forgetting `using var` +```csharp +// BAD: pooled array may leak if Grow() happened +var sb = ValueStringBuilder.Create(); +return sb.ToString(); // never disposed! + +// GOOD +using var sb = ValueStringBuilder.Create(); +return sb.ToString(); +``` + +### 3. Chaining Append calls +```csharp +// BAD: VSB Append returns void, not this +sb.Append("a").Append("b"); + +// GOOD +sb.Append("a"); +sb.Append("b"); + +// BETTER: use interpolation +sb.Append($"a{value}b"); +``` + +### 4. Reassigning a using variable +```csharp +// BAD: can't reassign using var +using var sb = ValueStringBuilder.Create(); +sb = ValueStringBuilder.Create(); // CS1656! + +// GOOD: use Reset() +using var sb = ValueStringBuilder.Create(); +sb.Reset(); +``` + +### 5. `using var` with `ref` extension methods +```csharp +// BAD: CS1657 — using var can't be passed by ref +using var sb = new ValueStringBuilder(stackalloc char[64]); +sb.AppendSpaceWithArticle(text, articleAn); // takes ref VSB + +// GOOD: manual Dispose +var sb = new ValueStringBuilder(stackalloc char[64]); +sb.AppendSpaceWithArticle(text, articleAn); +var result = sb.ToString(); +sb.Dispose(); +``` + +### 6. No AppendFormat — use `$"..."` interpolation +```csharp +// BAD: AppendFormat doesn't exist on VSB (no object[] params equivalent) +sb.AppendFormat("{0:N0} points, {1:N0} kills", score, kills); + +// GOOD: use interpolation with format specifiers (zero boxing, zero intermediate strings) +sb.Append($"{score:N0} points, {kills:N0} kills"); +``` + +## Capacity Sizing Guide + +| Content | Recommended | +|---|---| +| Version strings, coordinates | `stackalloc char[32-48]` | +| Player names, short messages | `stackalloc char[64]` | +| Item descriptions, titles | `stackalloc char[128]` | +| Paragraph text, HTML snippets | `stackalloc char[256]` | +| Large HTML, gump content | `Create(512)` or `Create()` | +| Unbounded (logs, file paths) | `Create()` | + +## Related Docs +- `dev-docs/string-handling.md` — full reference +- `dev-docs/code-standards.md` — memory management rules +- `dev-docs/property-lists.md` — IPropertyList string interpolation (different handler) diff --git a/dev-docs/code-standards.md b/dev-docs/code-standards.md index 156281400..6a5540047 100644 --- a/dev-docs/code-standards.md +++ b/dev-docs/code-standards.md @@ -486,7 +486,7 @@ finally - Use `PooledRefList` instead of `new List()` - Use `stackalloc` for small fixed-size buffers - Use `STArrayPool` for larger buffers -- Avoid string concatenation in loops (use `StringBuilder` or string interpolation in `IPropertyList`) +- Use `ValueStringBuilder` with `stackalloc` for string building (never `System.Text.StringBuilder`) → `dev-docs/string-handling.md` --- diff --git a/dev-docs/string-handling.md b/dev-docs/string-handling.md new file mode 100644 index 000000000..930d8820c --- /dev/null +++ b/dev-docs/string-handling.md @@ -0,0 +1,220 @@ +# 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](#valuestringbuilder) +2. [RawInterpolatedStringHandler](#rawinterpolatedstringhandler) +3. [StringHelpers](#stringhelpers) +4. [TextEncoding](#textencoding) +5. [Decision Guide](#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)**: +```csharp +// 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)**: +```csharp +// 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. + +```csharp +// 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: + +```csharp +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` (zero-alloc) | +| `sb.AsSpan(terminate: true)` | You need a null-terminated span | + +### Disposal + +Always use `using var` for automatic disposal: +```csharp +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()`: +```csharp +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: + ```csharp + // 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, ...)` | 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)` | Encode string to UTF-8 in buffer | +| `GetBytesUtf8(ReadOnlySpan, Span)` | Encode char span to UTF-8 | +| `GetStringUtf8(Span)` | 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.Shared` (VSB uses lock-free `STArrayPool`) + +### Common patterns + +**Instead of string concatenation**: +```csharp +// 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**: +```csharp +// 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): +```csharp +// 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 +```