Skip to content

Commit

Permalink
feat: Adds optimized dynamic/static layout gumps (#1652)
Browse files Browse the repository at this point in the history
# New Gump API
We are pleased to release a new API that is faster, allocates nearly zero memory, and still feels very similar to the original API. The API is broken into 3 types of gumps, dynamic, static with placeholders, and static without placeholders. 

### Dynamic Gumps
These gumps will inherit `DynamicGump` and are meant for gumps that have a dynamic layout. This includes specifying dynamic arguments to HtmlLocalized entries.

## Static Gumps 
Static gumps are those where the function to the build the layout is called only once and cached forever. They can optionally have placeholders. These placeholders allow the developer to specify the string values later, dynamically in a `BuildStrings` method on the gump. If a gump does not have any placeholders, the string entries will also be cached forever.

## Benchmarks
To make sure we were going in the right direction and not wasting time, we took copious benchmarks. Here are the final benchmarks for a really simple gump. 

Note:
* The majority of creating a gump is compressing the layout and the strings. Compressing each section takes ~6,000ns (12us total).

```cs
| Method                         | Mean         | Error        | StdDev       | Median       | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|------------------------------- |-------------:|-------------:|-------------:|-------------:|------:|--------:|-------:|----------:|------------:|
| OldGump                        | 13,308.29 ns | 1,059.695 ns | 1,883.608 ns | 14,330.72 ns | 1.000 |    0.00 | 0.1526 |    2400 B |        1.00 |
| DynamicLayoutGump              | 13,357.86 ns |   129.144 ns |   226.185 ns | 13,323.60 ns | 1.029 |    0.17 |      - |      48 B |        0.02 |
| StaticLayoutDynamicStringsGump |  6,653.10 ns |    81.815 ns |   143.292 ns |  6,617.45 ns | 0.514 |    0.09 |      - |      40 B |        0.02 |
| StaticLayoutGump               |     86.33 ns |     0.760 ns |     1.350 ns |     86.07 ns | 0.007 |    0.00 | 0.0020 |      32 B |        0.01 |
```

# Non-Breaking Changes
* All gump components in the core have been moved to `Gumps/Legacy`.
* All legacy gumps will still inherit `Gump`, which now inherits `BaseGump`

# Special Thanks

Thank you to @stefanomerotta for considerable contributions/benchmarking/testing to make this effort a reality! We collectively went through over 10 iterations, but it is finally ready.
  • Loading branch information
kamronbatman committed Apr 26, 2024
1 parent 1c10b6d commit 65532ea
Show file tree
Hide file tree
Showing 46 changed files with 2,528 additions and 274 deletions.
35 changes: 35 additions & 0 deletions Projects/Server.Tests/Tests/Gumps/TestGumps/DynamicTestGump.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Server.Gumps;

namespace Server.Tests.Gumps;

public class DynamicTestGump : DynamicGump
{
private readonly string _petName;

public DynamicTestGump(string petName) : base(50, 50)
{
_petName = petName;
Serial = (Serial)0x123;
TypeID = 0x5345;
}

protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
builder.AddPage();

builder.AddBackground(10, 10, 265, 140, 0x242C);

builder.AddItem(205, 40, 0x4);
builder.AddItem(227, 40, 0x5);

builder.AddItem(180, 78, 0xCAE);
builder.AddItem(195, 90, 0xCAD);
builder.AddItem(218, 95, 0xCB0);

builder.AddHtml(30, 30, 150, 75, "<div align=center>Wilt thou sanctify the resurrection of:</div>");
builder.AddHtml(30, 70, 150, 25, $"<CENTER>{_petName}</CENTER>", true);

builder.AddButton(40, 105, 0x81A, 0x81B, 0x1); // Okay
builder.AddButton(110, 105, 0x819, 0x818, 0x2); // Cancel
}
}
29 changes: 29 additions & 0 deletions Projects/Server.Tests/Tests/Gumps/TestGumps/LegacyTestGump.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Server.Gumps;

namespace Server.Tests.Gumps;

public sealed class LegacyTestGump : Gump
{
public LegacyTestGump(string petName) : base(50, 50)
{
Serial = (Serial)0x123;
TypeID = 0x5345;

AddPage(0);

AddBackground(10, 10, 265, 140, 0x242C);

AddItem(205, 40, 0x4);
AddItem(227, 40, 0x5);

AddItem(180, 78, 0xCAE);
AddItem(195, 90, 0xCAD);
AddItem(218, 95, 0xCB0);

AddHtml(30, 30, 150, 75, "<div align=center>Wilt thou sanctify the resurrection of:</div>");
AddHtml(30, 70, 150, 25, $"<CENTER>{petName}</CENTER>", true);

AddButton(40, 105, 0x81A, 0x81B, 0x1); // Okay
AddButton(110, 105, 0x819, 0x818, 0x2); // Cancel
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using Server.Gumps;

namespace Server.Tests.Gumps;

public class StaticLayoutTestGump : StaticGump<StaticLayoutTestGump>
{
private readonly string _petName;

public StaticLayoutTestGump(string petName) : base(50, 50)
{
_petName = petName;
Serial = (Serial)0x123;
TypeID = 0x5345;
}

protected override void BuildLayout(ref StaticGumpBuilder builder)
{
builder.AddPage();

builder.AddBackground(10, 10, 265, 140, 0x242C);

builder.AddItem(205, 40, 0x4);
builder.AddItem(227, 40, 0x5);

builder.AddItem(180, 78, 0xCAE);
builder.AddItem(195, 90, 0xCAD);
builder.AddItem(218, 95, 0xCB0);

builder.AddHtml(30, 30, 150, 75, "<div align=center>Wilt thou sanctify the resurrection of:</div>");
builder.AddHtmlPlaceholder(30, 70, 150, 25, "petName", true);

builder.AddButton(40, 105, 0x81A, 0x81B, 0x1); // Okay
builder.AddButton(110, 105, 0x819, 0x818, 0x2); // Cancel
}

protected override void BuildStrings(ref GumpStringsBuilder builder)
{
builder.SetStringSlot("petName", $"<CENTER>{_petName}</CENTER>");
}
}
32 changes: 32 additions & 0 deletions Projects/Server.Tests/Tests/Gumps/TestGumps/StaticTestGump.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Server.Gumps;

namespace Server.Tests.Gumps;

public class StaticTestGump : StaticGump<StaticTestGump>
{
public StaticTestGump() : base(50, 50)
{
Serial = (Serial)0x123;
TypeID = 0x5345;
}

protected override void BuildLayout(ref StaticGumpBuilder builder)
{
builder.AddPage();

builder.AddBackground(10, 10, 265, 140, 0x242C);

builder.AddItem(205, 40, 0x4);
builder.AddItem(227, 40, 0x5);

builder.AddItem(180, 78, 0xCAE);
builder.AddItem(195, 90, 0xCAD);
builder.AddItem(218, 95, 0xCB0);

builder.AddHtml(30, 30, 150, 75, "<div align=center>Wilt thou sanctify the resurrection of:</div>");
builder.AddHtml(30, 70, 150, 25, "<CENTER>Test</CENTER>", true);

builder.AddButton(40, 105, 0x81A, 0x81B, 0x1); // Okay
builder.AddButton(110, 105, 0x819, 0x818, 0x2); // Cancel
}
}
143 changes: 143 additions & 0 deletions Projects/Server.Tests/Tests/Gumps/TestLayoutGumps.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using System;
using System.Buffers;
using System.IO;
using Server.Gumps;
using Server.Network;
using Server.Tests.Network;
using Xunit;

namespace Server.Tests.Gumps;

[Collection("Sequential Tests")]
public class TestLayoutGumps
{
[Fact]
public void TestDynamicGumpPacket()
{
var legacyGump = new LegacyTestGump("Test");
var legacyPacketData = legacyGump.Compile().Compile();

var staticGump = new DynamicTestGump("Test");
var buffer = GC.AllocateUninitializedArray<byte>(512);
var writer = new SpanWriter(buffer);
staticGump.CreatePacket(ref writer);

AssertThat.Equal(writer.Span, legacyPacketData);
}

[Fact]
public void TestStaticLayoutGumpPacket()
{
var expectedLayout =
"{ page 0 }{ resizepic 10 10 9260 265 140 }{ tilepic 205 40 4 }{ tilepic 227 40 5 }{ tilepic 180 78 3246 }{ tilepic 195 90 3245 }{ tilepic 218 95 3248 }{ htmlgump 30 30 150 75 1 0 0 }{ htmlgump 30 70 150 25 00002 1 0 }{ button 40 105 2074 2075 1 0 1 }{ button 110 105 2073 2072 1 0 2 }\0"u8;

string[] strings =
[
"<div align=center>Wilt thou sanctify the resurrection of:</div>",
"<CENTER>Test</CENTER>"
];

InternalTestStaticGump(expectedLayout, new StaticLayoutTestGump("Test"), strings);
}

[Fact]
public void TestStaticGumpPacket()
{
var expectedLayout =
"{ page 0 }{ resizepic 10 10 9260 265 140 }{ tilepic 205 40 4 }{ tilepic 227 40 5 }{ tilepic 180 78 3246 }{ tilepic 195 90 3245 }{ tilepic 218 95 3248 }{ htmlgump 30 30 150 75 1 0 0 }{ htmlgump 30 70 150 25 2 1 0 }{ button 40 105 2074 2075 1 0 1 }{ button 110 105 2073 2072 1 0 2 }\0"u8;

string[] strings =
[
"<div align=center>Wilt thou sanctify the resurrection of:</div>",
"<CENTER>Test</CENTER>"
];

InternalTestStaticGump(expectedLayout, new StaticTestGump(), strings);
}

[Fact]
public void TestStaticGumpIsCached()
{
var gump = new CachedGump();
var buffer = GC.AllocateUninitializedArray<byte>(512);
var writer = new SpanWriter(buffer);
gump.CreatePacket(ref writer);

var packet = writer.Span.ToArray();

// Reset the writer
writer.Seek(0, SeekOrigin.Begin);

// Second call should not call BuildLayout
gump.CreatePacket(ref writer);

AssertThat.Equal(writer.Span, packet);
}

private static void InternalTestStaticGump<T>(ReadOnlySpan<byte> expectedLayout, StaticGump<T> staticGump, string[] strings)
where T : StaticGump<T>
{
// Expected layout
var expectedBuffer = GC.AllocateUninitializedArray<byte>(512);
var expectedBufferWriter = new SpanWriter(expectedBuffer);
OutgoingGumpPackets.WritePacked(expectedLayout, ref expectedBufferWriter);
var layoutLength = expectedBufferWriter.BytesWritten;

var buffer = GC.AllocateUninitializedArray<byte>(512);
var writer = new SpanWriter(buffer);
staticGump.CreatePacket(ref writer);

// Assert layout is exactly what we are expecting
AssertThat.Equal(writer.Span.Slice(19, layoutLength), expectedBufferWriter.Span);

// Assert strings count
AssertThat.Equal(writer.Span.Slice(19 + layoutLength, 4), stackalloc byte[] { 0, 0, 0, 3 });

var expectedStringsBuffer = GC.AllocateUninitializedArray<byte>(512);
var expectedStringsWriter = new SpanWriter(expectedStringsBuffer);

// Empty string
expectedStringsWriter.Write((ushort)0);

// loop through the strings, write them to the strings writer
foreach (var str in strings)
{
expectedStringsWriter.Write((ushort)str.Length);
expectedStringsWriter.WriteBigUni(str);
}

// Reset buffer
expectedBufferWriter.Seek(0, SeekOrigin.Begin);

OutgoingGumpPackets.WritePacked(expectedStringsWriter.Span, ref expectedBufferWriter);

// Assert strings are exactly what we are expecting
AssertThat.Equal(writer.Span[(19 + layoutLength + 4)..], expectedBufferWriter.Span);
}

private class CachedGump : StaticGump<CachedGump>
{
private bool _isCachedLayout;

public CachedGump() : base(50, 50)
{
Serial = (Serial)0x124;
TypeID = 0x5346;
}

protected override void BuildLayout(ref StaticGumpBuilder builder)
{
Assert.False(_isCachedLayout);
_isCachedLayout = true;

builder.AddPage();

builder.AddHtml(30, 30, 150, 75, "Some text");
}

protected override void BuildStrings(ref GumpStringsBuilder builder)
{
Assert.Fail("BuildStrings should not be called when the layout is cached.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Server.Tests.Network;

[Collection("Sequential Tests")]
public class GumpPacketTests : IClassFixture<ServerFixture>
{
[Theory]
Expand Down
2 changes: 1 addition & 1 deletion Projects/Server/Buffers/RawInterpolatedStringHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

namespace Server.Buffers;

/// <summary>Provides a handler to interpolate strings which UNSAFELY exposes it's internal character span.</summary>
/// <summary>Provides a handler to interpolate strings which UNSAFELY exposes its internal character span.</summary>
[InterpolatedStringHandler]
public ref struct RawInterpolatedStringHandler
{
Expand Down
73 changes: 73 additions & 0 deletions Projects/Server/Gumps/BaseGump.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2024 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BaseGump.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 <http://www.gnu.org/licenses/>. *
*************************************************************************/

using Server.Network;
using System;
using System.Runtime.CompilerServices;

namespace Server.Gumps;

public abstract class BaseGump
{
private static Serial nextSerial = (Serial)1;

public int TypeID { get; protected set; }
public Serial Serial { get; protected set; }

public abstract int Switches { get; }
public abstract int TextEntries { get; }

public int X { get; set; }

public int Y { get; set; }

public BaseGump(int x, int y) : this()
{
X = x;
Y = y;
}

public BaseGump()
{
Serial = nextSerial++;
TypeID = GetTypeId(GetType());
}

public abstract void SendTo(NetState ns);

public virtual void OnResponse(NetState sender, in RelayInfo info)
{
}

public virtual void OnServerClose(NetState owner)
{
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int GetTypeId(Type type)
{
unchecked
{
// To use the original .NET Framework deterministic hash code (with really terrible performance)
// change the next line to use HashUtility.GetNetFrameworkHashCode
var hash = (int)HashUtility.ComputeHash32(type?.FullName);

const int primeMulti = 0x108B76F1;

// Virtue Gump
return hash == 461 ? hash * primeMulti : hash;
}
}
}

0 comments on commit 65532ea

Please sign in to comment.