perf: kcp2k V1.11 - where-allocation - 25x reduction in Socket.SendTo/ReceiveFrom allocations (#2759)

This commit is contained in:
vis2k 2021-06-08 12:41:56 +08:00 committed by GitHub
parent 0a9533eb92
commit f84c012ae8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 454 additions and 14 deletions

View File

@ -1,3 +1,30 @@
V1.11 [2021-06-01]
- perf: where-allocation (https://github.com/vis2k/where-allocation):
nearly removes all socket.SendTo/ReceiveFrom allocations
- perf: Segment MemoryStream initial capacity set to MTU to avoid early runtime
resizing/allocations
V1.10 [2021-05-28]
- feature: configurable Timeout
- allocations explained with comments (C# ReceiveFrom / IPEndPoint.GetHashCode)
- fix: #17 KcpConnection.ReceiveNextReliable now assigns message default so it
works in .net too
- fix: Segment pool is not static anymore. Each kcp instance now has it's own
Pool<Segment>. fixes #18 concurrency issues
V1.9 [2021-03-02]
- Tick() split into TickIncoming()/TickOutgoing() to use in Mirror's new update
functions. allows to minimize latency.
=> original Tick() is still supported for convenience. simply processes both!
V1.8 [2021-02-14]
- fix: Unity IPv6 errors on Nintendo Switch
- fix: KcpConnection now disconnects if data message was received without content.
previously it would call OnData with an empty ArraySegment, causing all kinds of
weird behaviour in Mirror/DOTSNET. Added tests too.
- fix: KcpConnection.SendData: don't allow sending empty messages anymore. disconnect
and log a warning to make it completely obvious.
V1.7 [2021-01-13]
- fix: unreliable messages reset timeout now too
- perf: KcpConnection OnCheckEnabled callback changed to a simple 'paused' boolean.

View File

@ -1,5 +1,6 @@
using System.Net;
using System.Net.Sockets;
using WhereAllocation;
namespace kcp2k
{
@ -12,6 +13,9 @@ public class KcpClientConnection : KcpConnection
// => we need the MTU to fit channel + message!
readonly byte[] rawReceiveBuffer = new byte[Kcp.MTU_DEF];
// where-allocation
IPEndPointNonAlloc reusableEP;
public void Connect(string host, ushort port, bool noDelay, uint interval = Kcp.INTERVAL, int fastResend = 0, bool congestionWindow = true, uint sendWindowSize = Kcp.WND_SND, uint receiveWindowSize = Kcp.WND_RCV, int timeout = DEFAULT_TIMEOUT)
{
Log.Info($"KcpClient: connect to {host}:{port}");
@ -21,6 +25,11 @@ public void Connect(string host, ushort port, bool noDelay, uint interval = Kcp.
remoteEndpoint = new IPEndPoint(ipAddress[0], port);
socket = new Socket(remoteEndpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
// create reusableEP with same address family as remoteEndPoint.
// otherwise ReceiveFrom_NonAlloc couldn't use it.
reusableEP = new IPEndPointNonAlloc(ipAddress[0], port);
socket.Connect(remoteEndpoint);
SetupKcp(noDelay, interval, fastResend, congestionWindow, sendWindowSize, receiveWindowSize, timeout);
@ -39,7 +48,8 @@ public void RawReceive()
{
while (socket.Poll(0, SelectMode.SelectRead))
{
int msgLength = socket.ReceiveFrom(rawReceiveBuffer, ref remoteEndpoint);
// where-allocation: receive nonalloc.
int msgLength = socket.ReceiveFrom_NonAlloc(rawReceiveBuffer, reusableEP);
// IMPORTANT: detect if buffer was too small for the
// received msgLength. otherwise the excess
// data would be silently lost.

View File

@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using WhereAllocation;
namespace kcp2k
{
@ -43,9 +44,11 @@ public class KcpServer
Socket socket;
#if UNITY_SWITCH
// switch does not support ipv6
EndPoint newClientEP = new IPEndPoint(IPAddress.Any, 0);
//EndPoint newClientEP = new IPEndPoint(IPAddress.Any, 0);
IPEndPointNonAlloc reusableClientEP = new IPEndPointNonAlloc(IPAddress.Any, 0); // where-allocation
#else
EndPoint newClientEP = new IPEndPoint(IPAddress.IPv6Any, 0);
//EndPoint newClientEP = new IPEndPoint(IPAddress.IPv6Any, 0);
IPEndPointNonAlloc reusableClientEP = new IPEndPointNonAlloc(IPAddress.IPv6Any, 0); // where-allocation
#endif
// IMPORTANT: raw receive buffer always needs to be of 'MTU' size, even
// if MaxMessageSize is larger. kcp always sends in MTU
@ -140,9 +143,13 @@ public void TickIncoming()
// receive from calls newClientEP.Create(socketAddr).
// IPEndPoint.Create always returns a new IPEndPoint.
// https://github.com/mono/mono/blob/f74eed4b09790a0929889ad7fc2cf96c9b6e3757/mcs/class/System/System.Net.Sockets/Socket.cs#L1761
int msgLength = socket.ReceiveFrom(rawReceiveBuffer, 0, rawReceiveBuffer.Length, SocketFlags.None, ref newClientEP);
//int msgLength = socket.ReceiveFrom(rawReceiveBuffer, 0, rawReceiveBuffer.Length, SocketFlags.None, ref newClientEP);
//Log.Info($"KCP: server raw recv {msgLength} bytes = {BitConverter.ToString(buffer, 0, msgLength)}");
// where-allocation nonalloc ReceiveFrom.
int msgLength = socket.ReceiveFrom_NonAlloc(rawReceiveBuffer, 0, rawReceiveBuffer.Length, SocketFlags.None, reusableClientEP);
SocketAddress remoteAddress = reusableClientEP.temp;
// calculate connectionId from endpoint
// NOTE: IPEndPoint.GetHashCode() allocates.
// it calls m_Address.GetHashCode().
@ -152,7 +159,10 @@ public void TickIncoming()
//
// => using only newClientEP.Port wouldn't work, because
// different connections can have the same port.
int connectionId = newClientEP.GetHashCode();
//int connectionId = newClientEP.GetHashCode();
// where-allocation nonalloc GetHashCode
int connectionId = remoteAddress.GetHashCode();
// IMPORTANT: detect if buffer was too small for the received
// msgLength. otherwise the excess data would be
@ -163,8 +173,19 @@ public void TickIncoming()
// is this a new connection?
if (!connections.TryGetValue(connectionId, out KcpServerConnection connection))
{
// IPEndPointNonAlloc is reused all the time.
// we can't store that as the connection's endpoint.
// we need a new copy!
IPEndPoint newClientEP = reusableClientEP.DeepCopyIPEndPoint();
// for allocation free sending, we also need another
// IPEndPointNonAlloc...
IPEndPointNonAlloc reusableSendEP = new IPEndPointNonAlloc(newClientEP.Address, newClientEP.Port);
// create a new KcpConnection
connection = new KcpServerConnection(socket, newClientEP, NoDelay, Interval, FastResend, CongestionWindow, SendWindowSize, ReceiveWindowSize, Timeout);
// -> where-allocation IPEndPointNonAlloc is reused.
// need to create a new one from the temp address.
connection = new KcpServerConnection(socket, newClientEP, reusableSendEP, NoDelay, Interval, FastResend, CongestionWindow, SendWindowSize, ReceiveWindowSize, Timeout);
// DO NOT add to connections yet. only if the first message
// is actually the kcp handshake. otherwise it's either:

View File

@ -1,20 +1,25 @@
using System.Net;
using System.Net.Sockets;
using WhereAllocation;
namespace kcp2k
{
public class KcpServerConnection : KcpConnection
{
public KcpServerConnection(Socket socket, EndPoint remoteEndpoint, bool noDelay, uint interval = Kcp.INTERVAL, int fastResend = 0, bool congestionWindow = true, uint sendWindowSize = Kcp.WND_SND, uint receiveWindowSize = Kcp.WND_RCV, int timeout = DEFAULT_TIMEOUT)
IPEndPointNonAlloc reusableSendEndPoint;
public KcpServerConnection(Socket socket, EndPoint remoteEndPoint, IPEndPointNonAlloc reusableSendEndPoint, bool noDelay, uint interval = Kcp.INTERVAL, int fastResend = 0, bool congestionWindow = true, uint sendWindowSize = Kcp.WND_SND, uint receiveWindowSize = Kcp.WND_RCV, int timeout = DEFAULT_TIMEOUT)
{
this.socket = socket;
this.remoteEndpoint = remoteEndpoint;
this.remoteEndpoint = remoteEndPoint;
this.reusableSendEndPoint = reusableSendEndPoint;
SetupKcp(noDelay, interval, fastResend, congestionWindow, sendWindowSize, receiveWindowSize, timeout);
}
protected override void RawSend(byte[] data, int length)
{
socket.SendTo(data, 0, length, SocketFlags.None, remoteEndpoint);
// where-allocation nonalloc
socket.SendTo_NonAlloc(data, 0, length, SocketFlags.None, reusableSendEndPoint);
}
}
}

View File

@ -747,10 +747,15 @@ void FlushBuffer()
// calculate window size
uint cwnd_ = Math.Min(snd_wnd, rmt_wnd);
// if congestion window:
if (!nocwnd) cwnd_ = Math.Min(cwnd, cwnd_);
// move data from snd_queue to snd_buf
// sliding window, controlled by snd_nxt && sna_una+cwnd
//
// ELI5: 'snd_nxt' is what we want to send.
// 'snd_una' is what hasn't been acked yet.
// copy up to 'cwnd_' difference between them (sliding window)
while (Utils.TimeDiff(snd_nxt, snd_una + cwnd_) < 0)
{
if (snd_queue.Count == 0) break;

View File

@ -16,10 +16,12 @@ internal class Segment
internal int rto;
internal uint fastack;
internal uint xmit;
// we need a auto scaling byte[] with a WriteBytes function.
// we need an auto scaling byte[] with a WriteBytes function.
// MemoryStream does that perfectly, no need to reinvent the wheel.
// note: no need to pool it, because Segment is already pooled.
internal MemoryStream data = new MemoryStream();
// -> MTU as initial capacity to avoid most runtime resizing/allocations
internal MemoryStream data = new MemoryStream(Kcp.MTU_DEF);
// ikcp_encode_seg
// encode a segment into buffer

View File

@ -1,12 +1,15 @@
{
"name": "kcp2k",
"references": [],
"optionalUnityReferences": [],
"references": [
"GUID:63c380d6dae6946209ed0832388a657c"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": true,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": []
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e9de45e025f26411bbb52d1aefc8d5a5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Mirror Networking (vis2k, FakeByte)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: a857d4e863bbf4a7dba70bc2cd1b5949
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6b7f3f8e8fa16475bbe48a8e9fbe800b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("where-allocations.Tests")]

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 158a96a7489b450485a8b06a13328871
timeCreated: 1622356221

View File

@ -0,0 +1,58 @@
using System.Net;
using System.Net.Sockets;
namespace WhereAllocation
{
public static class Extensions
{
// always pass the same IPEndPointNonAlloc instead of allocating a new
// one each time.
//
// use IPEndPointNonAlloc.temp to get the latest SocketAdddress written
// by ReceiveFrom_Internal!
//
// IMPORTANT: .temp will be overwritten in next call!
// hash or manually copy it if you need to store it, e.g.
// when adding a new connection.
public static int ReceiveFrom_NonAlloc(
this Socket socket,
byte[] buffer,
int offset,
int size,
SocketFlags socketFlags,
IPEndPointNonAlloc remoteEndPoint)
{
// call ReceiveFrom with IPEndPointNonAlloc.
// need to wrap this in ReceiveFrom_NonAlloc because it's not
// obvious that IPEndPointNonAlloc.Create does NOT create a new
// IPEndPoint. it saves the result in IPEndPointNonAlloc.temp!
EndPoint casted = remoteEndPoint;
return socket.ReceiveFrom(buffer, offset, size, socketFlags, ref casted);
}
// same as above, different parameters
public static int ReceiveFrom_NonAlloc(this Socket socket, byte[] buffer, IPEndPointNonAlloc remoteEndPoint)
{
EndPoint casted = remoteEndPoint;
return socket.ReceiveFrom(buffer, ref casted);
}
// SendTo allocates too:
// https://github.com/mono/mono/blob/f74eed4b09790a0929889ad7fc2cf96c9b6e3757/mcs/class/System/System.Net.Sockets/Socket.cs#L2240
// -> the allocation is in EndPoint.Serialize()
// NOTE: technically this function isn't necessary.
// could just pass IPEndPointNonAlloc.
// still good for strong typing.
public static int SendTo_NonAlloc(
this Socket socket,
byte[] buffer,
int offset,
int size,
SocketFlags socketFlags,
IPEndPointNonAlloc remoteEndPoint)
{
EndPoint casted = remoteEndPoint;
return socket.SendTo(buffer, offset, size, socketFlags, casted);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9e801942544d44d65808fb250623fe25
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,208 @@
using System;
using System.Net;
using System.Net.Sockets;
namespace WhereAllocation
{
public class IPEndPointNonAlloc : IPEndPoint
{
// Two steps to remove allocations in ReceiveFrom_Internal:
//
// 1.) remoteEndPoint.Serialize():
// https://github.com/mono/mono/blob/f74eed4b09790a0929889ad7fc2cf96c9b6e3757/mcs/class/System/System.Net.Sockets/Socket.cs#L1733
// -> creates an EndPoint for ReceiveFrom_Internal to write into
// -> it's never read from:
// ReceiveFrom_Internal passes it to native:
// https://github.com/mono/mono/blob/f74eed4b09790a0929889ad7fc2cf96c9b6e3757/mcs/class/System/System.Net.Sockets/Socket.cs#L1885
// native recv populates 'sockaddr* from' with the remote address:
// https://docs.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-recvfrom
// -> can NOT be null. bricks both Unity and Unity Hub otherwise.
// -> it seems as if Serialize() is only called to avoid allocating
// a 'new SocketAddress' in ReceiveFrom. it's up to the EndPoint.
//
// 2.) EndPoint.Create(SocketAddress):
// https://github.com/mono/mono/blob/f74eed4b09790a0929889ad7fc2cf96c9b6e3757/mcs/class/System/System.Net.Sockets/Socket.cs#L1761
// -> SocketAddress is the remote's address that we want to return
// -> to avoid 'new EndPoint(SocketAddress), it seems up to the user
// to decide how to create a new EndPoint via .Create
// -> SocketAddress is the object that was returned by Serialize()
//
// in other words, all we need is an extra SocketAddress field that we
// can pass to ReceiveFrom_Internal to write the result into.
// => callers can then get the result from the extra field!
// => no allocations
//
// IMPORTANT: remember that IPEndPointNonAlloc is always the same object
// and never changes. only the helper field is changed.
public SocketAddress temp;
// constructors simply create the field once by calling the base method.
// (our overwritten method would create anything new)
public IPEndPointNonAlloc(long address, int port) : base(address, port)
{
temp = base.Serialize();
}
public IPEndPointNonAlloc(IPAddress address, int port) : base(address, port)
{
temp = base.Serialize();
}
// Serialize simply returns it
public override SocketAddress Serialize() => temp;
// Create doesn't need to create anything.
// SocketAddress object is already the one we returned in Serialize().
// ReceiveFrom_Internal simply wrote into it.
public override EndPoint Create(SocketAddress socketAddress)
{
// original IPEndPoint.Create validates:
if (socketAddress.Family != AddressFamily)
throw new ArgumentException($"Unsupported socketAddress.AddressFamily: {socketAddress.Family}. Expected: {AddressFamily}");
if (socketAddress.Size < 8)
throw new ArgumentException($"Unsupported socketAddress.Size: {socketAddress.Size}. Expected: <8");
// double check to guarantee that ReceiveFrom actually did write
// into our 'temp' field. just in case that's ever changed.
if (socketAddress != temp)
{
// well this is fun.
// in the latest mono from the above github links,
// the result of Serialize() is passed as 'ref' so ReceiveFrom
// does in fact write into it.
//
// in Unity 2019 LTS's mono version, it does create a new one
// each time. this is from ILSpy Receive_From:
//
// SocketPal.CheckDualModeReceiveSupport(this);
// ValidateBlockingMode();
// if (NetEventSource.IsEnabled)
// {
// NetEventSource.Info(this, $"SRC{LocalEndPoint} size:{size} remoteEP:{remoteEP}", "ReceiveFrom");
// }
// EndPoint remoteEP2 = remoteEP;
// System.Net.Internals.SocketAddress socketAddress = SnapshotAndSerialize(ref remoteEP2);
// System.Net.Internals.SocketAddress socketAddress2 = IPEndPointExtensions.Serialize(remoteEP2);
// int bytesTransferred;
// SocketError socketError = SocketPal.ReceiveFrom(_handle, buffer, offset, size, socketFlags, socketAddress.Buffer, ref socketAddress.InternalSize, out bytesTransferred);
// SocketException ex = null;
// if (socketError != 0)
// {
// ex = new SocketException((int)socketError);
// UpdateStatusAfterSocketError(ex);
// if (NetEventSource.IsEnabled)
// {
// NetEventSource.Error(this, ex, "ReceiveFrom");
// }
// if (ex.SocketErrorCode != SocketError.MessageSize)
// {
// throw ex;
// }
// }
// if (!socketAddress2.Equals(socketAddress))
// {
// try
// {
// remoteEP = remoteEP2.Create(socketAddress);
// }
// catch
// {
// }
// if (_rightEndPoint == null)
// {
// _rightEndPoint = remoteEP2;
// }
// }
// if (ex != null)
// {
// throw ex;
// }
// if (NetEventSource.IsEnabled)
// {
// NetEventSource.DumpBuffer(this, buffer, offset, size, "ReceiveFrom");
// NetEventSource.Exit(this, bytesTransferred, "ReceiveFrom");
// }
// return bytesTransferred;
//
// so until they upgrade their mono version, we are stuck with
// some allocations.
//
// for now, let's pass the newly created on to our temp so at
// least we reuse it next time.
temp = socketAddress;
// SocketAddress.GetHashCode() depends on SocketAddress.m_changed.
// ReceiveFrom only sets the buffer, it does not seem to set m_changed.
// we need to reset m_changed for two reasons:
// * if m_changed is false, GetHashCode() returns the cahced m_hash
// which is '0'. that would be a problem.
// https://github.com/mono/mono/blob/bdd772531d379b4e78593587d15113c37edd4a64/mcs/class/referencesource/System/net/System/Net/SocketAddress.cs#L262
// * if we have a cached m_hash, but ReceiveFrom modified the buffer
// then the GetHashCode() should change too. so we need to reset
// either way.
//
// the only way to do that is by _actually_ modifying the buffer:
// https://github.com/mono/mono/blob/bdd772531d379b4e78593587d15113c37edd4a64/mcs/class/referencesource/System/net/System/Net/SocketAddress.cs#L99
// so let's do that.
// -> unchecked in case it's byte.Max
unchecked
{
temp[0] += 1;
temp[0] -= 1;
}
// make sure this worked.
// at least throw an Exception to make it obvious if the trick does
// not work anymore, in case ReceiveFrom is ever changed.
if (temp.GetHashCode() == 0)
throw new Exception($"SocketAddress GetHashCode() is 0 after ReceiveFrom. Does the m_changed trick not work anymore?");
// in the future, enable this again:
//throw new Exception($"Socket.ReceiveFrom(): passed SocketAddress={socketAddress} but expected {temp}. This should never happen. Did ReceiveFrom() change?");
}
// ReceiveFrom sets seed_endpoint to the result of Create():
// https://github.com/mono/mono/blob/f74eed4b09790a0929889ad7fc2cf96c9b6e3757/mcs/class/System/System.Net.Sockets/Socket.cs#L1764
// so let's return ourselves at least.
// (seed_endpoint only seems to matter for BeginSend etc.)
return this;
}
// we need to overwrite GetHashCode() for two reasons.
// https://github.com/mono/mono/blob/bdd772531d379b4e78593587d15113c37edd4a64/mcs/class/referencesource/System/net/System/Net/IPEndPoint.cs#L160
// * it uses m_Address. but our true SocketAddress is in m_temp.
// m_Address might not be set at all.
// * m_Address.GetHashCode() allocates:
// https://github.com/mono/mono/blob/bdd772531d379b4e78593587d15113c37edd4a64/mcs/class/referencesource/System/net/System/Net/IPAddress.cs#L699
public override int GetHashCode() => temp.GetHashCode();
// helper function to create an ACTUAL new IPEndPoint from this.
// server needs it to store new connections as unique IPEndPoints.
public IPEndPoint DeepCopyIPEndPoint()
{
// we need to create a new IPEndPoint from 'temp' SocketAddress.
// there is no 'new IPEndPoint(SocketAddress) constructor.
// so we need to be a bit creative...
// allocate a placeholder IPAddress to copy
// our SocketAddress into.
// -> needs to be the same address family.
IPAddress ipAddress;
if (temp.Family == AddressFamily.InterNetworkV6)
ipAddress = IPAddress.IPv6Any;
else if (temp.Family == AddressFamily.InterNetwork)
ipAddress = IPAddress.Any;
else
throw new Exception($"Unexpected SocketAddress family: {temp.Family}");
// allocate a placeholder IPEndPoint
// with the needed size form IPAddress.
// (the real class. not NonAlloc)
IPEndPoint placeholder = new IPEndPoint(ipAddress, 0);
// the real IPEndPoint's .Create function can create a new IPEndPoint
// copy from a SocketAddress.
return (IPEndPoint)placeholder.Create(temp);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: af0279d15e39b484792394f1d3cad4d9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,13 @@
{
"name": "where-allocations",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 63c380d6dae6946209ed0832388a657c
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,2 @@
V0.1 [2021-06-01]
- initial release

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f1256cadc037546ccb66071784fce137
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: