From 679e66b99ddd47a33366e59210accafc1e73f06c Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 3 May 2026 18:24:57 -0700 Subject: [PATCH] feat(buffers): add :L lowercase format spec to RawInterpolatedStringHandler (#2440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a custom `:L` format specifier to `RawInterpolatedStringHandler`. When the format string is `"L"`, the handler lowercases the formatted value's chars in-place after the underlying `ISpanFormattable.TryFormat` / `IFormattable.ToString` path completes. Zero allocation, single-pass. ## Usage ```csharp mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold" mob.SendMessage($"Welcome, {playerName:L}"); // lowercased mob.SendMessage($"{count:L} kills"); // ints unchanged ("42") ``` ## Motivation Eliminates the `value.ToString().ToLowerInvariant()` two-allocation idiom that appears across the codebase for any type that goes through an interpolation handler. After this lands, content code can use the `:L` specifier directly instead of helper extensions or per-enum lookup tables. ## Coverage - `AppendFormatted(T value, string? format)` — generic path (covers IFormattable, ISpanFormattable, .ToString fallback) - `AppendFormatted(ReadOnlySpan value, int alignment, string? format)` — span path with alignment-aware lowercase range (only the value range is lowercased, not padding) - `AppendFormatted(T value, int alignment, string? format)` and `AppendFormatted(string? value, int alignment, string? format)` and `AppendFormatted(object? value, int alignment, string? format)` — inherit via delegation The `format == "L"` comparison is case-sensitive — `:l` (lowercase L) is NOT recognized. `:L` matches the convention of e.g. `:N0` / `:F2` (numeric format specifiers traditionally use uppercase). `char.ToLowerInvariant` is used (not locale-dependent) for predictable game text. ## Future cleanup Phase 3.3 (#2438) introduced a per-enum `TrophyRank.LowerName()` extension to eliminate `rank.ToString().ToLower()` allocations at 10 ConPVP sites. Once this PR lands, those sites can be simplified to `{rank:L}` and the `TrophyRankExtensions` helper can be removed. Tracked as a follow-up. --- .../RawInterpolatedStringHandlerTests.cs | 95 +++++++++++++ .../Buffers/RawInterpolatedStringHandler.cs | 133 +++++++++++++----- 2 files changed, 196 insertions(+), 32 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs diff --git a/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs new file mode 100644 index 000000000..0b76d3f94 --- /dev/null +++ b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs @@ -0,0 +1,95 @@ +using System; +using Server.Buffers; +using Xunit; + +namespace Server.Tests.Buffers; + +public class RawInterpolatedStringHandlerTests +{ + [Fact] + public void TestLowercaseFormatString() + { + var name = "Hello WORLD"; + var handler = new RawInterpolatedStringHandler(0, 1); + handler.AppendFormatted(name, format: "L"); + Assert.Equal("hello world", handler.Text.ToString()); + handler.Clear(); + } + + [Fact] + public void TestLowercaseFormatEnum() + { + var handler = new RawInterpolatedStringHandler(0, 1); + handler.AppendFormatted(DayOfWeek.Wednesday, format: "L"); + Assert.Equal("wednesday", handler.Text.ToString()); + handler.Clear(); + } + + [Fact] + public void TestLowercaseFormatInt() + { + // Numerics have no uppercase chars; :L should be a no-op + var handler = new RawInterpolatedStringHandler(0, 1); + handler.AppendFormatted(42, format: "L"); + Assert.Equal("42", handler.Text.ToString()); + handler.Clear(); + } + + [Fact] + public void TestLowercaseFormatSpan() + { + var span = "MIXED Case TEXT".AsSpan(); + var handler = new RawInterpolatedStringHandler(0, 1); + handler.AppendFormatted(span, alignment: 0, format: "L"); + Assert.Equal("mixed case text", handler.Text.ToString()); + handler.Clear(); + } + + [Fact] + public void TestLowercaseFormatWithAlignment() + { + // Right-aligned: "Gold" in width 8 with :L -> " gold" + var handler = new RawInterpolatedStringHandler(0, 1); + handler.AppendFormatted("Gold", alignment: 8, format: "L"); + Assert.Equal(" gold", handler.Text.ToString()); + handler.Clear(); + } + + [Fact] + public void TestNoFormatPreservesCase() + { + var handler = new RawInterpolatedStringHandler(0, 1); + handler.AppendFormatted("Hello WORLD"); + Assert.Equal("Hello WORLD", handler.Text.ToString()); + handler.Clear(); + } + + [Fact] + public void TestUnicodeLowercase() + { + // ToLowerInvariant on Greek letter + var handler = new RawInterpolatedStringHandler(0, 1); + handler.AppendFormatted("ΑΒΓ", format: "L"); + Assert.Equal("αβγ", handler.Text.ToString()); + handler.Clear(); + } + + [Fact] + public void TestLowercaseFormatSurrogatePairAtChunkBoundary() + { + // The chunked lowercase path uses a 256-char stackalloc temp buffer. Place a + // supplementary-plane code point (U+10400 DESERET CAPITAL LONG I, encoded as + // surrogate pair "𐐀") so its high half lands at offset 255 and its + // low half at offset 256 — straddling the chunk boundary. Without the + // surrogate-aware boundary trim, ToLowerInvariant would see two lone surrogates + // and pass them through unchanged, leaving the capital code point intact. + // With the trim, the chunk shrinks to 255 chars and the pair stays together + // in the next chunk, lowercasing correctly to U+10428 ("𐐨"). + var input = new string('a', 255) + "𐐀" + new string('b', 10); + var handler = new RawInterpolatedStringHandler(0, 1); + handler.AppendFormatted(input, format: "L"); + var expected = new string('a', 255) + "𐐨" + new string('b', 10); + Assert.Equal(expected, handler.Text.ToString()); + handler.Clear(); + } +} diff --git a/Projects/Server/Buffers/RawInterpolatedStringHandler.cs b/Projects/Server/Buffers/RawInterpolatedStringHandler.cs index 4dcbbccbe..e6d2e88fa 100644 --- a/Projects/Server/Buffers/RawInterpolatedStringHandler.cs +++ b/Projects/Server/Buffers/RawInterpolatedStringHandler.cs @@ -261,49 +261,72 @@ public ref struct RawInterpolatedStringHandler } /// Writes the specified value to the handler. /// The value to write. - /// The format string. + /// + /// The format string. Pass "L" to lowercase the formatted hole's output + /// using ; the underlying value's + /// TryFormat/ToString never sees "L". + /// public void AppendFormatted(T value, string? format) { + var lowercase = format == "L"; + if (lowercase) + { + format = null; + } + + var startPos = _pos; + // If there's a custom formatter, always use it. if (_hasCustomFormatter) { AppendCustomFormatter(value, format); - return; - } - - // Check first for IFormattable, even though we'll prefer to use ISpanFormattable, as the latter - // requires the former. For value types, it won't matter as the type checks devolve into - // JIT-time constants. For reference types, they're more likely to implement IFormattable - // than they are to implement ISpanFormattable: if they don't implement either, we save an - // interface check over first checking for ISpanFormattable and then for IFormattable, and - // if it only implements IFormattable, we come out even: only if it implements both do we - // end up paying for an extra interface check. - string? s; - if (value is IFormattable) - { - // If the value can format itself directly into our buffer, do so. - if (value is ISpanFormattable) - { - int charsWritten; - while (!((ISpanFormattable)value).TryFormat(_chars[_pos..], out charsWritten, format, _provider)) // constrained call avoiding boxing for value types - { - Grow(); - } - - _pos += charsWritten; - return; - } - - s = ((IFormattable)value).ToString(format, _provider); // constrained call avoiding boxing for value types } else { - s = value?.ToString(); + // Check first for IFormattable, even though we'll prefer to use ISpanFormattable, as the latter + // requires the former. For value types, it won't matter as the type checks devolve into + // JIT-time constants. For reference types, they're more likely to implement IFormattable + // than they are to implement ISpanFormattable: if they don't implement either, we save an + // interface check over first checking for ISpanFormattable and then for IFormattable, and + // if it only implements IFormattable, we come out even: only if it implements both do we + // end up paying for an extra interface check. + string? s; + if (value is IFormattable) + { + // If the value can format itself directly into our buffer, do so. + if (value is ISpanFormattable) + { + int charsWritten; + while (!((ISpanFormattable)value).TryFormat(_chars[_pos..], out charsWritten, format, _provider)) // constrained call avoiding boxing for value types + { + Grow(); + } + + _pos += charsWritten; + + if (lowercase) + { + LowercaseRange(_chars.Slice(startPos, _pos - startPos)); + } + return; + } + + s = ((IFormattable)value).ToString(format, _provider); // constrained call avoiding boxing for value types + } + else + { + s = value?.ToString(); + } + + if (s is not null) + { + AppendStringDirect(s); + } } - if (s is not null) + if (lowercase) { - AppendStringDirect(s); + LowercaseRange(_chars.Slice(startPos, _pos - startPos)); } } @@ -354,9 +377,14 @@ public ref struct RawInterpolatedStringHandler /// Writes the specified string of chars to the handler. /// The span to write. /// Minimum number of characters that should be written for this value. If the value is negative, it indicates left-aligned and the required minimum is the absolute value. - /// The format string. + /// + /// The format string. Pass "L" to lowercase the value's chars (only the + /// value range, not any alignment padding) using . + /// public void AppendFormatted(ReadOnlySpan value, int alignment = 0, string? format = null) { + var lowercase = format == "L"; + var leftAlign = false; if (alignment < 0) { @@ -369,7 +397,12 @@ public ref struct RawInterpolatedStringHandler { // The value is as large or larger than the required amount of padding, // so just write the value. + var startPos = _pos; AppendFormatted(value); + if (lowercase) + { + LowercaseRange(_chars.Slice(startPos, _pos - startPos)); + } return; } @@ -377,8 +410,13 @@ public ref struct RawInterpolatedStringHandler EnsureCapacityForAdditionalChars(value.Length + paddingRequired); if (leftAlign) { + var valueStart = _pos; value.CopyTo(_chars[_pos..]); _pos += value.Length; + if (lowercase) + { + LowercaseRange(_chars.Slice(valueStart, _pos - valueStart)); + } _chars.Slice(_pos, paddingRequired).Fill(' '); _pos += paddingRequired; } @@ -386,8 +424,13 @@ public ref struct RawInterpolatedStringHandler { _chars.Slice(_pos, paddingRequired).Fill(' '); _pos += paddingRequired; + var valueStart = _pos; value.CopyTo(_chars[_pos..]); _pos += value.Length; + if (lowercase) + { + LowercaseRange(_chars.Slice(valueStart, _pos - valueStart)); + } } } #endregion @@ -522,6 +565,32 @@ public ref struct RawInterpolatedStringHandler } } + /// + /// Lowercases the chars in in the half-open range [start, end) + /// using the BCL's vectorized . + /// Copies through a stackalloc temp for ranges up to 256 chars, otherwise rents from + /// . The BCL overload throws on overlapping source/destination + /// spans, so a temp is always required; running one big SIMD pass beats chunking on long inputs + /// and avoids splitting surrogate pairs at chunk boundaries. + /// + private static void LowercaseRange(Span dest) + { + if (dest.Length <= MinimumArrayPoolLength) + { + Span temp = stackalloc char[MinimumArrayPoolLength]; + var t = temp[..dest.Length]; + dest.CopyTo(t); + ((ReadOnlySpan)t).ToLowerInvariant(dest); + return; + } + + var rented = STArrayPool.Shared.Rent(dest.Length); + var rentedSlice = rented.AsSpan(0, dest.Length); + dest.CopyTo(rentedSlice); + ((ReadOnlySpan)rentedSlice).ToLowerInvariant(dest); + STArrayPool.Shared.Return(rented); + } + /// Ensures has the capacity to store beyond . [MethodImpl(MethodImplOptions.AggressiveInlining)] private void EnsureCapacityForAdditionalChars(int additionalChars)