diff --git a/Projects/Server/Buffers/ValueStringBuilder.cs b/Projects/Server/Buffers/ValueStringBuilder.cs index ecdf73072..3f5820f85 100644 --- a/Projects/Server/Buffers/ValueStringBuilder.cs +++ b/Projects/Server/Buffers/ValueStringBuilder.cs @@ -14,6 +14,17 @@ namespace Server.Buffers private char[]? _arrayToReturnToPool; private Span _chars; + // If this ctor is used, you cannot pass in stackalloc ROS for append/replace. + public ValueStringBuilder(ReadOnlySpan initialString) : this(initialString.Length) + { + Append(initialString); + } + + public ValueStringBuilder(ReadOnlySpan initialString, Span initialBuffer) : this(initialBuffer) + { + Append(initialString); + } + public ValueStringBuilder(Span initialBuffer) { _arrayToReturnToPool = null; @@ -21,6 +32,7 @@ namespace Server.Buffers Length = 0; } + // If this ctor is used, you cannot pass in stackalloc ROS for append/replace. public ValueStringBuilder(int initialCapacity) { _arrayToReturnToPool = ArrayPool.Shared.Rent(initialCapacity); @@ -297,6 +309,37 @@ namespace Server.Buffers } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void ReplaceAny(ReadOnlySpan oldChars, ReadOnlySpan newChars, int startIndex, int count) + { + int currentLength = Length; + if ((uint)startIndex > (uint)currentLength) + { + throw new ArgumentOutOfRangeException(nameof(startIndex)); + } + + if (count < 0 || startIndex > currentLength - count) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + var slice = _chars; + + while (true) + { + var indexOf = slice.IndexOfAny(oldChars); + if (indexOf == -1) + { + break; + } + + var chr = slice[indexOf]; + + slice[indexOf] = newChars[oldChars.IndexOf(chr)]; + slice = slice.Slice(indexOf + 1); + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Replace(char oldChar, char newChar, int startIndex, int count) { diff --git a/Projects/Server/Utilities/Utility.cs b/Projects/Server/Utilities/Utility.cs index cfda11e72..03cb5ac57 100644 --- a/Projects/Server/Utilities/Utility.cs +++ b/Projects/Server/Utilities/Utility.cs @@ -10,6 +10,7 @@ using System.Runtime.CompilerServices; using System.Text; using System.Xml; using Microsoft.Toolkit.HighPerformance.Extensions; +using Server.Buffers; using Server.Network; using Server.Random; @@ -560,31 +561,10 @@ namespace Server return ""; } - var hasOpen = str.ContainsOrdinal('<'); - var hasClose = str.ContainsOrdinal('>'); - var hasPound = str.ContainsOrdinal('#'); - - if (!hasOpen && !hasClose && !hasPound) - { - return str; - } - - var sb = new StringBuilder(str); - - if (hasOpen) - { - sb.Replace('<', '('); - } - - if (hasClose) - { - sb.Replace('>', ')'); - } - - if (hasPound) - { - sb.Replace('#', '-'); - } + using var sb = new ValueStringBuilder(str, stackalloc char[Math.Min(40960, str.Length)]); + ReadOnlySpan invalid = stackalloc []{ '<', '>', '#' }; + ReadOnlySpan replacement = stackalloc []{ '(', ')', '-' }; + sb.ReplaceAny(invalid, replacement, 0, sb.Length); return sb.ToString(); } diff --git a/Projects/UOContent.Tests/Tests/Items/Books/BookPacketTests.cs b/Projects/UOContent.Tests/Tests/Items/Books/BookPacketTests.cs new file mode 100644 index 000000000..a8bdebcce --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Books/BookPacketTests.cs @@ -0,0 +1,67 @@ +using System; +using Server; +using Server.Items; +using Server.Network; +using Server.Tests; +using Server.Tests.Network; +using Xunit; + +namespace UOContent.Tests +{ + public class BookPacketTests : IClassFixture + { + [Theory] + [InlineData("🅵🅰🅽🅲🆈 🆃🅴🆇🆃 Author", "🅵🅰🅽🅲🆈 🆃🅴🆇🆃 Title")] + public void TestBookCover(string author, string title) + { + var m = new Mobile(0x1); + m.DefaultMobileInit(); + + var book = new BlueBook { Author = author, Title = title }; + + var expected = new BookHeader(m, book).Compile(); + + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.SendBookCover(m, book); + + var result = ns.SendPipe.Reader.TryRead(); + AssertThat.Equal(result.Buffer[0].AsSpan(0), expected); + } + + [Fact] + public void TestBookContent() + { + var m = new Mobile(0x1); + m.DefaultMobileInit(); + + var book = new BlueBook { Author = "Some Author", Title = "Some Title" }; + book.Pages[0].Lines = new[] + { + "Some books start with actual content", + "This book does not have any actual content", + "Instead it has several pages of useless text" + }; + + book.Pages[1].Lines = new[] + { + "Another page exists but this page:", + "Has lots of: 🅵🅰🅽🅲🆈 🆃🅴🆇🆃", + "And just more: 🅵🅰🅽🅲🆈 🆃🅴🆇🆃", + "So everyone can read: 🅵🅰🅽🅲🆈 🆃🅴🆇🆃" + }; + + book.Pages[2].Lines = new[] + { + "The end" + }; + + var expected = new BookPageDetails(book).Compile(); + + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.SendBookContent(book); + + var result = ns.SendPipe.Reader.TryRead(); + AssertThat.Equal(result.Buffer[0].AsSpan(0), expected); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Items/Books/Packets.cs b/Projects/UOContent.Tests/Tests/Items/Books/Packets.cs new file mode 100644 index 000000000..aca4013c2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Books/Packets.cs @@ -0,0 +1,59 @@ +using Server.Items; +using Server.Text; + +namespace Server.Network +{ + public sealed class BookPageDetails : Packet + { + public BookPageDetails(BaseBook book) : base(0x66) + { + EnsureCapacity(256); + + Stream.Write(book.Serial); + Stream.Write((ushort)book.PagesCount); + + for (var i = 0; i < book.PagesCount; ++i) + { + var page = book.Pages[i]; + + Stream.Write((ushort)(i + 1)); + Stream.Write((ushort)page.Lines.Length); + + for (var j = 0; j < page.Lines.Length; ++j) + { + var buffer = page.Lines[j].GetBytesUtf8(); + + Stream.Write(buffer, 0, buffer.Length); + Stream.Write((byte)0); + } + } + } + } + + public sealed class BookHeader : Packet + { + public BookHeader(Mobile from, BaseBook book) : base(0xD4) + { + var title = book.Title ?? ""; + var author = book.Author ?? ""; + + var titleBuffer = title.GetBytesUtf8(); + var authorBuffer = author.GetBytesUtf8(); + + EnsureCapacity(15 + titleBuffer.Length + authorBuffer.Length); + + Stream.Write(book.Serial); + Stream.Write(true); + Stream.Write(book.Writable && from.InRange(book.GetWorldLocation(), 1)); + Stream.Write((ushort)book.PagesCount); + + Stream.Write((ushort)(titleBuffer.Length + 1)); + Stream.Write(titleBuffer, 0, titleBuffer.Length); + Stream.Write((byte)0); // terminate + + Stream.Write((ushort)(authorBuffer.Length + 1)); + Stream.Write(authorBuffer, 0, authorBuffer.Length); + Stream.Write((byte)0); // terminate + } + } +} diff --git a/Projects/UOContent/Items/Books/BaseBook.cs b/Projects/UOContent/Items/Books/BaseBook.cs index ab00e60dd..ce885f369 100644 --- a/Projects/UOContent/Items/Books/BaseBook.cs +++ b/Projects/UOContent/Items/Books/BaseBook.cs @@ -1,46 +1,12 @@ using System; using System.Collections.Generic; -using System.IO; using Server.Buffers; using Server.ContextMenus; using Server.Gumps; using Server.Multis; -using Server.Network; -using Server.Text; namespace Server.Items { - public class BookPageInfo - { - public BookPageInfo() => Lines = Array.Empty(); - - public BookPageInfo(params string[] lines) => Lines = lines; - - public BookPageInfo(IGenericReader reader) - { - var length = reader.ReadInt(); - - Lines = new string[length]; - - for (var i = 0; i < Lines.Length; ++i) - { - Lines[i] = Utility.Intern(reader.ReadString()); - } - } - - public string[] Lines { get; set; } - - public void Serialize(IGenericWriter writer) - { - writer.Write(Lines.Length); - - for (var i = 0; i < Lines.Length; ++i) - { - writer.Write(Lines[i]); - } - } - } - public class BaseBook : Item, ISecurable { private string m_Author; @@ -356,121 +322,8 @@ namespace Server.Items Author = from.Name; } - from.Send(new BookHeader(from, this)); - from.Send(new BookPageDetails(this)); - } - - public static void Initialize() - { - IncomingPackets.Register(0xD4, 0, true, HeaderChange); - IncomingPackets.Register(0x66, 0, true, ContentChange); - IncomingPackets.Register(0x93, 99, true, OldHeaderChange); - } - - public static void OldHeaderChange(NetState state, CircularBufferReader reader, ref int packetLength) - { - var from = state.Mobile; - - if (!(World.FindItem(reader.ReadUInt32()) is BaseBook book) || !book.Writable || - !from.InRange(book.GetWorldLocation(), 1) || !book.IsAccessibleTo(from)) - { - return; - } - - reader.Seek(4, SeekOrigin.Current); // Skip flags and page count - - var title = reader.ReadAsciiSafe(60); - var author = reader.ReadAsciiSafe(30); - - book.Title = Utility.FixHtml(title); - book.Author = Utility.FixHtml(author); - } - - public static void HeaderChange(NetState state, CircularBufferReader reader, ref int packetLength) - { - var from = state.Mobile; - - if (!(World.FindItem(reader.ReadUInt32()) is BaseBook book) || !book.Writable || - !from.InRange(book.GetWorldLocation(), 1) || !book.IsAccessibleTo(from)) - { - return; - } - - reader.Seek(4, SeekOrigin.Current); // Skip flags and page count - - int titleLength = reader.ReadUInt16(); - - if (titleLength > 60) - { - return; - } - - var title = reader.ReadUTF8Safe(titleLength); - - int authorLength = reader.ReadUInt16(); - - if (authorLength > 30) - { - return; - } - - var author = reader.ReadUTF8Safe(authorLength); - - book.Title = Utility.FixHtml(title); - book.Author = Utility.FixHtml(author); - } - - public static void ContentChange(NetState state, CircularBufferReader reader, ref int packetLength) - { - var from = state.Mobile; - - if (!(World.FindItem(reader.ReadUInt32()) is BaseBook book) || !book.Writable || - !from.InRange(book.GetWorldLocation(), 1) || !book.IsAccessibleTo(from)) - { - return; - } - - int pageCount = reader.ReadUInt16(); - - if (pageCount > book.PagesCount) - { - return; - } - - for (var i = 0; i < pageCount; ++i) - { - int index = reader.ReadUInt16(); - - if (index >= 1 && index <= book.PagesCount) - { - --index; - - int lineCount = reader.ReadUInt16(); - - if (lineCount <= 8) - { - var lines = new string[lineCount]; - - for (var j = 0; j < lineCount; ++j) - { - if ((lines[j] = reader.ReadUTF8Safe()).Length >= 80) - { - return; - } - } - - book.Pages[index].Lines = lines; - } - else - { - return; - } - } - else - { - return; - } - } + from.NetState.SendBookCover(from, this); + from.NetState.SendBookContent(this); } [Flags] @@ -483,58 +336,4 @@ namespace Server.Items Content = 0x08 } } - - public sealed class BookPageDetails : Packet - { - public BookPageDetails(BaseBook book) : base(0x66) - { - EnsureCapacity(256); - - Stream.Write(book.Serial); - Stream.Write((ushort)book.PagesCount); - - for (var i = 0; i < book.PagesCount; ++i) - { - var page = book.Pages[i]; - - Stream.Write((ushort)(i + 1)); - Stream.Write((ushort)page.Lines.Length); - - for (var j = 0; j < page.Lines.Length; ++j) - { - var buffer = page.Lines[j].GetBytesUtf8(); - - Stream.Write(buffer, 0, buffer.Length); - Stream.Write((byte)0); - } - } - } - } - - public sealed class BookHeader : Packet - { - public BookHeader(Mobile from, BaseBook book) : base(0xD4) - { - var title = book.Title ?? ""; - var author = book.Author ?? ""; - - var titleBuffer = title.GetBytesUtf8(); - var authorBuffer = author.GetBytesUtf8(); - - EnsureCapacity(15 + titleBuffer.Length + authorBuffer.Length); - - Stream.Write(book.Serial); - Stream.Write(true); - Stream.Write(book.Writable && from.InRange(book.GetWorldLocation(), 1)); - Stream.Write((ushort)book.PagesCount); - - Stream.Write((ushort)(titleBuffer.Length + 1)); - Stream.Write(titleBuffer, 0, titleBuffer.Length); - Stream.Write((byte)0); // terminate - - Stream.Write((ushort)(authorBuffer.Length + 1)); - Stream.Write(authorBuffer, 0, authorBuffer.Length); - Stream.Write((byte)0); // terminate - } - } } diff --git a/Projects/UOContent/Items/Books/Defined/BookContent.cs b/Projects/UOContent/Items/Books/BookContent.cs similarity index 100% rename from Projects/UOContent/Items/Books/Defined/BookContent.cs rename to Projects/UOContent/Items/Books/BookContent.cs diff --git a/Projects/UOContent/Items/Books/BookPackets.cs b/Projects/UOContent/Items/Books/BookPackets.cs new file mode 100644 index 000000000..21b1d66f1 --- /dev/null +++ b/Projects/UOContent/Items/Books/BookPackets.cs @@ -0,0 +1,216 @@ +/************************************************************************* + * ModernUO * + * Copyright (C) 2019-2021 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BookPackets.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Buffers; +using System.IO; +using Server.Network; +using Server.Text; + +namespace Server.Items +{ + public static class BookPackets + { + public static void Configure() + { + IncomingPackets.Register(0xD4, 0, true, HeaderChange); + IncomingPackets.Register(0x66, 0, true, ContentChange); + IncomingPackets.Register(0x93, 99, true, OldHeaderChange); + } + + public static void OldHeaderChange(NetState state, CircularBufferReader reader, ref int packetLength) + { + var from = state.Mobile; + + if (!(World.FindItem(reader.ReadUInt32()) is BaseBook book) || !book.Writable || + !from.InRange(book.GetWorldLocation(), 1) || !book.IsAccessibleTo(from)) + { + return; + } + + reader.Seek(4, SeekOrigin.Current); // Skip flags and page count + + var title = reader.ReadAsciiSafe(60); + var author = reader.ReadAsciiSafe(30); + + book.Title = Utility.FixHtml(title); + book.Author = Utility.FixHtml(author); + } + + public static void HeaderChange(NetState state, CircularBufferReader reader, ref int packetLength) + { + var from = state.Mobile; + + if (!(World.FindItem(reader.ReadUInt32()) is BaseBook book) || !book.Writable || + !from.InRange(book.GetWorldLocation(), 1) || !book.IsAccessibleTo(from)) + { + return; + } + + reader.Seek(4, SeekOrigin.Current); // Skip flags and page count + + int titleLength = reader.ReadUInt16(); + + if (titleLength > 60) + { + return; + } + + // TODO: Read string to a Span stackalloc instead of a returned value + // This way we can avoid an allocation and do Utility.FixHtml against it by searching/replacing characters + var title = reader.ReadUTF8Safe(titleLength); + + int authorLength = reader.ReadUInt16(); + + if (authorLength > 30) + { + return; + } + + var author = reader.ReadUTF8Safe(authorLength); + + book.Title = Utility.FixHtml(title); + book.Author = Utility.FixHtml(author); + } + + public static void ContentChange(NetState state, CircularBufferReader reader, ref int packetLength) + { + var from = state.Mobile; + + if (!(World.FindItem(reader.ReadUInt32()) is BaseBook book) || !book.Writable || + !from.InRange(book.GetWorldLocation(), 1) || !book.IsAccessibleTo(from)) + { + return; + } + + int pageCount = reader.ReadUInt16(); + + if (pageCount > book.PagesCount) + { + return; + } + + for (var i = 0; i < pageCount; ++i) + { + int index = reader.ReadUInt16(); + + if (index < 1 || index > book.PagesCount) + { + return; + } + + --index; + + int lineCount = reader.ReadUInt16(); + + if (lineCount > 8) + { + return; + } + + var lines = new string[lineCount]; + + for (var j = 0; j < lineCount; ++j) + { + if ((lines[j] = reader.ReadUTF8Safe()).Length >= 80) + { + return; + } + } + + book.Pages[index].Lines = lines; + } + } + + public static void SendBookContent(this NetState ns, BaseBook book) + { + if (ns == null) + { + return; + } + + var count = book.PagesCount; + + // Practical limit is 24 full pages at full unicode, or 96 full pages at ascii-only + // For pure ascii, finding byte count is fast enough. + // If perf becomes an issue, then switch to resizable SpanWriter + var length = 9; + for (var i = 0; i < count; i++) + { + var page = book.Pages[i]; + length += 4; + // max is 8 + for (var j = 0; j < page.Lines.Length; j++) + { + length += TextEncoding.UTF8.GetByteCount(page.Lines[j]) + 1; + } + } + + var writer = new SpanWriter(stackalloc byte[length]); + writer.Write((byte)0x66); // Packet ID + writer.Write((ushort)length); + writer.Write(book.Serial); + writer.Write((ushort)count); + + for (var i = 0; i < count; i++) + { + var page = book.Pages[i]; + + writer.Write((ushort)(i + 1)); + writer.Write((ushort)page.Lines.Length); + + for (var j = 0; j < page.Lines.Length; j++) + { + writer.WriteUTF8Null(page.Lines[j]); + } + } + + writer.WritePacketLength(); + + ns.Send(writer.Span); + } + + public static void SendBookCover(this NetState ns, Mobile from, BaseBook book) + { + if (ns == null) + { + return; + } + + var title = book.Title ?? ""; + var titleLength = TextEncoding.UTF8.GetByteCount(title); + + var author = book.Author ?? ""; + var authorLength = TextEncoding.UTF8.GetByteCount(author); + + var length = 17 + titleLength + authorLength; + var writer = new SpanWriter(stackalloc byte[length]); + writer.Write((byte)0xD4); // Packet ID + writer.Write((ushort)length); + writer.Write(book.Serial); + writer.Write((byte)0x1); // Flag on + writer.Write(book.Writable && from.InRange(book.GetWorldLocation(), 1)); + writer.Write((ushort)book.PagesCount); + + writer.Write((ushort)(titleLength + 1)); + writer.WriteUTF8Null(title); + + writer.Write((ushort)(authorLength + 1)); + writer.WriteUTF8Null(author); + + ns.Send(writer.Span); + } + } +} diff --git a/Projects/UOContent/Items/Books/BookPageInfo.cs b/Projects/UOContent/Items/Books/BookPageInfo.cs new file mode 100644 index 000000000..28220e24a --- /dev/null +++ b/Projects/UOContent/Items/Books/BookPageInfo.cs @@ -0,0 +1,35 @@ +using System; + +namespace Server.Items +{ + public class BookPageInfo + { + public BookPageInfo() => Lines = Array.Empty(); + + public BookPageInfo(params string[] lines) => Lines = lines; + + public BookPageInfo(IGenericReader reader) + { + var length = reader.ReadInt(); + + Lines = new string[length]; + + for (var i = 0; i < Lines.Length; ++i) + { + Lines[i] = Utility.Intern(reader.ReadString()); + } + } + + public string[] Lines { get; set; } + + public void Serialize(IGenericWriter writer) + { + writer.Write(Lines.Length); + + for (var i = 0; i < Lines.Length; ++i) + { + writer.Write(Lines[i]); + } + } + } +}