Skip to content

Instantly share code, notes, and snippets.

@jungrok5
Created April 5, 2016 16:16
Show Gist options
  • Save jungrok5/0ffce72d65609f74127ec1f674e3fb3f to your computer and use it in GitHub Desktop.
Save jungrok5/0ffce72d65609f74127ec1f674e3fb3f to your computer and use it in GitHub Desktop.
using GMTool.Sources.Config;
using GMTool.Sources.Data;
using GMTool.Sources.Network;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Web;
namespace GMTool.Sources.Util
{
public static class GMToolNetwork
{
private static string szManagerServerIP;
private static int dwManagerServerPort;
private static List<OneTimeSession> sessionList = new List<OneTimeSession>();
public static void SetCurrentManagerServer(string ip, int port)
{
szManagerServerIP = ip;
dwManagerServerPort = port;
}
// 매니저 서버로 부터 응답을 받아야 하는 경우
public static async Task SendToManagerServer(GMTool.Sources.GMToolDefine.GMToolCommand commandType, Packet packet, OneTimeSession.PacketHandler recvHandler)
{
OneTimeSession session = new OneTimeSession(recvHandler);
sessionList.Add(session);
try
{
using (Packet sendPacket = Packet.Alloc(CSProtocolID.CS_GM_TOOL_COMMAND_REQ))
{
sendPacket.WriteString(GMToolConfig.Instance.Config.GMToolCertificateKey);
sendPacket.WriteByte((byte)commandType);
sendPacket.WriteAppendData(packet);
await session.Send(szManagerServerIP, dwManagerServerPort, sendPacket);
}
}
catch (Exception e)
{
sessionList.Remove(session);
session.PacketCallback -= recvHandler;
session.Dispose();
throw e;
}
}
// 매니저 서버로 그냥 보내기만 하는 경우
public static async Task SendToManagerServer(GMTool.Sources.GMToolDefine.GMToolCommand commandType, Packet packet)
{
using (Packet sendPacket = Packet.Alloc(CSProtocolID.CS_GM_TOOL_COMMAND_REQ))
{
sendPacket.WriteString(GMToolConfig.Instance.Config.GMToolCertificateKey);
sendPacket.WriteByte((byte)commandType);
sendPacket.WriteAppendData(packet);
using (OneTimeSession session = new OneTimeSession())
{
await session.Send(szManagerServerIP, dwManagerServerPort, sendPacket);
}
}
}
public static void PacketHandler(OneTimeSession session, Packet packet)
{
sessionList.Remove(session);
session.PacketCallback -= PacketHandler;
session.Dispose();
if (packet.GetID() != (ushort)CSProtocolID.CS_GM_TOOL_COMMAND_ACK)
return;
GMToolDefine.GMToolCommand commandType;
byte bCommandType = packet.ReadByte();
commandType = (GMToolDefine.GMToolCommand)bCommandType;
switch (commandType)
{
case GMToolDefine.GMToolCommand.GetServerStatus:
{
WorldInfoMgr.Instance.PopStatusPacket(packet);
}
break;
case GMToolDefine.GMToolCommand.GetServerCountInfo:
{
short wServerID = packet.ReadShort();
byte bWorldID = packet.ReadByte();
short wServerType = packet.ReadShort();
ServerInfo serverInfo = null;
if (wServerType == (short)GMToolDefine.SERVER_TYPE.AuthServer)
{
serverInfo = WorldInfoMgr.Instance.GetAuthServer(wServerID);
}
else
{
serverInfo = WorldInfoMgr.Instance.GetServer(wServerID);
}
if (serverInfo != null)
{
CountInfoMgr countMgr = new CountInfoMgr();
countMgr.PopInfoPacket(packet);
if (serverInfo.CountInfoMgrQueue.Count() >= 10)
serverInfo.CountInfoMgrQueue.Dequeue();
serverInfo.CountInfoMgrQueue.Enqueue(countMgr);
}
}
break;
}
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace GMTool.Sources.Util
{
// http://msdn.microsoft.com/ko-kr/library/ff458671(v=vs.110).aspx
// http://msdn.microsoft.com/ko-kr/library/fs2xkftw(v=vs.80).aspx
// http://msdn.microsoft.com/ko-kr/library/8edha89s.aspx
// http://msdn.microsoft.com/ko-kr/library/xhbhezf4.aspx
// https://gist.github.com/ujentus/4001356
// http://www.simpleisbest.net/category/NET-Framework-General.aspx
// http://kookiandkiki.blogspot.kr/2014/01/c-weakreference-idisosalbe.html
public abstract class ObjectPool<T> : IDisposable
where T : ObjectPool<T>, new()
{
public static readonly int AUTO_INCREASE = -1;
public static readonly int NOT_USE_POOL = 0;
private static ThreadSafeQueue<T> pool;
private static Func<T> generator;
private static Func<T, T> destroyer;
protected bool createdByPool = false;
public static void CreatePool(int poolSize = -1, Func<T> generatorFunc = null, Func<T, T> destroyerFunc = null)
{
generator = generatorFunc;
if (generator == null)
generator = () => { return new T(); };
destroyer = destroyerFunc;
if (destroyer == null)
destroyer = (e) =>
{
e.ReUse();
return e;
};
if (poolSize != NOT_USE_POOL)
{
pool = new ThreadSafeQueue<T>();
if (poolSize != AUTO_INCREASE)
{
PreAlloc(poolSize);
}
}
}
private static void PreAlloc(int count)
{
for (int i = 0; i < count; ++i)
{
T item = generator();
item.createdByPool = true;
Free(item);
}
}
public static T Alloc()
{
//if (generator == null)
// return new T();
if (pool != null)
{
T item = pool.Dequeue();
if (item != null)
return item;
}
if (generator == null) throw new ArgumentNullException("generator", "ObjectPool<T>.CreatePool 함수를 통해서 풀을 먼저 생성하셔야 합니다.");
T newItem = generator();
newItem.createdByPool = true;
return newItem;
}
public static void Free(T item)
{
if (destroyer != null)
destroyer(item);
if (pool != null && item.IsCreatedByPool() == true)
pool.Enqueue(item);
}
public static void Clear()
{
pool.Clear();
generator = null;
destroyer = null;
}
public static int PoolCount()
{
return pool.Count();
}
// 풀에 의해 생성되었느냐
public bool IsCreatedByPool()
{
return createdByPool;
}
public abstract void Dispose(bool disposing);
public abstract void ReUse();
~ObjectPool()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
}
using GMTool.Sources.Network;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
namespace GMTool.Sources.Util
{
public class OneTimeSession : IDisposable
{
public delegate void ErrorHandler();
public delegate void PacketHandler(OneTimeSession session, Packet packet);
public ErrorHandler ErrorCallback;
public PacketHandler PacketCallback;
private SocketAsyncEventArgs sendSAEA;
private SocketAsyncEventArgs recvSAEA;
private byte[] recvBuffer = new byte[4096];
private byte[] m_Buffer;
private int m_BufferPos = 0;
private TcpClient client;
public OneTimeSession()
{
sendSAEA = new SocketAsyncEventArgs();
sendSAEA.Completed += new EventHandler<SocketAsyncEventArgs>(CompletedSend);
sendSAEA.UserToken = this;
recvSAEA = new SocketAsyncEventArgs();
recvSAEA.Completed += new EventHandler<SocketAsyncEventArgs>(CompletedReceive);
recvSAEA.UserToken = this;
recvSAEA.SetBuffer(recvBuffer, 0, 4096);
m_Buffer = new byte[8192];
}
public OneTimeSession(PacketHandler callback) : this()
{
PacketCallback = callback;
}
~OneTimeSession()
{
Dispose(false);
}
public void Dispose(bool disposing)
{
// 관리되는 자원들 해제
if (disposing)
{
}
if (sendSAEA != null)
{
sendSAEA.UserToken = null;
sendSAEA.Completed -= CompletedSend;
sendSAEA = null;
}
if (recvSAEA != null)
{
recvSAEA.UserToken = null;
recvSAEA.Completed -= CompletedReceive;
recvSAEA = null;
}
if (client != null)
{
client.Close();
client = null;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
public async Task Send(string ip, int port, byte[] data, int offset, int length)
{
client = new TcpClient();
await client.ConnectAsync(ip, port);
if (client.Connected == false)
{
if (ErrorCallback != null)
ErrorCallback();
return;
}
StartReceive(recvSAEA);
sendSAEA.SetBuffer(data, offset, length);
bool pending = client.Client.SendAsync(sendSAEA);
if (pending == false)
{
ProcessSend(sendSAEA);
}
}
public async Task Send(string ip, int port, Packet packet)
{
client = new TcpClient();
await client.ConnectAsync(ip, port);
if (client.Connected == false)
{
if (ErrorCallback != null)
ErrorCallback();
return;
}
sendSAEA.SetBuffer(packet.GetData(), 0, packet.GetTotalPacketSize());
bool pending = client.Client.SendAsync(sendSAEA);
if (pending == false)
{
ProcessSend(sendSAEA);
}
}
private void OnReceived(byte[] buffer, int offset, int length)
{
if (length >= m_Buffer.Length || m_BufferPos + length >= m_Buffer.Length)
return;
Array.Copy(buffer, offset, m_Buffer, m_BufferPos, length);
m_BufferPos += length;
if (m_BufferPos < Packet.HEADER_SIZE)
return;
int remainSize = m_BufferPos;
int readOffset = 0;
while (remainSize > 0)
{
if (readOffset + Packet.HEADER_SIZE > m_BufferPos)
break;
ushort packetSize = (ushort)(BitConverter.ToUInt16(m_Buffer, readOffset + Packet.PACKET_SIZE_OFFSET) + Packet.HEADER_SIZE);
if (packetSize < 0 || packetSize >= Packet.BUFFER_SIZE)
return;
if (packetSize > remainSize)
break;
Packet recvPacket = new Packet(buffer, offset, length);
if (PacketCallback != null)
PacketCallback(this, recvPacket);
// 일회용 세션이니 한개가 조합되면 끊어버리자
Disconnect();
return;
//remainSize -= packetSize;
//readOffset += packetSize;
}
if (readOffset > 0)
{
if (m_BufferPos > readOffset)
{
Array.Copy(m_Buffer, readOffset, m_Buffer, 0, m_BufferPos - readOffset);
m_BufferPos -= readOffset;
}
else
{
m_BufferPos = 0;
}
}
}
private bool IsConnected()
{
if (client == null || client.Connected == false)
return false;
return true;
}
private void Disconnect()
{
if (client != null)
{
client.Close();
client = null;
}
}
private void ProcessSend(SocketAsyncEventArgs e)
{
if (IsConnected() == false)
return;
e.UserToken = null;
if (e.SocketError != SocketError.Success)
{
Disconnect();
return;
}
StartReceive(recvSAEA);
}
private void StartReceive(SocketAsyncEventArgs e)
{
if (IsConnected() == false)
return;
try
{
bool pending = client.Client.ReceiveAsync(e);
if (pending == false)
{
ProcessReceive(e);
}
}
catch (Exception ex)
{
if (ErrorCallback != null)
ErrorCallback();
Disconnect();
return;
}
}
private void ProcessReceive(SocketAsyncEventArgs e)
{
if (e.BytesTransferred > 0 && e.SocketError == SocketError.Success)
{
OnReceived(e.Buffer, e.Offset, e.BytesTransferred);
}
else
{
Disconnect();
return;
}
StartReceive(e);
}
private void CompletedReceive(object sender, SocketAsyncEventArgs e)
{
var session = e.UserToken as OneTimeSession;
if (session == null)
return;
if (e.LastOperation != SocketAsyncOperation.Receive)
throw new ArgumentException(string.Format("Invalid LastOperation:{0}", e.LastOperation));
session.ProcessReceive(e);
}
private void CompletedSend(object sender, SocketAsyncEventArgs e)
{
var session = e.UserToken as OneTimeSession;
if (session == null)
return;
if (e.LastOperation != SocketAsyncOperation.Send)
throw new ArgumentException(string.Format("Invalid LastOperation:{0}", e.LastOperation));
session.ProcessSend(e);
}
}
}
using GMTool.Sources.Util;
using System;
using System.Collections;
using System.Text;
namespace GMTool.Sources.Network
{
public partial class Packet : ObjectPool<Packet>
{
public static readonly ushort BUFFER_SIZE = 8192;
public static readonly ushort HEADER_SIZE = 4;
public static readonly ushort ID_OFFSET = 0;
public static readonly ushort PACKET_SIZE_OFFSET = 2;
public ushort ID { get; private set; }
private ushort m_wPacketSize;
private ushort m_wReadOffSet;
private ushort m_wWriteOffSet;
private byte[] m_Data;
private ushort m_wPacketLen;
public bool HasError { get; private set; }
public static Packet AllocNoID()
{
return Alloc((ushort)0);
}
public static Packet Alloc(CSProtocolID id)
{
return Alloc((ushort)id);
}
public static Packet Alloc(byte[] data, int offset, int length)
{
Packet packet = Alloc();
packet.CopyData(data, offset, length);
return packet;
}
public static Packet Alloc(ushort id)
{
Packet packet = Alloc();
packet.SetID(id);
return packet;
}
public Packet()
{
HasError = false;
m_wPacketSize = 0;
m_wReadOffSet = 0;
m_wWriteOffSet = 0;
}
public Packet(ushort id) : this()
{
// 여기로 들어오는 경우는 풀이 아닌 직접 생성자로 생성하는 경우다
SetID(id);
}
public Packet(CSProtocolID id)
: this((ushort)id)
{
}
public Packet(byte[] data, int offset, int length) : this()
{
// 여기로 들어오는 경우는 풀이 아닌 직접 생성자로 생성하는 경우다
CopyData(data, offset, length);
}
public void CopyData(byte[] data, int offset, int length)
{
if (data == null || length < HEADER_SIZE || length >= BUFFER_SIZE)
{
HasError = true;
return;
}
ID = BitConverter.ToUInt16(data, offset + ID_OFFSET);
m_wPacketSize = BitConverter.ToUInt16(data, offset + PACKET_SIZE_OFFSET);
if (data.Length < (int)(offset + HEADER_SIZE + m_wPacketSize)) //이상하게 패킷이 오면 응급처리를 한다.
{
m_wPacketSize = (ushort)(length - 4);
}
// 풀에 의해 생성되었다면 패킷버퍼 사이즈가 서로 상이하면 사용할때 문제가 되므로
// 동일한 사이즈로 하기 위해
if (IsCreatedByPool() == true)
{
m_Data = new byte[BUFFER_SIZE];
m_wPacketLen = (ushort)m_Data.Length;
}
else
{
m_Data = new byte[length];
m_wPacketLen = (ushort)length;
}
Array.Copy(data, offset, m_Data, 0, length);
}
private void CreateBuffer()
{
m_Data = new byte[BUFFER_SIZE];
m_wPacketLen = (ushort)m_Data.Length;
WritePacketSize(0);
}
public override void Dispose(bool disposing)
{
Free(this);
}
public override void ReUse()
{
ID = 0;
m_wPacketSize = 0;
m_wReadOffSet = 0;
m_wWriteOffSet = 0;
if (m_Data != null)
{
Array.Clear(m_Data, 0, m_Data.Length);
m_wPacketLen = (ushort)m_Data.Length;
}
HasError = false;
}
public void WriteRemainData(Packet packet)
{
Write(packet.GetData(), HEADER_SIZE + packet.m_wReadOffSet, packet.GetRemainDataSize());
packet.m_wReadOffSet += packet.GetRemainDataSize();
}
public void WriteAppendData(Packet packet)
{
Write(packet.GetData(), HEADER_SIZE + packet.m_wReadOffSet, packet.GetRemainDataSize());
}
public void WriteAppendData(Packet packet, int offset, ushort length)
{
Write(packet.GetData(), offset, length);
}
public ushort GetRemainDataSize()
{
return (ushort)(m_wPacketSize - m_wReadOffSet);
}
public void FinishReadData()
{
m_wReadOffSet = m_wPacketSize;
}
public void Reset()
{
m_wReadOffSet = 0;
}
public void DecreaseReadOffset(ushort offset)
{
if (m_wReadOffSet > offset)
m_wReadOffSet -= offset;
else
m_wReadOffSet = 0;
}
public void IncreaseReadOffset(ushort offset)
{
m_wReadOffSet += offset;
}
public void SetReadOffset(ushort offset)
{
m_wReadOffSet = offset;
}
public ushort GetReadOffset()
{
return m_wReadOffSet;
}
public ushort GetID() { return ID; }
public void SetID(CSProtocolID id)
{
SetID((ushort)id);
}
public void SetID(ushort id)
{
if (m_Data == null)
{
CreateBuffer();
}
ID = id;
WriteID(id);
}
public byte[] GetData() { return m_Data; }
public void SetData(byte[] data)
{
Array.Copy(data, 0, m_Data, HEADER_SIZE, data.Length);
WritePacketSize((ushort)data.Length);
}
public ushort GetPacketSize() { return m_wPacketSize; }
public ushort GetTotalPacketSize()
{
return (ushort)(HEADER_SIZE + m_wPacketSize);
}
private void WriteID(ushort id)
{
if (m_Data == null)
m_Data = new byte[BUFFER_SIZE];
byte[] data = BitConverter.GetBytes(id);
data.CopyTo(m_Data, ID_OFFSET);
}
private void WritePacketSize(ushort wPacketSize)
{
byte[] data = BitConverter.GetBytes(wPacketSize);
data.CopyTo(m_Data, PACKET_SIZE_OFFSET);
}
public bool ValidWriteSize(ushort size)
{
if (HEADER_SIZE + m_wWriteOffSet + size > m_wPacketLen)
{
HasError = true;
return false;
}
return true;
}
public bool ValidReadSize(ushort size)
{
if (HEADER_SIZE + m_wReadOffSet + size > m_wPacketLen)
{
HasError = true;
return false;
}
return true;
}
private void Write(byte data)
{
if (ValidWriteSize(sizeof(byte)) == false)
return;
m_Data[HEADER_SIZE + m_wWriteOffSet] = data;
m_wWriteOffSet += sizeof(byte);
m_wPacketSize += sizeof(byte);
WritePacketSize(m_wPacketSize);
}
private void Write(byte[] data, int offset, int length)
{
if (ValidWriteSize((ushort)length) == false)
return;
if (data == null || data.Length < offset + length)
{
HasError = true;
return;
}
if (BitConverter.IsLittleEndian == false)
{
Array.Reverse(data, offset, length);
}
Array.Copy(data, offset, m_Data, HEADER_SIZE + m_wWriteOffSet, length);
m_wWriteOffSet += (ushort)length;
m_wPacketSize += (ushort)length;
WritePacketSize(m_wPacketSize);
}
private void Write(byte[] data)
{
if (ValidWriteSize((ushort)data.Length) == false)
return;
if (BitConverter.IsLittleEndian == false)
{
Array.Reverse(data);
}
data.CopyTo(m_Data, HEADER_SIZE + m_wWriteOffSet);
m_wWriteOffSet += (ushort)data.Length;
m_wPacketSize += (ushort)data.Length;
WritePacketSize(m_wPacketSize);
}
private byte[] Read(int offset, ushort length)
{
if (ValidReadSize(length) == false)
return null;
byte[] data = new byte[length];
Array.Copy(m_Data, offset, data, 0, length);
if (BitConverter.IsLittleEndian == false)
{
Array.Reverse(data);
}
m_wReadOffSet += length;
return data;
}
public void WriteULong(ulong data) { Write(BitConverter.GetBytes(data)); }
public void WriteUInt(uint data) { Write(BitConverter.GetBytes(data)); }
public void WriteUShort(ushort data) { Write(BitConverter.GetBytes(data)); }
public void WriteDouble(double data) { Write(BitConverter.GetBytes(data)); }
public void WriteFloat(float data) { Write(BitConverter.GetBytes(data)); }
public void WriteByte(byte data) { Write(data); }
public void WriteBool(bool data) { Write(BitConverter.GetBytes(data)); }
public void WriteShort(short data) { Write(BitConverter.GetBytes(data)); }
public void WriteLong(long data) { Write(BitConverter.GetBytes(data)); }
public void WriteInt(int data) { Write(BitConverter.GetBytes(data)); }
public void WriteString(string data)
{
if (string.IsNullOrEmpty(data) == false)
{
//data = data.Trim();
byte[] ConvData = System.Text.Encoding.Unicode.GetBytes(data);
if (ConvData.Length >= 1000)
{
HasError = true;
return;
}
if (ValidWriteSize((ushort)ConvData.Length) == false)
return;
WriteUShort((ushort)ConvData.Length);
Write(ConvData);
}
else
{
WriteUShort(0);
}
}
public void WriteByteData(byte[] data)
{
// 문자열을 넘길땐 data를 Encoding.ASCII.GetBytes 변환 사용할것
if (data != null)
{
WriteUShort((ushort)data.Length);
Write(data);
}
else
{
WriteUShort(0);
}
}
public void WriteDateTime(DateTime data)
{
Write(BitConverter.GetBytes(data.ToBinary()));
}
public void WriteDateTimeYMDHMS(DateTime data)
{
WriteUShort((ushort)data.Year);
WriteUShort((ushort)data.Month);
WriteUShort((ushort)data.Day);
WriteUShort((ushort)data.Hour);
WriteUShort((ushort)data.Minute);
WriteUShort((ushort)data.Second);
}
public byte ReadByte()
{
if (ValidReadSize(sizeof(byte)) == false)
return 0;
byte read = m_Data[HEADER_SIZE + m_wReadOffSet];
m_wReadOffSet += sizeof(byte);
return read;
}
public ulong ReadULong()
{
if (ValidReadSize(sizeof(ulong)) == false)
return 0;
ulong read = BitConverter.ToUInt64(m_Data, HEADER_SIZE + m_wReadOffSet);
m_wReadOffSet += sizeof(ulong);
return read;
}
public uint ReadUInt()
{
if (ValidReadSize(sizeof(uint)) == false)
return 0;
uint read = BitConverter.ToUInt32(m_Data, HEADER_SIZE + m_wReadOffSet);
m_wReadOffSet += sizeof(uint);
return read;
}
public ushort ReadUShort()
{
if (ValidReadSize(sizeof(ushort)) == false)
return 0;
ushort read = BitConverter.ToUInt16(m_Data, HEADER_SIZE + m_wReadOffSet);
m_wReadOffSet += sizeof(ushort);
return read;
}
public double ReadDouble()
{
if (ValidReadSize(sizeof(double)) == false)
return 0.0;
double read = BitConverter.ToDouble(m_Data, HEADER_SIZE + m_wReadOffSet);
m_wReadOffSet += sizeof(double);
return read;
}
public float ReadFloat()
{
if (ValidReadSize(sizeof(float)) == false)
return 0.0f;
float read = BitConverter.ToSingle(m_Data, HEADER_SIZE + m_wReadOffSet);
m_wReadOffSet += sizeof(float);
return read;
}
public bool ReadBool()
{
if (ValidReadSize(sizeof(bool)) == false)
return false;
bool read = BitConverter.ToBoolean(m_Data, HEADER_SIZE + m_wReadOffSet);
m_wReadOffSet += sizeof(bool);
return read;
}
public short ReadShort()
{
if (ValidReadSize(sizeof(short)) == false)
return 0;
short read = BitConverter.ToInt16(m_Data, HEADER_SIZE + m_wReadOffSet);
m_wReadOffSet += sizeof(short);
return read;
}
public long ReadLong()
{
if (ValidReadSize(sizeof(long)) == false)
return 0;
long read = BitConverter.ToInt64(m_Data, HEADER_SIZE + m_wReadOffSet);
m_wReadOffSet += sizeof(long);
return read;
}
public int ReadInt()
{
if (ValidReadSize(sizeof(int)) == false)
return 0;
int read = BitConverter.ToInt32(m_Data, HEADER_SIZE + m_wReadOffSet);
m_wReadOffSet += sizeof(int);
return read;
}
public string ReadString()
{
ushort wLen = ReadUShort();
if (ValidReadSize(wLen) == false)
return string.Empty;
if (wLen != 0)
{
string s = System.Text.Encoding.Unicode.GetString(m_Data, HEADER_SIZE + m_wReadOffSet, wLen);
m_wReadOffSet += wLen;
string ts = s.TrimEnd('\0');
return ts;
}
else
return string.Empty;
}
public DateTime ReadDateTime()
{
long read = ReadLong();
return DateTime.FromBinary(read);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment