fix(core): Cleans up serialization (#606)
This commit is contained in:
parent
8418d4a7db
commit
cb66bef0e5
7 changed files with 177 additions and 218 deletions
|
|
@ -19,7 +19,6 @@ using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime;
|
using System.Runtime;
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net;
|
|
||||||
|
|
||||||
namespace Server
|
namespace Server
|
||||||
{
|
{
|
||||||
|
|
@ -35,14 +34,6 @@ namespace Server
|
||||||
return intern ? Utility.Intern(str) : str;
|
return intern ? Utility.Intern(str) : str;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DateTime ReadDateTime() => new(_reader.ReadInt64(), DateTimeKind.Utc);
|
|
||||||
|
|
||||||
public TimeSpan ReadTimeSpan() => new(_reader.ReadInt64());
|
|
||||||
|
|
||||||
public DateTime ReadDeltaTime() => new(_reader.ReadInt64() + DateTime.UtcNow.Ticks, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
public decimal ReadDecimal() => _reader.ReadDecimal();
|
|
||||||
|
|
||||||
public long ReadLong() => _reader.ReadInt64();
|
public long ReadLong() => _reader.ReadInt64();
|
||||||
|
|
||||||
public ulong ReadULong() => _reader.ReadUInt64();
|
public ulong ReadULong() => _reader.ReadUInt64();
|
||||||
|
|
@ -65,43 +56,6 @@ namespace Server
|
||||||
|
|
||||||
public bool ReadBool() => _reader.ReadBoolean();
|
public bool ReadBool() => _reader.ReadBoolean();
|
||||||
|
|
||||||
public int ReadEncodedInt()
|
|
||||||
{
|
|
||||||
int v = 0, shift = 0;
|
|
||||||
byte b;
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
b = _reader.ReadByte();
|
|
||||||
v |= (b & 0x7F) << shift;
|
|
||||||
shift += 7;
|
|
||||||
}
|
|
||||||
while (b >= 0x80);
|
|
||||||
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IPAddress ReadIPAddress()
|
|
||||||
{
|
|
||||||
byte length = ReadByte();
|
|
||||||
// Either 2 ushorts, or 8 ushorts
|
|
||||||
Span<byte> integer = stackalloc byte[length];
|
|
||||||
Read(integer);
|
|
||||||
return Utility.Intern(new IPAddress(integer));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Point3D ReadPoint3D() => new(ReadInt(), ReadInt(), ReadInt());
|
|
||||||
|
|
||||||
public Point2D ReadPoint2D() => new(ReadInt(), ReadInt());
|
|
||||||
|
|
||||||
public Rectangle2D ReadRect2D() => new(ReadPoint2D(), ReadPoint2D());
|
|
||||||
|
|
||||||
public Rectangle3D ReadRect3D() => new(ReadPoint3D(), ReadPoint3D());
|
|
||||||
|
|
||||||
public Map ReadMap() => Map.Maps[ReadByte()];
|
|
||||||
|
|
||||||
public Race ReadRace() => Race.Races[ReadByte()];
|
|
||||||
|
|
||||||
public int Read(Span<byte> buffer) => _reader.Read(buffer);
|
public int Read(Span<byte> buffer) => _reader.Read(buffer);
|
||||||
|
|
||||||
public long Seek(long offset, SeekOrigin origin) => _reader.BaseStream.Seek(offset, origin);
|
public long Seek(long offset, SeekOrigin origin) => _reader.BaseStream.Seek(offset, origin);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ using System;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Server.Text;
|
using Server.Text;
|
||||||
|
|
||||||
|
|
@ -52,7 +51,7 @@ namespace Server
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var length = ReadEncodedInt();
|
var length = ((IGenericReader)this).ReadEncodedInt();
|
||||||
if (length <= 0)
|
if (length <= 0)
|
||||||
{
|
{
|
||||||
return intern ? Utility.Intern("") : "";
|
return intern ? Utility.Intern("") : "";
|
||||||
|
|
@ -63,14 +62,6 @@ namespace Server
|
||||||
return intern ? Utility.Intern(str) : str;
|
return intern ? Utility.Intern(str) : str;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc);
|
|
||||||
|
|
||||||
public TimeSpan ReadTimeSpan() => new(ReadLong());
|
|
||||||
|
|
||||||
public DateTime ReadDeltaTime() => new(ReadLong() + DateTime.UtcNow.Ticks, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
public decimal ReadDecimal() => new(stackalloc int[4] { ReadInt(), ReadInt(), ReadInt(), ReadInt() });
|
|
||||||
|
|
||||||
public long ReadLong()
|
public long ReadLong()
|
||||||
{
|
{
|
||||||
var v = BinaryPrimitives.ReadInt64LittleEndian(_buffer.AsSpan(_position, 8));
|
var v = BinaryPrimitives.ReadInt64LittleEndian(_buffer.AsSpan(_position, 8));
|
||||||
|
|
@ -133,42 +124,6 @@ namespace Server
|
||||||
|
|
||||||
public bool ReadBool() => _buffer[_position++] != 0;
|
public bool ReadBool() => _buffer[_position++] != 0;
|
||||||
|
|
||||||
public int ReadEncodedInt()
|
|
||||||
{
|
|
||||||
int v = 0, shift = 0;
|
|
||||||
byte b;
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
b = ReadByte();
|
|
||||||
v |= (b & 0x7F) << shift;
|
|
||||||
shift += 7;
|
|
||||||
} while (b >= 0x80);
|
|
||||||
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IPAddress ReadIPAddress()
|
|
||||||
{
|
|
||||||
byte length = ReadByte();
|
|
||||||
// Either 2 ushorts, or 8 ushorts
|
|
||||||
Span<byte> integer = stackalloc byte[length];
|
|
||||||
Read(integer);
|
|
||||||
return Utility.Intern(new IPAddress(integer));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Point3D ReadPoint3D() => new(ReadInt(), ReadInt(), ReadInt());
|
|
||||||
|
|
||||||
public Point2D ReadPoint2D() => new(ReadInt(), ReadInt());
|
|
||||||
|
|
||||||
public Rectangle2D ReadRect2D() => new(ReadPoint2D(), ReadPoint2D());
|
|
||||||
|
|
||||||
public Rectangle3D ReadRect3D() => new(ReadPoint3D(), ReadPoint3D());
|
|
||||||
|
|
||||||
public Map ReadMap() => Map.Maps[ReadByte()];
|
|
||||||
|
|
||||||
public Race ReadRace() => Race.Races[ReadByte()];
|
|
||||||
|
|
||||||
public int Read(Span<byte> buffer)
|
public int Read(Span<byte> buffer)
|
||||||
{
|
{
|
||||||
var length = buffer.Length;
|
var length = buffer.Length;
|
||||||
|
|
|
||||||
|
|
@ -158,19 +158,6 @@ namespace Server
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void WriteEncodedInt(int value)
|
|
||||||
{
|
|
||||||
var v = (uint)value;
|
|
||||||
|
|
||||||
while (v >= 0x80)
|
|
||||||
{
|
|
||||||
Write((byte)(v | 0x80));
|
|
||||||
v >>= 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
Write((byte)v);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Write(string value)
|
public void Write(string value)
|
||||||
{
|
{
|
||||||
if (m_PrefixStrings)
|
if (m_PrefixStrings)
|
||||||
|
|
@ -191,56 +178,6 @@ namespace Server
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Write(DateTime value)
|
|
||||||
{
|
|
||||||
var ticks = (value.Kind switch
|
|
||||||
{
|
|
||||||
DateTimeKind.Local => value.ToUniversalTime(),
|
|
||||||
DateTimeKind.Unspecified => value.ToLocalTime().ToUniversalTime(),
|
|
||||||
_ => value
|
|
||||||
}).Ticks;
|
|
||||||
|
|
||||||
Write(ticks);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Find a way to speed this up.
|
|
||||||
// Maybe used a special cached value?
|
|
||||||
public void WriteDeltaTime(DateTime value)
|
|
||||||
{
|
|
||||||
var ticks = (value.Kind switch
|
|
||||||
{
|
|
||||||
DateTimeKind.Local => value.ToUniversalTime(),
|
|
||||||
DateTimeKind.Unspecified => value.ToLocalTime().ToUniversalTime(),
|
|
||||||
_ => value
|
|
||||||
}).Ticks;
|
|
||||||
|
|
||||||
// Technically supports negative deltas for times in the past
|
|
||||||
Write(ticks - DateTime.UtcNow.Ticks);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Write(IPAddress value)
|
|
||||||
{
|
|
||||||
Span<byte> stack = stackalloc byte[16];
|
|
||||||
value.TryWriteBytes(stack, out var bytesWritten);
|
|
||||||
Write((byte)bytesWritten);
|
|
||||||
Write(stack[..bytesWritten]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Write(TimeSpan value)
|
|
||||||
{
|
|
||||||
Write(value.Ticks);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Write(decimal value)
|
|
||||||
{
|
|
||||||
var bits = decimal.GetBits(value);
|
|
||||||
|
|
||||||
for (var i = 0; i < 4; ++i)
|
|
||||||
{
|
|
||||||
Write(bits[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Write(long value)
|
public void Write(long value)
|
||||||
{
|
{
|
||||||
FlushIfNeeded(8);
|
FlushIfNeeded(8);
|
||||||
|
|
@ -350,46 +287,11 @@ namespace Server
|
||||||
_buffer[Index++] = *(byte*)&value; // up to 30% faster to dereference the raw value on the stack
|
_buffer[Index++] = *(byte*)&value; // up to 30% faster to dereference the raw value on the stack
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Write(Point3D value)
|
|
||||||
{
|
|
||||||
Write(value.m_X);
|
|
||||||
Write(value.m_Y);
|
|
||||||
Write(value.m_Z);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Write(Point2D value)
|
|
||||||
{
|
|
||||||
Write(value.m_X);
|
|
||||||
Write(value.m_Y);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Write(Rectangle2D value)
|
|
||||||
{
|
|
||||||
Write(value.Start);
|
|
||||||
Write(value.End);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Write(Rectangle3D value)
|
|
||||||
{
|
|
||||||
Write(value.Start);
|
|
||||||
Write(value.End);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Write(Map value)
|
|
||||||
{
|
|
||||||
Write((byte)(value?.MapIndex ?? 0xFF));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Write(Race value)
|
|
||||||
{
|
|
||||||
Write((byte)(value?.RaceIndex ?? 0xFF));
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void InternalWriteString(string value)
|
internal void InternalWriteString(string value)
|
||||||
{
|
{
|
||||||
var remaining = m_Encoding.GetByteCount(value);
|
var remaining = m_Encoding.GetByteCount(value);
|
||||||
|
|
||||||
WriteEncodedInt(remaining);
|
((IGenericWriter)this).WriteEncodedInt(remaining);
|
||||||
|
|
||||||
if (remaining == 0)
|
if (remaining == 0)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,6 @@ namespace Server
|
||||||
public interface IGenericReader
|
public interface IGenericReader
|
||||||
{
|
{
|
||||||
string ReadString(bool intern = false);
|
string ReadString(bool intern = false);
|
||||||
DateTime ReadDateTime();
|
|
||||||
TimeSpan ReadTimeSpan();
|
|
||||||
DateTime ReadDeltaTime();
|
|
||||||
decimal ReadDecimal();
|
|
||||||
long ReadLong();
|
long ReadLong();
|
||||||
ulong ReadULong();
|
ulong ReadULong();
|
||||||
int ReadInt();
|
int ReadInt();
|
||||||
|
|
@ -37,15 +33,69 @@ namespace Server
|
||||||
byte ReadByte();
|
byte ReadByte();
|
||||||
sbyte ReadSByte();
|
sbyte ReadSByte();
|
||||||
bool ReadBool();
|
bool ReadBool();
|
||||||
int ReadEncodedInt();
|
|
||||||
IPAddress ReadIPAddress();
|
DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc);
|
||||||
Point3D ReadPoint3D();
|
TimeSpan ReadTimeSpan() => new(ReadLong());
|
||||||
Point2D ReadPoint2D();
|
DateTime ReadDeltaTime() => new(ReadLong() + DateTime.UtcNow.Ticks, DateTimeKind.Utc);
|
||||||
Rectangle2D ReadRect2D();
|
decimal ReadDecimal() => new(stackalloc int[4] { ReadInt(), ReadInt(), ReadInt(), ReadInt() });
|
||||||
Rectangle3D ReadRect3D();
|
int ReadEncodedInt()
|
||||||
Map ReadMap();
|
{
|
||||||
Race ReadRace();
|
int v = 0, shift = 0;
|
||||||
|
byte b;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
b = ReadByte();
|
||||||
|
v |= (b & 0x7F) << shift;
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
while (b >= 0x80);
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
IPAddress ReadIPAddress()
|
||||||
|
{
|
||||||
|
byte length = ReadByte();
|
||||||
|
// Either 2 ushorts, or 8 ushorts
|
||||||
|
Span<byte> integer = stackalloc byte[length];
|
||||||
|
Read(integer);
|
||||||
|
return Utility.Intern(new IPAddress(integer));
|
||||||
|
}
|
||||||
|
Point3D ReadPoint3D() => new(ReadInt(), ReadInt(), ReadInt());
|
||||||
|
Point2D ReadPoint2D() => new(ReadInt(), ReadInt());
|
||||||
|
Rectangle2D ReadRect2D() => new(ReadPoint2D(), ReadPoint2D());
|
||||||
|
Rectangle3D ReadRect3D() => new(ReadPoint3D(), ReadPoint3D());
|
||||||
|
Map ReadMap() => Map.Maps[ReadByte()];
|
||||||
|
Race ReadRace() => Race.Races[ReadByte()];
|
||||||
int Read(Span<byte> buffer);
|
int Read(Span<byte> buffer);
|
||||||
|
unsafe T ReadEnum<T>() where T : unmanaged, Enum
|
||||||
|
{
|
||||||
|
switch (ReadByte())
|
||||||
|
{
|
||||||
|
case 1:
|
||||||
|
{
|
||||||
|
var num = ReadByte();
|
||||||
|
return *(T*)#
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
{
|
||||||
|
var num = ReadShort();
|
||||||
|
return *(T*)#
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
{
|
||||||
|
var num = ReadEncodedInt();
|
||||||
|
return *(T*)#
|
||||||
|
}
|
||||||
|
case 4:
|
||||||
|
{
|
||||||
|
var num = ReadLong();
|
||||||
|
return *(T*)#
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
long Seek(long offset, SeekOrigin origin);
|
long Seek(long offset, SeekOrigin origin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,6 @@ namespace Server
|
||||||
long Position { get; }
|
long Position { get; }
|
||||||
void Close();
|
void Close();
|
||||||
void Write(string value);
|
void Write(string value);
|
||||||
void Write(DateTime value);
|
|
||||||
void Write(TimeSpan value);
|
|
||||||
void Write(decimal value);
|
|
||||||
void Write(long value);
|
void Write(long value);
|
||||||
void Write(ulong value);
|
void Write(ulong value);
|
||||||
void Write(int value);
|
void Write(int value);
|
||||||
|
|
@ -38,17 +35,120 @@ namespace Server
|
||||||
void Write(byte value);
|
void Write(byte value);
|
||||||
void Write(sbyte value);
|
void Write(sbyte value);
|
||||||
void Write(bool value);
|
void Write(bool value);
|
||||||
void WriteEncodedInt(int value);
|
|
||||||
void Write(IPAddress value);
|
|
||||||
void WriteDeltaTime(DateTime value);
|
|
||||||
void Write(Point3D value);
|
|
||||||
void Write(Point2D value);
|
|
||||||
void Write(Rectangle2D value);
|
|
||||||
void Write(Rectangle3D value);
|
|
||||||
void Write(Map value);
|
|
||||||
void Write(Race value);
|
|
||||||
void Write(ReadOnlySpan<byte> bytes);
|
|
||||||
|
|
||||||
|
void Write(DateTime value)
|
||||||
|
{
|
||||||
|
var ticks = (value.Kind switch
|
||||||
|
{
|
||||||
|
DateTimeKind.Local => value.ToUniversalTime(),
|
||||||
|
DateTimeKind.Unspecified => value.ToLocalTime().ToUniversalTime(),
|
||||||
|
_ => value
|
||||||
|
}).Ticks;
|
||||||
|
|
||||||
|
Write(ticks);
|
||||||
|
}
|
||||||
|
void WriteDeltaTime(DateTime value)
|
||||||
|
{
|
||||||
|
var ticks = (value.Kind switch
|
||||||
|
{
|
||||||
|
DateTimeKind.Local => value.ToUniversalTime(),
|
||||||
|
DateTimeKind.Unspecified => value.ToLocalTime().ToUniversalTime(),
|
||||||
|
_ => value
|
||||||
|
}).Ticks;
|
||||||
|
|
||||||
|
// Technically supports negative deltas for times in the past
|
||||||
|
Write(ticks - DateTime.UtcNow.Ticks);
|
||||||
|
}
|
||||||
|
void Write(IPAddress value)
|
||||||
|
{
|
||||||
|
Span<byte> stack = stackalloc byte[16];
|
||||||
|
value.TryWriteBytes(stack, out var bytesWritten);
|
||||||
|
Write((byte)bytesWritten);
|
||||||
|
Write(stack[..bytesWritten]);
|
||||||
|
}
|
||||||
|
void Write(TimeSpan value)
|
||||||
|
{
|
||||||
|
Write(value.Ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Write(decimal value)
|
||||||
|
{
|
||||||
|
var bits = decimal.GetBits(value);
|
||||||
|
|
||||||
|
for (var i = 0; i < 4; ++i)
|
||||||
|
{
|
||||||
|
Write(bits[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void WriteEncodedInt(int value)
|
||||||
|
{
|
||||||
|
var v = (uint)value;
|
||||||
|
|
||||||
|
while (v >= 0x80)
|
||||||
|
{
|
||||||
|
Write((byte)(v | 0x80));
|
||||||
|
v >>= 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
Write((byte)v);
|
||||||
|
}
|
||||||
|
void Write(Point3D value)
|
||||||
|
{
|
||||||
|
Write(value.m_X);
|
||||||
|
Write(value.m_Y);
|
||||||
|
Write(value.m_Z);
|
||||||
|
}
|
||||||
|
void Write(Point2D value)
|
||||||
|
{
|
||||||
|
Write(value.m_X);
|
||||||
|
Write(value.m_Y);
|
||||||
|
}
|
||||||
|
void Write(Rectangle2D value)
|
||||||
|
{
|
||||||
|
Write(value.Start);
|
||||||
|
Write(value.End);
|
||||||
|
}
|
||||||
|
void Write(Rectangle3D value)
|
||||||
|
{
|
||||||
|
Write(value.Start);
|
||||||
|
Write(value.End);
|
||||||
|
}
|
||||||
|
void Write(Map value) => Write((byte)(value?.MapIndex ?? 0xFF));
|
||||||
|
void Write(Race value) => Write((byte)(value?.RaceIndex ?? 0xFF));
|
||||||
|
void Write(ReadOnlySpan<byte> bytes);
|
||||||
|
unsafe void Write<T>(T value) where T : unmanaged, Enum
|
||||||
|
{
|
||||||
|
var size = sizeof(T);
|
||||||
|
|
||||||
|
switch (size)
|
||||||
|
{
|
||||||
|
default: throw new ArgumentException($"Argument of type {typeof(T)} is not a normal enum");
|
||||||
|
case 1:
|
||||||
|
{
|
||||||
|
Write((byte)1);
|
||||||
|
Write(*(byte*)&value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
{
|
||||||
|
Write((byte)2);
|
||||||
|
Write(*(ushort*)&value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 4:
|
||||||
|
{
|
||||||
|
Write((byte)3);
|
||||||
|
WriteEncodedInt(*(int*)&value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 8:
|
||||||
|
{
|
||||||
|
Write((byte)4);
|
||||||
|
Write(*(ulong*)&value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
long Seek(long offset, SeekOrigin origin);
|
long Seek(long offset, SeekOrigin origin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Server.Text;
|
|
||||||
|
|
||||||
namespace Server
|
namespace Server
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue