fix(network): Adds ConnectUO (#551)
- [X] Adds ability to create new protocol extension packets - [X] Adds freeshard protocol extension support (packet 0xF1) - [X] Adds ConnectUO support - [X] Adds arbitrary read for `CircularBufferReader` and `SpanReader`
This commit is contained in:
parent
76970325a3
commit
5e3b42bf9c
11 changed files with 381 additions and 162 deletions
|
|
@ -10,7 +10,7 @@ namespace Server.Tests
|
|||
public void TestGetBytes(string input, byte[] bytes)
|
||||
{
|
||||
Span<byte> outputBytes = stackalloc byte[input.Length / 2];
|
||||
HexStringConverter.GetBytes(input, outputBytes);
|
||||
input.GetBytes(outputBytes);
|
||||
|
||||
Assert.Equal(bytes, outputBytes.ToArray());
|
||||
Assert.Equal(input, bytes.ToHexString());
|
||||
|
|
|
|||
|
|
@ -374,5 +374,21 @@ namespace Server.Network
|
|||
SeekOrigin.End => Length + offset,
|
||||
_ => Position + offset // Current
|
||||
};
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool Read(Span<byte> bytes)
|
||||
{
|
||||
if (bytes.Length < Length)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(bytes));
|
||||
}
|
||||
|
||||
if (_first.Length > 0 && !_first.TryCopyTo(bytes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _second.Length <= 0 || _second.TryCopyTo(bytes.Slice(_first.Length));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -278,5 +278,16 @@ namespace System.Buffers
|
|||
_ => offset // Begin
|
||||
});
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool Read(Span<byte> bytes)
|
||||
{
|
||||
if (bytes.Length < Length)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(bytes));
|
||||
}
|
||||
|
||||
return _buffer.TryCopyTo(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ namespace Server.Text
|
|||
return result;
|
||||
}
|
||||
|
||||
public static unsafe void GetBytes(string str, Span<byte> bytes)
|
||||
public static unsafe void GetBytes(this string str, Span<byte> bytes)
|
||||
{
|
||||
fixed (char* strP = str)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ namespace Server.Accounting.Security
|
|||
public bool ValidatePassword(string encryptedPassword, string plainPassword)
|
||||
{
|
||||
Span<byte> encryptedBytes = stackalloc byte[m_OutputSize];
|
||||
HexStringConverter.GetBytes(encryptedPassword, encryptedBytes);
|
||||
encryptedPassword.GetBytes(encryptedBytes);
|
||||
|
||||
var iterations = BinaryPrimitives.ReadUInt16LittleEndian(encryptedBytes.SliceToLength(2));
|
||||
var salt = encryptedBytes.Slice(2, m_SaltSize);
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ namespace Server.Gumps
|
|||
AddLabel(150, 270, LabelHue, Core.ScriptItems.ToString());
|
||||
|
||||
AddLabel(20, 290, LabelHue, "Uptime:");
|
||||
AddLabel(150, 290, LabelHue, FormatTimeSpan(Core.Now - Clock.ServerStart));
|
||||
AddLabel(150, 290, LabelHue, FormatTimeSpan(TimeSpan.FromMilliseconds(Core.TickCount)));
|
||||
|
||||
AddLabel(20, 310, LabelHue, "Memory:");
|
||||
AddLabel(150, 310, LabelHue, FormatByteAmount(GC.GetTotalMemory(false)));
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ namespace Server.Items
|
|||
|
||||
public static DateTime ServerStart { get; private set; }
|
||||
|
||||
public static void Initialize()
|
||||
public static void Configure()
|
||||
{
|
||||
ServerStart = Core.Now;
|
||||
}
|
||||
|
|
|
|||
122
Projects/UOContent/Network/ConnectUO.cs
Normal file
122
Projects/UOContent/Network/ConnectUO.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright (C) 2019-2021 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: ConnectUO.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 System;
|
||||
using System.Buffers;
|
||||
using Server.Accounting;
|
||||
using Server.Text;
|
||||
|
||||
namespace Server.Network
|
||||
{
|
||||
public static class ConnectUO
|
||||
{
|
||||
public enum ConnectUOServerType
|
||||
{
|
||||
RunUO,
|
||||
ServUO,
|
||||
UOX3,
|
||||
POL,
|
||||
Sphere,
|
||||
ModernUO
|
||||
}
|
||||
|
||||
public const byte ConnectUOProtocolVersion = 0;
|
||||
private const int _connectUOTokenLength = 32;
|
||||
private const ConnectUOServerType _serverType = ConnectUOServerType.ModernUO;
|
||||
private static byte[] _token;
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
var enabled = ServerConfiguration.GetOrUpdateSetting("connectuo.enabled", true);
|
||||
var token = ServerConfiguration.GetOrUpdateSetting("connectuo.token", null);
|
||||
|
||||
if (enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
if (token.Length != _connectUOTokenLength * 2)
|
||||
{
|
||||
throw new Exception("Invalid length for ConnectUO token");
|
||||
}
|
||||
|
||||
_token = GC.AllocateUninitializedArray<byte>(_connectUOTokenLength);
|
||||
token.GetBytes(_token);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Utility.PushColor(ConsoleColor.Red);
|
||||
Console.WriteLine("ConnectUO token could not be parsed");
|
||||
Console.WriteLine("Make sure modernuo.json is properly configured");
|
||||
Utility.PopColor();
|
||||
_token = null;
|
||||
}
|
||||
|
||||
FreeshardProtocol.Register(0xC0, false, PollInfo);
|
||||
}
|
||||
}
|
||||
|
||||
public static void PollInfo(NetState ns, CircularBufferReader reader, ref int packetLength)
|
||||
{
|
||||
var version = reader.ReadByte();
|
||||
|
||||
if (_token != null)
|
||||
{
|
||||
unsafe {
|
||||
byte* tok = stackalloc byte[_token.Length];
|
||||
var span = new Span<byte>(tok, _token.Length);
|
||||
reader.Read(span);
|
||||
|
||||
if (!span.SequenceEqual(_token))
|
||||
{
|
||||
ns.Disconnect("Invalid token sent for ConnectUO");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ns.WriteConsole($"ConnectUO (v{version}) is requesting stats.");
|
||||
if (version > ConnectUOProtocolVersion)
|
||||
{
|
||||
Utility.PushColor(ConsoleColor.Yellow);
|
||||
ns.WriteConsole("Warning! ConnectUO (v{version}) is newer than what is supported.");
|
||||
Utility.PopColor();
|
||||
}
|
||||
|
||||
ns.SendServerPollInfo();
|
||||
}
|
||||
|
||||
public static void SendServerPollInfo(this NetState ns)
|
||||
{
|
||||
if (ns == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var writer = new SpanWriter(stackalloc byte[15]);
|
||||
writer.Write((byte)0xC0); // Packet ID
|
||||
writer.Write(17); // Length
|
||||
writer.Write(ConnectUOProtocolVersion); // Version
|
||||
writer.Write((byte)_serverType);
|
||||
writer.Write((int)(Core.TickCount / 1000));
|
||||
writer.Write(Accounts.Count); // Shame if you modify this!
|
||||
writer.Write(TcpServer.Instances.Count); // Shame if you modify this!
|
||||
|
||||
ns.Send(writer.Span);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
Projects/UOContent/Network/FreeshardProtocol.cs
Normal file
31
Projects/UOContent/Network/FreeshardProtocol.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright (C) 2019-2021 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: FreeshardProtocol.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/>. *
|
||||
*************************************************************************/
|
||||
|
||||
namespace Server.Network
|
||||
{
|
||||
public static class FreeshardProtocol
|
||||
{
|
||||
private static PacketHandler[] _handlers;
|
||||
|
||||
[CallPriority(10)]
|
||||
public static void Configure()
|
||||
{
|
||||
_handlers = ProtocolExtensions.Register(0xF1);
|
||||
}
|
||||
|
||||
public static void Register(int cmd, bool ingame, OnPacketReceive onReceive) =>
|
||||
_handlers[cmd] = new PacketHandler(cmd, 0, ingame, onReceive);
|
||||
}
|
||||
}
|
||||
164
Projects/UOContent/Network/MapUO.cs
Normal file
164
Projects/UOContent/Network/MapUO.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright (C) 2019-2021 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: MapUO.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 System;
|
||||
using System.Buffers;
|
||||
using System.IO;
|
||||
using Server.Engines.PartySystem;
|
||||
using Server.Guilds;
|
||||
|
||||
namespace Server.Network
|
||||
{
|
||||
public static class MapUO
|
||||
{
|
||||
private static PacketHandler[] _handlers;
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
_handlers = ProtocolExtensions.Register(0xF0);
|
||||
|
||||
Register(0x00, true, QueryPartyMemberLocations);
|
||||
Register(0x01, true, QueryGuildMemberLocations);
|
||||
}
|
||||
|
||||
public static void Register(int cmd, bool ingame, OnPacketReceive onReceive) =>
|
||||
_handlers[cmd] = new PacketHandler(cmd, 0, ingame, onReceive);
|
||||
|
||||
public static void QueryGuildMemberLocations(NetState state, CircularBufferReader reader, ref int packetLength)
|
||||
{
|
||||
Mobile from = state.Mobile;
|
||||
|
||||
state.SendGuildMemberLocations(from, from.Guild as Guild, reader.ReadBoolean());
|
||||
}
|
||||
|
||||
public static void QueryPartyMemberLocations(NetState state, CircularBufferReader reader, ref int packetLength)
|
||||
{
|
||||
Mobile from = state.Mobile;
|
||||
var party = Party.Get(from);
|
||||
|
||||
if (party != null)
|
||||
{
|
||||
state.SendPartyMemberLocations(from, party);
|
||||
}
|
||||
}
|
||||
|
||||
public static void SendGuildMemberLocations(this NetState ns, Mobile from, Guild guild, bool sendLocations)
|
||||
{
|
||||
if (ns == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var count = guild?.Members.Count ?? 0;
|
||||
var maxLength = 9 + (count > 1 ? (count - 1) * (sendLocations ? 10 : 4) : 0);
|
||||
var writer = new SpanWriter(stackalloc byte[maxLength]);
|
||||
writer.Write((byte)0xF0); // Packet ID
|
||||
writer.Seek(2, SeekOrigin.Current);
|
||||
writer.Write((byte)0x02); // Command
|
||||
writer.Write(count > 0 && sendLocations);
|
||||
|
||||
bool sendPacket = false;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var m = guild!.Members[i];
|
||||
|
||||
if (m?.NetState == null || m == from)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sendLocations && Utility.InUpdateRange(from, m) && from.CanSee(m))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sendPacket = true;
|
||||
writer.Write(m.Serial);
|
||||
|
||||
if (sendLocations)
|
||||
{
|
||||
writer.Write((short)m.X);
|
||||
writer.Write((short)m.Y);
|
||||
writer.Write((byte)(m.Map?.MapID ?? 0));
|
||||
|
||||
if (m.Alive)
|
||||
{
|
||||
writer.Write((byte)(m.Hits * 100 / Math.Max(m.HitsMax, 1)));
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.Write((byte)0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sendPacket)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.Write(0);
|
||||
writer.WritePacketLength();
|
||||
ns.Send(writer.Span);
|
||||
}
|
||||
|
||||
public static void SendPartyMemberLocations(this NetState ns, Mobile from, Party party)
|
||||
{
|
||||
if (ns == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var count = party?.Members.Count ?? 0;
|
||||
var maxLength = 9 + (count > 1 ? (count - 1) * 9 : 0);
|
||||
var writer = new SpanWriter(stackalloc byte[maxLength]);
|
||||
writer.Write((byte)0xF0); // Packet ID
|
||||
writer.Seek(2, SeekOrigin.Current);
|
||||
writer.Write((byte)0x01); // Command
|
||||
|
||||
bool sendPacket = false;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var pmi = party!.Members[i];
|
||||
Mobile mob = pmi?.Mobile;
|
||||
|
||||
if (mob?.NetState == null || mob == from)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Utility.InUpdateRange(from, mob) && from.CanSee(mob))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sendPacket = true;
|
||||
writer.Write(mob.Serial);
|
||||
writer.Write((short)mob.X);
|
||||
writer.Write((short)mob.Y);
|
||||
writer.Write((byte)(mob.Map?.MapID ?? 0));
|
||||
}
|
||||
|
||||
if (!sendPacket)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.Write(0);
|
||||
writer.WritePacketLength();
|
||||
ns.Send(writer.Span);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +1,31 @@
|
|||
using System;
|
||||
using System.Buffers;
|
||||
using System.IO;
|
||||
using Server.Engines.PartySystem;
|
||||
using Server.Guilds;
|
||||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright (C) 2019-2021 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: ProtocolExtension.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/>. *
|
||||
*************************************************************************/
|
||||
|
||||
namespace Server.Network
|
||||
{
|
||||
public static class ProtocolExtensions
|
||||
{
|
||||
private static readonly PacketHandler[] _handlers = new PacketHandler[0x100];
|
||||
|
||||
public static void Configure()
|
||||
public static PacketHandler[] Register(byte packetId)
|
||||
{
|
||||
IncomingPackets.Register( 0xF0, 0, false, DecodeBundledPacket);
|
||||
Register(0x00, true, QueryPartyMemberLocations);
|
||||
Register(0x01, true, QueryGuildMemberLocations);
|
||||
}
|
||||
var packetHandlers = new PacketHandler[0x100];
|
||||
|
||||
public static void Register(int packetID, bool ingame, OnPacketReceive onReceive)
|
||||
void DecodeBundledPacket(NetState state, CircularBufferReader reader, ref int packetLength)
|
||||
{
|
||||
_handlers[packetID] = new PacketHandler(packetID, 0, ingame, onReceive);
|
||||
}
|
||||
int cmd = reader.ReadByte();
|
||||
|
||||
public static PacketHandler GetHandler(int packetID) =>
|
||||
packetID >= 0 && packetID < _handlers.Length ? _handlers[packetID] : null;
|
||||
|
||||
public static void DecodeBundledPacket(NetState state, CircularBufferReader reader, ref int packetLength)
|
||||
{
|
||||
int packetID = reader.ReadByte();
|
||||
|
||||
PacketHandler ph = GetHandler(packetID);
|
||||
PacketHandler ph = cmd >= 0 && cmd < packetHandlers.Length ? packetHandlers[cmd] : null;
|
||||
|
||||
if (ph == null)
|
||||
{
|
||||
|
|
@ -38,7 +34,7 @@ namespace Server.Network
|
|||
|
||||
if (ph.Ingame && state.Mobile == null)
|
||||
{
|
||||
state.WriteConsole("Sent in-game packet (0xBFx{0:X2}) before having been attached to a mobile", packetID);
|
||||
state.WriteConsole("Sent in-game packet (0x{0:X2}x{1:X2}) before having been attached to a mobile", packetId, cmd);
|
||||
state.Disconnect("Sent in-game packet before being attached to a mobile.");
|
||||
}
|
||||
else if (ph.Ingame && state.Mobile.Deleted)
|
||||
|
|
@ -51,129 +47,8 @@ namespace Server.Network
|
|||
}
|
||||
}
|
||||
|
||||
public static void QueryGuildMemberLocations(NetState state, CircularBufferReader reader, ref int packetLength)
|
||||
{
|
||||
Mobile from = state.Mobile;
|
||||
|
||||
state.SendGuildMemberLocations(from, from.Guild as Guild, reader.ReadBoolean());
|
||||
}
|
||||
|
||||
public static void QueryPartyMemberLocations(NetState state, CircularBufferReader reader, ref int packetLength)
|
||||
{
|
||||
Mobile from = state.Mobile;
|
||||
var party = Party.Get(from);
|
||||
|
||||
if (party != null)
|
||||
{
|
||||
state.SendPartyMemberLocations(from, party);
|
||||
}
|
||||
}
|
||||
|
||||
public static void SendGuildMemberLocations(this NetState ns, Mobile from, Guild guild, bool sendLocations)
|
||||
{
|
||||
if (ns == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var count = guild?.Members.Count ?? 0;
|
||||
var maxLength = 9 + (count > 1 ? (count - 1) * (sendLocations ? 10 : 4) : 0);
|
||||
var writer = new SpanWriter(stackalloc byte[maxLength]);
|
||||
writer.Write((byte)0xF0); // Packet ID
|
||||
writer.Seek(2, SeekOrigin.Current);
|
||||
writer.Write((byte)0x02); // Command
|
||||
writer.Write(count > 0 && sendLocations);
|
||||
|
||||
bool sendPacket = false;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var m = guild!.Members[i];
|
||||
|
||||
if (m?.NetState == null || m == from)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sendLocations && Utility.InUpdateRange(from, m) && from.CanSee(m))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sendPacket = true;
|
||||
writer.Write(m.Serial);
|
||||
|
||||
if (sendLocations)
|
||||
{
|
||||
writer.Write((short)m.X);
|
||||
writer.Write((short)m.Y);
|
||||
writer.Write((byte)(m.Map?.MapID ?? 0));
|
||||
|
||||
if (m.Alive)
|
||||
{
|
||||
writer.Write((byte)(m.Hits * 100 / Math.Max(m.HitsMax, 1)));
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.Write((byte)0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sendPacket)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.Write(0);
|
||||
writer.WritePacketLength();
|
||||
ns.Send(writer.Span);
|
||||
}
|
||||
|
||||
public static void SendPartyMemberLocations(this NetState ns, Mobile from, Party party)
|
||||
{
|
||||
if (ns == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var count = party?.Members.Count ?? 0;
|
||||
var maxLength = 9 + (count > 1 ? (count - 1) * 9 : 0);
|
||||
var writer = new SpanWriter(stackalloc byte[maxLength]);
|
||||
writer.Write((byte)0xF0); // Packet ID
|
||||
writer.Seek(2, SeekOrigin.Current);
|
||||
writer.Write((byte)0x01); // Command
|
||||
|
||||
bool sendPacket = false;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var pmi = party!.Members[i];
|
||||
Mobile mob = pmi?.Mobile;
|
||||
|
||||
if (mob?.NetState == null || mob == from)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Utility.InUpdateRange(from, mob) && from.CanSee(mob))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sendPacket = true;
|
||||
writer.Write(mob.Serial);
|
||||
writer.Write((short)mob.X);
|
||||
writer.Write((short)mob.Y);
|
||||
writer.Write((byte)(mob.Map?.MapID ?? 0));
|
||||
}
|
||||
|
||||
if (!sendPacket)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.Write(0);
|
||||
writer.WritePacketLength();
|
||||
ns.Send(writer.Span);
|
||||
IncomingPackets.Register(packetId, 0, false, DecodeBundledPacket);
|
||||
return packetHandlers;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue