Many more optimizations and bugfixes

Again, sorry for the huge commit. Just taking on performance issues as I
see them. Changes in this:

- Deadlocks in region code finally fixed
- Chunk packet preparation optimized (saves ~10-20ms per packet, since
  we're sending these like 30 at a time that's pretty important) by
  storing chunks pre-encoded in memory (basically just using a single
  big array for IDs, metadata, and light)
- Move chunk generation and compression to the thread pool
- Move client chunk updates to the scheduler
- Improve profiler coverage
- Add knob to disable scheduling chunk events on chunk load
- Make it possible to disable specific scheduled events in config.yml
This commit is contained in:
Drew DeVault 2017-05-23 18:17:44 -04:00
parent 362c852f51
commit 6fb8ee7ba5
25 changed files with 460 additions and 325 deletions

View File

@ -29,7 +29,7 @@ namespace TrueCraft.API
config = new T();
}
var serializer = new Serializer();
var serializer = new Serializer(SerializationOptions.EmitDefaults);
using (var writer = new StreamWriter(configFileName))
serializer.Serialize(writer, config);

View File

@ -1,85 +0,0 @@
using fNbt;
using fNbt.Serialization;
using System;
using System.Collections.ObjectModel;
namespace TrueCraft.API
{
/// <summary>
/// Represents an array of 4-bit values.
/// </summary>
public class NibbleArray : INbtSerializable
{
/// <summary>
/// The data in the nibble array. Each byte contains
/// two nibbles, stored in big-endian.
/// </summary>
public byte[] Data { get; set; }
public NibbleArray()
{
}
/// <summary>
/// Creates a new nibble array with the given number of nibbles.
/// </summary>
public NibbleArray(int length)
{
Data = new byte[length/2];
}
/// <summary>
/// Gets the current number of nibbles in this array.
/// </summary>
[NbtIgnore]
public int Length
{
get { return Data.Length * 2; }
}
/// <summary>
/// Gets or sets a nibble at the given index.
/// </summary>
[NbtIgnore]
public byte this[int index]
{
get { return (byte)(Data[index / 2] >> ((index) % 2 * 4) & 0xF); }
set
{
value &= 0xF;
Data[index/2] &= (byte)(0xF << ((index + 1) % 2 * 4));
Data[index/2] |= (byte)(value << (index % 2 * 4));
}
}
public NbtTag Serialize(string tagName)
{
return new NbtByteArray(tagName, Data);
}
public void Deserialize(NbtTag value)
{
Data = value.ByteArrayValue;
}
}
public class ReadOnlyNibbleArray
{
private NibbleArray NibbleArray { get; set; }
public ReadOnlyNibbleArray(NibbleArray array)
{
NibbleArray = array;
}
public byte this[int index]
{
get { return NibbleArray[index]; }
}
public ReadOnlyCollection<byte> Data
{
get { return Array.AsReadOnly(NibbleArray.Data); }
}
}
}

View File

@ -0,0 +1,82 @@
using fNbt;
using fNbt.Serialization;
using System;
using System.Collections.ObjectModel;
namespace TrueCraft.API
{
/// <summary>
/// Represents a slice of an array of 4-bit values.
/// </summary>
public class NibbleSlice : INbtSerializable
{
/// <summary>
/// The data in the nibble array. Each byte contains
/// two nibbles, stored in big-endian.
/// </summary>
public byte[] Data { get; private set; }
public int Offset { get; private set; }
public int Length { get; private set; }
public NibbleSlice(byte[] data, int offset, int length)
{
Data = data;
Offset = offset;
Length = length;
}
/// <summary>
/// Gets or sets a nibble at the given index.
/// </summary>
[NbtIgnore]
public byte this[int index]
{
get { return (byte)(Data[Offset + index / 2] >> (index % 2 * 4) & 0xF); }
set
{
value &= 0xF;
Data[Offset + index / 2] &= (byte)(~(0xF << (index % 2 * 4)));
Data[Offset + index / 2] |= (byte)(value << (index % 2 * 4));
}
}
public byte[] ToArray()
{
byte[] array = new byte[Length];
Buffer.BlockCopy(Data, Offset, array, 0, Length);
return array;
}
public NbtTag Serialize(string tagName)
{
return new NbtByteArray(tagName, ToArray());
}
public void Deserialize(NbtTag value)
{
Length = value.ByteArrayValue.Length;
Buffer.BlockCopy(value.ByteArrayValue, 0,
Data, Offset, Length);
}
}
public class ReadOnlyNibbleArray
{
private NibbleSlice NibbleArray { get; set; }
public ReadOnlyNibbleArray(NibbleSlice array)
{
NibbleArray = array;
}
public byte this[int index]
{
get { return NibbleArray[index]; }
}
public ReadOnlyCollection<byte> Data
{
get { return Array.AsReadOnly(NibbleArray.Data); }
}
}
}

View File

@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
namespace TrueCraft.API.Server
{
public interface IEventScheduler
{
HashSet<string> DisabledEvents { get; }
/// <summary>
/// Schedules an event to occur some time in the future.
/// </summary>

View File

@ -88,7 +88,7 @@
<Compile Include="World\IWorld.cs" />
<Compile Include="World\IChunk.cs" />
<Compile Include="World\IChunkProvider.cs" />
<Compile Include="NibbleArray.cs" />
<Compile Include="NibbleSlice.cs" />
<Compile Include="World\IRegion.cs" />
<Compile Include="Biome.cs" />
<Compile Include="Logging\LogCategory.cs" />

View File

@ -15,12 +15,13 @@ namespace TrueCraft.API.World
int[] HeightMap { get; }
byte[] Biomes { get; }
DateTime LastAccessed { get; set; }
byte[] Blocks { get; }
byte[] Data { get; }
bool TerrainPopulated { get; set; }
Dictionary<Coordinates3D, NbtCompound> TileEntities { get; set; }
NibbleArray Metadata { get; }
NibbleArray BlockLight { get; }
NibbleArray SkyLight { get; }
NibbleSlice Metadata { get; }
NibbleSlice BlockLight { get; }
NibbleSlice SkyLight { get; }
IRegion ParentRegion { get; set; }
int GetHeight(byte x, byte z);
void UpdateHeightMap();
byte GetBlockID(Coordinates3D coordinates);

View File

@ -9,6 +9,10 @@ namespace TrueCraft.API.World
Coordinates2D Position { get; }
IChunk GetChunk(Coordinates2D position, bool generate = true);
/// <summary>
/// Marks the chunk for saving in the next Save().
/// </summary>
void DamageChunk(Coordinates2D position);
void UnloadChunk(Coordinates2D position);
void Save(string path);
}

View File

@ -53,14 +53,14 @@ namespace TrueCraft.Client.Handlers
&& packet.Depth == Chunk.Depth) // Fast path
{
// Block IDs
Buffer.BlockCopy(data, 0, chunk.Blocks, 0, chunk.Blocks.Length);
Buffer.BlockCopy(data, 0, chunk.Data, 0, chunk.Data.Length);
// Block metadata
Buffer.BlockCopy(data, chunk.Blocks.Length, chunk.Metadata.Data, 0, chunk.Metadata.Data.Length);
Buffer.BlockCopy(data, chunk.Data.Length, chunk.Metadata.Data, 0, chunk.Metadata.Data.Length);
// Block light
Buffer.BlockCopy(data, chunk.Blocks.Length + chunk.Metadata.Data.Length,
Buffer.BlockCopy(data, chunk.Data.Length + chunk.Metadata.Data.Length,
chunk.BlockLight.Data, 0, chunk.BlockLight.Data.Length);
// Sky light
Buffer.BlockCopy(data, chunk.Blocks.Length + chunk.Metadata.Data.Length + chunk.BlockLight.Data.Length,
Buffer.BlockCopy(data, chunk.Data.Length + chunk.Metadata.Data.Length + chunk.BlockLight.Data.Length,
chunk.SkyLight.Data, 0, chunk.SkyLight.Data.Length);
}
else // Slow path

View File

@ -116,7 +116,7 @@ namespace TrueCraft.Client
public int X { get { return Chunk.X; } }
public int Z { get { return Chunk.Z; } }
public ReadOnlyCollection<byte> Blocks { get { return Array.AsReadOnly(Chunk.Blocks); } }
public ReadOnlyCollection<byte> Blocks { get { return Array.AsReadOnly(Chunk.Data); } }
public ReadOnlyNibbleArray Metadata { get { return new ReadOnlyNibbleArray(Chunk.Metadata); } }
public ReadOnlyNibbleArray BlockLight { get { return new ReadOnlyNibbleArray(Chunk.BlockLight); } }
public ReadOnlyNibbleArray SkyLight { get { return new ReadOnlyNibbleArray(Chunk.SkyLight); } }

View File

@ -12,51 +12,80 @@ namespace TrueCraft.Core.Test.World
[TestFixture]
public class ChunkTest
{
public Chunk Chunk { get; set; }
[OneTimeSetUp]
public void SetUp()
{
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
var file = new NbtFile(Path.Combine(assemblyDir, "Files", "TestChunk.nbt"));
Chunk = Chunk.FromNbt(file);
}
[Test]
public void TestGetBlockID()
{
Assert.AreEqual(BedrockBlock.BlockID, Chunk.GetBlockID(Coordinates3D.Zero));
Chunk.SetBlockID(Coordinates3D.Zero, 12);
Assert.AreEqual(12, Chunk.GetBlockID(Coordinates3D.Zero));
Chunk.SetBlockID(Coordinates3D.Zero, BedrockBlock.BlockID);
var chunk = new Chunk();
chunk.SetBlockID(Coordinates3D.Zero, 12);
Assert.AreEqual(12, chunk.GetBlockID(Coordinates3D.Zero));
}
[Test]
public void TestGetBlockLight()
{
Assert.AreEqual(0, Chunk.GetBlockLight(Coordinates3D.Zero));
var chunk = new Chunk();
chunk.SetBlockLight(Coordinates3D.Zero, 5);
Assert.AreEqual(5, chunk.GetBlockLight(Coordinates3D.Zero));
}
[Test]
public void TestGetSkyLight()
{
Assert.AreEqual(0, Chunk.GetBlockLight(Coordinates3D.Zero));
var chunk = new Chunk();
chunk.SetSkyLight(Coordinates3D.Zero, 5);
Assert.AreEqual(5, chunk.GetSkyLight(Coordinates3D.Zero));
}
[Test]
public void TestGetMetadata()
{
Assert.AreEqual(0, Chunk.GetBlockLight(Coordinates3D.Zero));
var chunk = new Chunk();
chunk.SetMetadata(Coordinates3D.Zero, 5);
Assert.AreEqual(5, chunk.GetMetadata(Coordinates3D.Zero));
}
[Test]
public void TestHeightMap()
{
Chunk.UpdateHeightMap();
Assert.AreEqual(59, Chunk.GetHeight(0, 0));
Assert.AreEqual(58, Chunk.GetHeight(1, 0));
Chunk.SetBlockID(new Coordinates3D(1, 80, 0), 1);
Assert.AreEqual(80, Chunk.GetHeight(1, 0));
var chunk = new Chunk();
for (int x = 0; x < Chunk.Width; ++x)
for (int z = 0; z < Chunk.Width; ++z)
chunk.SetBlockID(new Coordinates3D(x, 20, z), StoneBlock.BlockID);
chunk.UpdateHeightMap();
Assert.AreEqual(20, chunk.GetHeight(0, 0));
Assert.AreEqual(20, chunk.GetHeight(1, 0));
chunk.SetBlockID(new Coordinates3D(1, 80, 0), 1);
Assert.AreEqual(80, chunk.GetHeight(1, 0));
}
[Test]
public void TestConsistency()
{
var chunk = new Chunk();
byte val = 0;
for (int y = 0; y < Chunk.Height; y++)
for (int x = 0; x < Chunk.Width; x++)
for (int z = 0; z < Chunk.Depth; z++)
{
var coords = new Coordinates3D(x, y, z);
chunk.SetBlockID(coords, val);
chunk.SetMetadata(coords, (byte)(val % 16));
chunk.SetBlockLight(coords, (byte)(val % 16));
chunk.SetSkyLight(coords, (byte)(val % 16));
val++;
}
val = 0;
for (int y = 0; y < Chunk.Height; y++)
for (int x = 0; x < Chunk.Width; x++)
for (int z = 0; z < Chunk.Depth; z++)
{
var coords = new Coordinates3D(x, y, z);
Assert.AreEqual(val, chunk.GetBlockID(coords));
Assert.AreEqual((byte)(val % 16), chunk.GetMetadata(coords));
Assert.AreEqual((byte)(val % 16), chunk.GetBlockLight(coords));
Assert.AreEqual((byte)(val % 16), chunk.GetSkyLight(coords));
val++;
}
}
}
}

View File

@ -6,6 +6,7 @@ using TrueCraft.API;
using System.Collections.Generic;
using System.Diagnostics;
using TrueCraft.Profiling;
using System.Collections.Concurrent;
namespace TrueCraft.Core.Lighting
{
@ -34,15 +35,14 @@ namespace TrueCraft.Core.Lighting
public IBlockRepository BlockRepository { get; set; }
public IWorld World { get; set; }
private object _Lock = new object();
private List<LightingOperation> PendingOperations { get; set; }
private ConcurrentQueue<LightingOperation> PendingOperations { get; set; }
private Dictionary<Coordinates2D, byte[,]> HeightMaps { get; set; }
public WorldLighting(IWorld world, IBlockRepository blockRepository)
{
BlockRepository = blockRepository;
World = world;
PendingOperations = new List<LightingOperation>();
PendingOperations = new ConcurrentQueue<LightingOperation>();
HeightMaps = new Dictionary<Coordinates2D, byte[,]>();
world.ChunkGenerated += (sender, e) => GenerateHeightMap(e.Chunk);
world.ChunkLoaded += (sender, e) => GenerateHeightMap(e.Chunk);
@ -72,7 +72,7 @@ namespace TrueCraft.Core.Lighting
if (id == 0)
continue;
var provider = BlockRepository.GetBlockProvider(id);
if (provider.LightOpacity != 0)
if (provider == null || provider.LightOpacity != 0)
{
map[x, z] = y;
break;
@ -252,33 +252,31 @@ namespace TrueCraft.Core.Lighting
public bool TryLightNext()
{
LightingOperation op;
lock (_Lock)
{
if (PendingOperations.Count == 0)
return false;
op = PendingOperations[0];
PendingOperations.RemoveAt(0);
}
LightBox(op);
return true;
if (PendingOperations.Count == 0)
return false;
// TODO: Maybe a timeout or something?
bool dequeued = false;
while (!(dequeued = PendingOperations.TryDequeue(out op)) && PendingOperations.Count > 0) ;
if (dequeued)
LightBox(op);
return dequeued;
}
public void EnqueueOperation(BoundingBox box, bool skyLight, bool initial = false)
{
lock (_Lock)
// Try to merge with existing operation
/*
for (int i = PendingOperations.Count - 1; i > PendingOperations.Count - 5 && i > 0; i--)
{
// Try to merge with existing operation
for (int i = PendingOperations.Count - 1; i > PendingOperations.Count - 5 && i > 0; i--)
var op = PendingOperations[i];
if (op.Box.Intersects(box))
{
var op = PendingOperations[i];
if (op.Box.Intersects(box))
{
op.Box = new BoundingBox(Vector3.Min(op.Box.Min, box.Min), Vector3.Max(op.Box.Max, box.Max));
return;
}
op.Box = new BoundingBox(Vector3.Min(op.Box.Min, box.Min), Vector3.Max(op.Box.Max, box.Max));
return;
}
PendingOperations.Add(new LightingOperation { SkyLight = skyLight, Box = box, Initial = initial });
}
*/
PendingOperations.Enqueue(new LightingOperation { SkyLight = skyLight, Box = box, Initial = initial });
}
private void SetUpperVoxels(IChunk chunk)

View File

@ -220,7 +220,7 @@ namespace TrueCraft.Core.TerrainGen
var coords = new Coordinates2D(x, z);
double distance = IsSpawnCoordinate(x, z) ? coords.Distance : 1000;
if (distance < 1000) // Avoids deep water within 1km sq of spawn
value += (1 - distance / 1000f) * 12;
value += (1 - distance / 1000f) * 18;
if (value < 0)
value = GroundLevel;
if (value > Chunk.Height)

View File

@ -25,13 +25,13 @@ namespace TrueCraft.Core.World
[NbtIgnore]
public bool IsModified { get; set; }
[NbtIgnore]
public byte[] Blocks { get; set; }
public byte[] Data { get; set; }
[NbtIgnore]
public NibbleArray Metadata { get; set; }
public NibbleSlice Metadata { get; set; }
[NbtIgnore]
public NibbleArray BlockLight { get; set; }
public NibbleSlice BlockLight { get; set; }
[NbtIgnore]
public NibbleArray SkyLight { get; set; }
public NibbleSlice SkyLight { get; set; }
public byte[] Biomes { get; set; }
public int[] HeightMap { get; set; }
public int MaxHeight { get; private set; }
@ -73,7 +73,7 @@ namespace TrueCraft.Core.World
public bool TerrainPopulated { get; set; }
[NbtIgnore]
public Region ParentRegion { get; set; }
public IRegion ParentRegion { get; set; }
public Chunk()
{
@ -83,23 +83,24 @@ namespace TrueCraft.Core.World
TerrainPopulated = false;
LightPopulated = false;
MaxHeight = 0;
const int size = Width * Height * Depth;
const int halfSize = size / 2;
Data = new byte[size + halfSize * 3];
Metadata = new NibbleSlice(Data, size, halfSize);
BlockLight = new NibbleSlice(Data, size + halfSize, halfSize);
SkyLight = new NibbleSlice(Data, size + halfSize * 2, halfSize);
}
public Chunk(Coordinates2D coordinates) : this()
{
X = coordinates.X;
Z = coordinates.Z;
const int size = Width * Height * Depth;
Blocks = new byte[size];
Metadata = new NibbleArray(size);
BlockLight = new NibbleArray(size);
SkyLight = new NibbleArray(size);
}
public byte GetBlockID(Coordinates3D coordinates)
{
int index = coordinates.Y + (coordinates.Z * Height) + (coordinates.X * Height * Width);
return Blocks[index];
return Data[index];
}
public byte GetMetadata(Coordinates3D coordinates)
@ -127,8 +128,9 @@ namespace TrueCraft.Core.World
public void SetBlockID(Coordinates3D coordinates, byte value)
{
IsModified = true;
ParentRegion.DamageChunk(Coordinates);
int index = coordinates.Y + (coordinates.Z * Height) + (coordinates.X * Height * Width);
Blocks[index] = value;
Data[index] = value;
if (value == AirBlock.BlockID)
Metadata[index] = 0x0;
var oldHeight = GetHeight((byte)coordinates.X, (byte)coordinates.Z);
@ -164,6 +166,7 @@ namespace TrueCraft.Core.World
public void SetMetadata(Coordinates3D coordinates, byte value)
{
IsModified = true;
ParentRegion.DamageChunk(Coordinates);
int index = coordinates.Y + (coordinates.Z * Height) + (coordinates.X * Height * Width);
Metadata[index] = value;
}
@ -175,6 +178,7 @@ namespace TrueCraft.Core.World
public void SetSkyLight(Coordinates3D coordinates, byte value)
{
IsModified = true;
ParentRegion.DamageChunk(Coordinates);
int index = coordinates.Y + (coordinates.Z * Height) + (coordinates.X * Height * Width);
SkyLight[index] = value;
}
@ -186,6 +190,7 @@ namespace TrueCraft.Core.World
public void SetBlockLight(Coordinates3D coordinates, byte value)
{
IsModified = true;
ParentRegion.DamageChunk(Coordinates);
int index = coordinates.Y + (coordinates.Z * Height) + (coordinates.X * Height * Width);
BlockLight[index] = value;
}
@ -210,6 +215,7 @@ namespace TrueCraft.Core.World
else
TileEntities[coordinates] = value;
IsModified = true;
ParentRegion.DamageChunk(Coordinates);
}
/// <summary>
@ -236,7 +242,7 @@ namespace TrueCraft.Core.World
for (y = Chunk.Height - 1; y >= 0; y--)
{
int index = y + (z * Height) + (x * Height * Width);
if (Blocks[index] != 0)
if (Data[index] != 0)
{
SetHeight(x, z, y);
if (y > MaxHeight)
@ -275,10 +281,10 @@ namespace TrueCraft.Core.World
chunk.Add(new NbtInt("Z", Z));
chunk.Add(new NbtByte("LightPopulated", (byte)(LightPopulated ? 1 : 0)));
chunk.Add(new NbtByte("TerrainPopulated", (byte)(TerrainPopulated ? 1 : 0)));
chunk.Add(new NbtByteArray("Blocks", Blocks));
chunk.Add(new NbtByteArray("Data", Metadata.Data));
chunk.Add(new NbtByteArray("SkyLight", SkyLight.Data));
chunk.Add(new NbtByteArray("BlockLight", BlockLight.Data));
chunk.Add(new NbtByteArray("Blocks", Data));
chunk.Add(new NbtByteArray("Data", Metadata.ToArray()));
chunk.Add(new NbtByteArray("SkyLight", SkyLight.ToArray()));
chunk.Add(new NbtByteArray("BlockLight", BlockLight.ToArray()));
var tiles = new NbtList("TileEntities", NbtTagType.Compound);
foreach (var kvp in TileEntities)
@ -308,13 +314,17 @@ namespace TrueCraft.Core.World
TerrainPopulated = tag["TerrainPopulated"].ByteValue > 0;
if (tag.Contains("LightPopulated"))
LightPopulated = tag["LightPopulated"].ByteValue > 0;
Blocks = tag["Blocks"].ByteArrayValue;
Metadata = new NibbleArray();
Metadata.Data = tag["Data"].ByteArrayValue;
BlockLight = new NibbleArray();
BlockLight.Data = tag["BlockLight"].ByteArrayValue;
SkyLight = new NibbleArray();
SkyLight.Data = tag["SkyLight"].ByteArrayValue;
const int size = Width * Height * Depth;
const int halfSize = size / 2;
Data = new byte[(int)(size * 2.5)];
Buffer.BlockCopy(tag["Blocks"].ByteArrayValue, 0, Data, 0, size);
Metadata = new NibbleSlice(Data, size, halfSize);
BlockLight = new NibbleSlice(Data, size + halfSize, halfSize);
SkyLight = new NibbleSlice(Data, size + halfSize * 2, halfSize);
Metadata.Deserialize(tag["Data"]);
BlockLight.Deserialize(tag["BlockLight"]);
SkyLight.Deserialize(tag["SkyLight"]);
if (tag.Contains("TileEntities"))
{

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@ -20,10 +21,11 @@ namespace TrueCraft.Core.World
// In chunks
public const int Width = 32, Depth = 32;
private ConcurrentDictionary<Coordinates2D, IChunk> _Chunks { get; set; }
/// <summary>
/// The currently loaded chunk list.
/// </summary>
public IDictionary<Coordinates2D, IChunk> Chunks { get; set; }
public IDictionary<Coordinates2D, IChunk> Chunks { get { return _Chunks; } }
/// <summary>
/// The location of this region in the overworld.
/// </summary>
@ -31,6 +33,7 @@ namespace TrueCraft.Core.World
public World World { get; set; }
private HashSet<Coordinates2D> DirtyChunks { get; set; } = new HashSet<Coordinates2D>();
private Stream regionFile { get; set; }
private object streamLock = new object();
@ -40,7 +43,7 @@ namespace TrueCraft.Core.World
/// </summary>
public Region(Coordinates2D position, World world)
{
Chunks = new Dictionary<Coordinates2D, IChunk>();
_Chunks = new ConcurrentDictionary<Coordinates2D, IChunk>();
Position = position;
World = world;
}
@ -51,7 +54,10 @@ namespace TrueCraft.Core.World
public Region(Coordinates2D position, World world, string file) : this(position, world)
{
if (File.Exists(file))
{
regionFile = File.Open(file, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite);
regionFile.Read(HeaderCache, 0, 8192);
}
else
{
regionFile = File.Open(file, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite);
@ -59,6 +65,13 @@ namespace TrueCraft.Core.World
}
}
public void DamageChunk(Coordinates2D coords)
{
int x = coords.X / Region.Width - ((coords.X < 0) ? 1 : 0);
int z = coords.Z / Region.Depth - ((coords.Z < 0) ? 1 : 0);
DirtyChunks.Add(new Coordinates2D(coords.X - x * 32, coords.Z - z * 32));
}
/// <summary>
/// Retrieves the requested chunk from the region, or
/// generates it if a world generator is provided.
@ -66,25 +79,24 @@ namespace TrueCraft.Core.World
/// <param name="position">The position of the requested local chunk coordinates.</param>
public IChunk GetChunk(Coordinates2D position, bool generate = true)
{
// TODO: This could use some refactoring
if (!Chunks.ContainsKey(position))
{
if (regionFile != null)
{
// Search the stream for that region
var chunkData = GetChunkFromTable(position);
if (chunkData == null)
{
if (World.ChunkProvider == null)
throw new ArgumentException("The requested chunk is not loaded.", "position");
if (generate)
GenerateChunk(position);
else
return null;
return Chunks[position];
}
lock (streamLock)
{
var chunkData = GetChunkFromTable(position);
if (chunkData == null)
{
if (World.ChunkProvider == null)
throw new ArgumentException("The requested chunk is not loaded.", "position");
if (generate)
GenerateChunk(position);
else
return null;
return Chunks[position];
}
regionFile.Seek(chunkData.Item1, SeekOrigin.Begin);
/*int length = */
new MinecraftStream(regionFile).ReadInt32(); // TODO: Avoid making new objects here, and in the WriteInt32
@ -97,6 +109,7 @@ namespace TrueCraft.Core.World
var nbt = new NbtFile();
nbt.LoadFromStream(regionFile, NbtCompression.ZLib, null);
var chunk = Chunk.FromNbt(nbt);
chunk.ParentRegion = this;
Chunks.Add(position, chunk);
World.OnChunkLoaded(new ChunkLoadedEventArgs(chunk));
break;
@ -106,7 +119,7 @@ namespace TrueCraft.Core.World
}
}
else if (World.ChunkProvider == null)
throw new ArgumentException("The requested chunk is not loaded.", "position");
throw new ArgumentException("The requested chunk is not loaded.", nameof(position));
else
{
if (generate)
@ -124,7 +137,9 @@ namespace TrueCraft.Core.World
var chunk = World.ChunkProvider.GenerateChunk(World, globalPosition);
chunk.IsModified = true;
chunk.Coordinates = globalPosition;
Chunks.Add(position, chunk);
chunk.ParentRegion = this;
DirtyChunks.Add(position);
Chunks[position] = chunk;
World.OnChunkGenerated(new ChunkLoadedEventArgs(chunk));
}
@ -136,6 +151,8 @@ namespace TrueCraft.Core.World
if (!Chunks.ContainsKey(position))
Chunks.Add(position, chunk);
chunk.IsModified = true;
DirtyChunks.Add(position);
chunk.ParentRegion = this;
Chunks[position] = chunk;
}
@ -162,17 +179,19 @@ namespace TrueCraft.Core.World
lock (streamLock)
{
var toRemove = new List<Coordinates2D>();
foreach (var kvp in Chunks)
var chunks = DirtyChunks.ToList();
DirtyChunks.Clear();
foreach (var coords in chunks)
{
var chunk = kvp.Value;
var chunk = GetChunk(coords, generate: false);
if (chunk.IsModified)
{
var data = ((Chunk)chunk).ToNbt();
byte[] raw = data.SaveToBuffer(NbtCompression.ZLib);
var header = GetChunkFromTable(kvp.Key);
var header = GetChunkFromTable(coords);
if (header == null || header.Item2 > raw.Length)
header = AllocateNewChunks(kvp.Key, raw.Length);
header = AllocateNewChunks(coords, raw.Length);
regionFile.Seek(header.Item1, SeekOrigin.Begin);
new MinecraftStream(regionFile).WriteInt32(raw.Length);
@ -182,7 +201,7 @@ namespace TrueCraft.Core.World
chunk.IsModified = false;
}
if ((DateTime.UtcNow - chunk.LastAccessed).TotalMinutes > 5)
toRemove.Add(kvp.Key);
toRemove.Add(coords);
}
regionFile.Flush();
// Unload idle chunks
@ -198,14 +217,15 @@ namespace TrueCraft.Core.World
#region Stream Helpers
private const int ChunkSizeMultiplier = 4096;
private byte[] HeaderCache = new byte[8192];
private Tuple<int, int> GetChunkFromTable(Coordinates2D position) // <offset, length>
{
int tableOffset = ((position.X % Width) + (position.Z % Depth) * Width) * 4;
regionFile.Seek(tableOffset, SeekOrigin.Begin);
byte[] offsetBuffer = new byte[4];
regionFile.Read(offsetBuffer, 0, 3);
Buffer.BlockCopy(HeaderCache, tableOffset, offsetBuffer, 0, 3);
Array.Reverse(offsetBuffer);
int length = regionFile.ReadByte();
int length = HeaderCache[tableOffset + 3];
int offset = BitConverter.ToInt32(offsetBuffer, 0) << 4;
if (offset == 0 || length == 0)
return null;
@ -215,7 +235,8 @@ namespace TrueCraft.Core.World
private void CreateRegionHeader()
{
regionFile.Write(new byte[8192], 0, 8192);
HeaderCache = new byte[8192];
regionFile.Write(HeaderCache, 0, 8192);
regionFile.Flush();
}
@ -237,6 +258,7 @@ namespace TrueCraft.Core.World
entry[0] = (byte)length;
Array.Reverse(entry);
regionFile.Write(entry, 0, entry.Length);
Buffer.BlockCopy(entry, 0, HeaderCache, tableOffset, 4);
return new Tuple<int, int>(dataOffset, length * ChunkSizeMultiplier);
}

View File

@ -69,7 +69,8 @@ namespace TrueCraft.Profiling
{
if (Match(EnabledBuckets[i], timer.Bucket))
{
Console.WriteLine("{0} took {1}ms", timer.Bucket, elapsed);
Console.WriteLine("[@{0:0.00}s] {1} took {2}ms",
Stopwatch.ElapsedMilliseconds / 1000.0, timer.Bucket, elapsed);
break;
}
}

View File

@ -519,7 +519,7 @@ namespace TrueCraft.Commands
{
lighter.InitialLighting(chunk, true);
(client as RemoteClient).UnloadChunk(chunk.Coordinates);
(client as RemoteClient).LoadChunk(chunk.Coordinates);
(client as RemoteClient).LoadChunk(chunk);
}
}

View File

@ -79,7 +79,8 @@ namespace TrueCraft
(int)(entity.Position.Z) >> 4 != (int)(entity.OldPosition.Z) >> 4)
{
client.Log("Passed chunk boundary at {0}, {1}", (int)(entity.Position.X) >> 4, (int)(entity.Position.Z) >> 4);
Task.Factory.StartNew(client.UpdateChunks);
Server.Scheduler.ScheduleEvent("client.update-chunks", client,
TimeSpan.Zero, s => client.UpdateChunks());
UpdateClientEntities(client);
}
break;

View File

@ -1,10 +1,12 @@
using System;
using System.Linq;
using TrueCraft.API.Server;
using System.Collections.Generic;
using TrueCraft.API;
using System.Diagnostics;
using TrueCraft.Profiling;
using System.Threading;
using System.Collections.Concurrent;
namespace TrueCraft
{
@ -15,80 +17,122 @@ namespace TrueCraft
private IMultiplayerServer Server { get; set; }
private HashSet<IEventSubject> Subjects { get; set; }
private Stopwatch Stopwatch { get; set; }
private ConcurrentQueue<ScheduledEvent> ImmediateEventQueue { get; set; }
private ConcurrentQueue<ScheduledEvent> LaterEventQueue { get; set; }
private ConcurrentQueue<IEventSubject> DisposedSubjects { get; set; }
public HashSet<string> DisabledEvents { get; private set; }
public EventScheduler(IMultiplayerServer server)
{
Events = new List<ScheduledEvent>();
ImmediateEventQueue = new ConcurrentQueue<ScheduledEvent>();
LaterEventQueue = new ConcurrentQueue<ScheduledEvent>();
DisposedSubjects = new ConcurrentQueue<IEventSubject>();
Server = server;
Subjects = new HashSet<IEventSubject>();
Stopwatch = new Stopwatch();
DisabledEvents = new HashSet<string>();
Stopwatch.Start();
}
private void ScheduleEvent(ScheduledEvent e)
{
int i;
for (i = 0; i < Events.Count; i++)
{
if (Events[i].When > e.When)
break;
}
Events.Insert(i, e);
}
public void ScheduleEvent(string name, IEventSubject subject, TimeSpan when, Action<IMultiplayerServer> action)
{
lock (EventLock)
if (DisabledEvents.Contains(name))
return;
long _when = Stopwatch.ElapsedTicks + when.Ticks;
if (subject != null && !Subjects.Contains(subject))
{
long _when = Stopwatch.ElapsedTicks + when.Ticks;
if (!Subjects.Contains(subject))
{
Subjects.Add(subject);
subject.Disposed += Subject_Disposed;
}
int i;
for (i = 0; i < Events.Count; i++)
{
if (Events[i].When > _when)
break;
}
Events.Insert(i, new ScheduledEvent
{
Name = name,
Subject = subject,
When = _when,
Action = action
});
Subjects.Add(subject);
subject.Disposed += Subject_Disposed;
}
var queue = when.TotalSeconds > 3 ? LaterEventQueue : ImmediateEventQueue;
queue.Enqueue(new ScheduledEvent
{
Name = name,
Subject = subject,
When = _when,
Action = action
});
}
void Subject_Disposed(object sender, EventArgs e)
{
// Cancel all events with this subject
lock (EventLock)
{
for (int i = 0; i < Events.Count; i++)
{
if (Events[i].Subject == sender)
{
Events.RemoveAt(i);
i--;
}
}
Subjects.Remove((IEventSubject)sender);
}
DisposedSubjects.Enqueue((IEventSubject)sender);
}
public void Update()
{
Profiler.Start("scheduler");
lock (EventLock)
Profiler.Start("scheduler.receive-events");
long start = Stopwatch.ElapsedTicks;
long limit = Stopwatch.ElapsedMilliseconds + 10;
while (ImmediateEventQueue.Count > 0 && Stopwatch.ElapsedMilliseconds < limit)
{
var start = Stopwatch.ElapsedTicks;
for (int i = 0; i < Events.Count; i++)
ScheduledEvent e;
bool dequeued = false;
while (!(dequeued = ImmediateEventQueue.TryDequeue(out e))
&& Stopwatch.ElapsedMilliseconds < limit) ;
if (dequeued)
ScheduleEvent(e);
}
while (LaterEventQueue.Count > 0 && Stopwatch.ElapsedMilliseconds < limit)
{
ScheduledEvent e;
bool dequeued = false;
while (!(dequeued = LaterEventQueue.TryDequeue(out e))
&& Stopwatch.ElapsedMilliseconds < limit) ;
if (dequeued)
ScheduleEvent(e);
}
Profiler.Done();
Profiler.Start("scheduler.dispose-subjects");
while (DisposedSubjects.Count > 0 && Stopwatch.ElapsedMilliseconds < limit)
{
IEventSubject subject;
bool dequeued = false;
while (!(dequeued = DisposedSubjects.TryDequeue(out subject))
&& Stopwatch.ElapsedMilliseconds < limit) ;
if (dequeued)
{
var e = Events[i];
if (e.When <= start)
// Cancel all events with this subject
for (int i = 0; i < Events.Count; i++)
{
Profiler.Start("scheduler." + e.Name);
e.Action(Server);
Events.RemoveAt(i);
i--;
Profiler.Done();
if (Events[i].Subject == subject)
{
Events.RemoveAt(i);
i--;
}
}
if (e.When > start)
break; // List is sorted, we can exit early
Subjects.Remove(subject);
}
}
limit = Stopwatch.ElapsedMilliseconds + 10;
Profiler.Done();
for (int i = 0; i < Events.Count && Stopwatch.ElapsedMilliseconds < limit; i++)
{
var e = Events[i];
if (e.When <= start)
{
Profiler.Start("scheduler." + e.Name);
e.Action(Server);
Events.RemoveAt(i);
i--;
Profiler.Done();
}
if (e.When > start)
break; // List is sorted, we can exit early
}
Profiler.Done(20);
}

View File

@ -59,7 +59,8 @@ namespace TrueCraft.Handlers
// Send setup packets
remoteClient.QueuePacket(new LoginResponsePacket(client.Entity.EntityID, 0, Dimension.Overworld));
remoteClient.UpdateChunks();
server.Scheduler.ScheduleEvent("client.update-chunks", remoteClient,
TimeSpan.Zero, s => remoteClient.UpdateChunks());
remoteClient.QueuePacket(new WindowItemsPacket(0, remoteClient.Inventory.GetSlots()));
remoteClient.QueuePacket(new UpdateHealthPacket((remoteClient.Entity as PlayerEntity).Health));
remoteClient.QueuePacket(new SpawnPositionPacket((int)remoteClient.Entity.Position.X,

View File

@ -125,6 +125,10 @@ namespace TrueCraft
public void Start(IPEndPoint endPoint)
{
Scheduler.DisabledEvents.Clear();
if (Program.ServerConfiguration.DisabledEvents != null)
Program.ServerConfiguration.DisabledEvents.ToList().ForEach(
ev => Scheduler.DisabledEvents.Add(ev));
ShuttingDown = false;
Time.Reset();
Time.Start();
@ -156,16 +160,6 @@ namespace TrueCraft
DisconnectClient(c);
}
public void Pause()
{
EnvironmentWorker.Change(Timeout.Infinite, Timeout.Infinite);
}
public void Resume()
{
EnvironmentWorker.Change(0, Timeout.Infinite);
}
public void AddWorld(IWorld world)
{
Worlds.Add(world);
@ -183,7 +177,8 @@ namespace TrueCraft
void HandleChunkLoaded(object sender, ChunkLoadedEventArgs e)
{
ChunksToSchedule.Add(new Tuple<IWorld, IChunk>(sender as IWorld, e.Chunk));
if (Program.ServerConfiguration.EnableEventLoading)
ChunksToSchedule.Add(new Tuple<IWorld, IChunk>(sender as IWorld, e.Chunk));
if (Program.ServerConfiguration.EnableLighting)
{
var lighter = WorldLighters.SingleOrDefault(l => l.World == sender);
@ -234,9 +229,9 @@ namespace TrueCraft
}
else
{
for (int i = 0; i < e.Chunk.SkyLight.Data.Length; i++)
for (int i = 0; i < e.Chunk.SkyLight.Length * 2; i++)
{
e.Chunk.SkyLight.Data[i] = 0xFF;
e.Chunk.SkyLight[i] = 0xF;
}
}
HandleChunkLoaded(sender, e);
@ -427,11 +422,14 @@ namespace TrueCraft
Profiler.Done();
}
Profiler.Start("environment.chunks");
Tuple<IWorld, IChunk> t;
if (ChunksToSchedule.TryTake(out t))
ScheduleUpdatesForChunk(t.Item1, t.Item2);
Profiler.Done();
if (Program.ServerConfiguration.EnableEventLoading)
{
Profiler.Start("environment.chunks");
Tuple<IWorld, IChunk> t;
if (ChunksToSchedule.TryTake(out t))
ScheduleUpdatesForChunk(t.Item1, t.Item2);
Profiler.Done();
}
Profiler.Done(MillisecondsPerTick);
long end = Time.ElapsedMilliseconds;

View File

@ -109,20 +109,24 @@ namespace TrueCraft
Server.ChatMessageReceived += HandleChatMessageReceived;
Server.Start(new IPEndPoint(IPAddress.Parse(ServerConfiguration.ServerAddress), ServerConfiguration.ServerPort));
Console.CancelKeyPress += HandleCancelKeyPress;
Server.Scheduler.ScheduleEvent("world.save", null,
TimeSpan.FromSeconds(ServerConfiguration.WorldSaveInterval), SaveWorlds);
while (true)
{
Thread.Sleep(1000 * ServerConfiguration.WorldSaveInterval);
Server.Pause();
Server.Log(LogCategory.Notice, "Saving world...");
foreach (var w in Server.Worlds)
{
w.Save();
}
Server.Log(LogCategory.Notice, "Done.");
Server.Resume();
Thread.Yield();
}
}
static void SaveWorlds(IMultiplayerServer server)
{
Server.Log(LogCategory.Notice, "Saving world...");
foreach (var w in Server.Worlds)
w.Save();
Server.Log(LogCategory.Notice, "Done.");
server.Scheduler.ScheduleEvent("world.save", null,
TimeSpan.FromSeconds(ServerConfiguration.WorldSaveInterval), SaveWorlds);
}
static void HandleCancelKeyPress(object sender, ConsoleCancelEventArgs e)
{
Server.Stop();

View File

@ -22,6 +22,7 @@ using fNbt;
using TrueCraft.API.Logging;
using TrueCraft.API.Logic;
using TrueCraft.Exceptions;
using TrueCraft.Profiling;
namespace TrueCraft
{
@ -29,7 +30,7 @@ namespace TrueCraft
{
public RemoteClient(IMultiplayerServer server, IPacketReader packetReader, PacketHandler[] packetHandlers, Socket connection)
{
LoadedChunks = new List<Coordinates2D>();
LoadedChunks = new HashSet<Coordinates2D>();
Server = server;
Inventory = new InventoryWindow(server.CraftingRepository);
InventoryWindow.WindowChange += HandleWindowChange;
@ -145,7 +146,7 @@ namespace TrueCraft
}
internal int ChunkRadius { get; set; }
internal IList<Coordinates2D> LoadedChunks { get; set; }
internal HashSet<Coordinates2D> LoadedChunks { get; set; }
public bool DataAvailable
{
@ -398,8 +399,10 @@ namespace TrueCraft
if (ChunkRadius < 8) // TODO: Allow customization of this number
{
ChunkRadius++;
UpdateChunks();
server.Scheduler.ScheduleEvent("remote.chunks", this, TimeSpan.FromSeconds(1), ExpandChunkRadius);
server.Scheduler.ScheduleEvent("client.update-chunks", this,
TimeSpan.Zero, s => UpdateChunks());
server.Scheduler.ScheduleEvent("remote.chunks", this,
TimeSpan.FromSeconds(1), ExpandChunkRadius);
}
});
}
@ -412,67 +415,77 @@ namespace TrueCraft
internal void UpdateChunks()
{
var newChunks = new List<Coordinates2D>();
var newChunks = new HashSet<Coordinates2D>();
var toLoad = new List<Tuple<Coordinates2D, IChunk>>();
Profiler.Start("client.new-chunks");
for (int x = -ChunkRadius; x < ChunkRadius; x++)
{
for (int z = -ChunkRadius; z < ChunkRadius; z++)
{
newChunks.Add(new Coordinates2D(
var coords = new Coordinates2D(
((int)Entity.Position.X >> 4) + x,
((int)Entity.Position.Z >> 4) + z));
((int)Entity.Position.Z >> 4) + z);
newChunks.Add(coords);
if (!LoadedChunks.Contains(coords))
toLoad.Add(new Tuple<Coordinates2D, IChunk>(
coords, World.GetChunk(coords, generate: false)));
}
}
// Unload extraneous columns
lock (LoadedChunks)
Profiler.Done();
Task.Factory.StartNew(() =>
{
var currentChunks = new List<Coordinates2D>(LoadedChunks);
foreach (Coordinates2D chunk in currentChunks)
Profiler.Start("client.encode-chunks");
foreach (var tup in toLoad)
{
if (!newChunks.Contains(chunk))
UnloadChunk(chunk);
var coords = tup.Item1;
var chunk = tup.Item2;
if (chunk == null)
chunk = World.GetChunk(coords);
chunk.LastAccessed = DateTime.UtcNow;
LoadChunk(chunk);
}
// Load new columns
foreach (Coordinates2D chunk in newChunks)
{
if (!LoadedChunks.Contains(chunk))
LoadChunk(chunk);
}
}
Profiler.Done();
});
Profiler.Start("client.old-chunks");
LoadedChunks.IntersectWith(newChunks);
Profiler.Done();
Profiler.Start("client.update-entities");
((EntityManager)Server.GetEntityManagerForWorld(World)).UpdateClientEntities(this);
Profiler.Done();
}
internal void UnloadAllChunks()
{
lock (LoadedChunks)
while (LoadedChunks.Any())
{
while (LoadedChunks.Any())
{
UnloadChunk(LoadedChunks[0]);
}
UnloadChunk(LoadedChunks.First());
}
}
internal void LoadChunk(Coordinates2D position)
internal void LoadChunk(IChunk chunk)
{
var chunk = World.GetChunk(position);
chunk.LastAccessed = DateTime.UtcNow;
QueuePacket(new ChunkPreamblePacket(chunk.Coordinates.X, chunk.Coordinates.Z));
QueuePacket(CreatePacket(chunk));
LoadedChunks.Add(position);
foreach (var kvp in chunk.TileEntities)
{
var coords = kvp.Key;
var descriptor = new BlockDescriptor
Server.Scheduler.ScheduleEvent("client.finalize-chunks", this,
TimeSpan.Zero, server =>
{
Coordinates = coords + new Coordinates3D(chunk.X, 0, chunk.Z),
Metadata = chunk.GetMetadata(coords),
ID = chunk.GetBlockID(coords),
BlockLight = chunk.GetBlockLight(coords),
SkyLight = chunk.GetSkyLight(coords)
};
var provider = Server.BlockRepository.GetBlockProvider(descriptor.ID);
provider.TileEntityLoadedForClient(descriptor, World, kvp.Value, this);
}
return;
LoadedChunks.Add(chunk.Coordinates);
foreach (var kvp in chunk.TileEntities)
{
var coords = kvp.Key;
var descriptor = new BlockDescriptor
{
Coordinates = coords + new Coordinates3D(chunk.X, 0, chunk.Z),
Metadata = chunk.GetMetadata(coords),
ID = chunk.GetBlockID(coords),
BlockLight = chunk.GetBlockLight(coords),
SkyLight = chunk.GetSkyLight(coords)
};
var provider = Server.BlockRepository.GetBlockProvider(descriptor.ID);
provider.TileEntityLoadedForClient(descriptor, World, kvp.Value, this);
}
});
}
internal void UnloadChunk(Coordinates2D position)
@ -511,19 +524,20 @@ namespace TrueCraft
var X = chunk.Coordinates.X;
var Z = chunk.Coordinates.Z;
const int blocksPerChunk = Chunk.Width * Chunk.Height * Chunk.Depth;
const int bytesPerChunk = (int)(blocksPerChunk * 2.5);
byte[] data = new byte[bytesPerChunk];
Buffer.BlockCopy(chunk.Blocks, 0, data, 0, chunk.Blocks.Length);
Buffer.BlockCopy(chunk.Metadata.Data, 0, data, chunk.Blocks.Length, chunk.Metadata.Data.Length);
Buffer.BlockCopy(chunk.BlockLight.Data, 0, data, chunk.Blocks.Length + chunk.Metadata.Data.Length, chunk.BlockLight.Data.Length);
Buffer.BlockCopy(chunk.SkyLight.Data, 0, data, chunk.Blocks.Length + chunk.Metadata.Data.Length
+ chunk.BlockLight.Data.Length, chunk.SkyLight.Data.Length);
var result = ZlibStream.CompressBuffer(data);
return new ChunkDataPacket(X * Chunk.Width, 0, Z * Chunk.Depth, Chunk.Width, Chunk.Height, Chunk.Depth, result);
Profiler.Start("client.encode-chunks.compress");
byte[] result;
using (var ms = new MemoryStream())
{
using (var deflate = new ZlibStream(new MemoryStream(chunk.Data),
CompressionMode.Compress,
CompressionLevel.BestSpeed))
deflate.CopyTo(ms);
result = ms.ToArray();
}
Profiler.Done();
return new ChunkDataPacket(X * Chunk.Width, 0, Z * Chunk.Depth,
Chunk.Width, Chunk.Height, Chunk.Depth, result);
}
public void Dispose()

View File

@ -48,6 +48,8 @@ namespace TrueCraft
Query = true;
QueryPort = 25566;
EnableLighting = true;
EnableEventLoading = true;
DisabledEvents = new string[0];
}
[YamlMember(Alias = "motd")]
@ -76,5 +78,11 @@ namespace TrueCraft
[YamlMember(Alias = "enable-lighting")]
public bool EnableLighting { get; set; }
[YamlMember(Alias = "enable-event-loading")]
public bool EnableEventLoading { get; set; }
[YamlMember(Alias = "disable-events")]
public string[] DisabledEvents { get; set; }
}
}

View File

@ -34,12 +34,12 @@
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="Ionic.Zip.Reduced">
<HintPath>..\lib\Ionic.Zip.Reduced.dll</HintPath>
</Reference>
<Reference Include="YamlDotNet">
<HintPath>..\packages\YamlDotNet.3.9.0\lib\net35\YamlDotNet.dll</HintPath>
</Reference>
<Reference Include="DotNetZip">
<HintPath>..\packages\DotNetZip.1.10.1\lib\net20\DotNetZip.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="AccessConfiguration.cs" />

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="DotNetZip" version="1.10.1" targetFramework="net45" />
<package id="YamlDotNet" version="3.9.0" targetFramework="net45" />
</packages>