diff --git a/src/Hypercube.Core/IO/Parsers/Yaml/YamlParser.cs b/src/Hypercube.Core/IO/Parsers/Yaml/YamlParser.cs
new file mode 100644
index 0000000..b820fa4
--- /dev/null
+++ b/src/Hypercube.Core/IO/Parsers/Yaml/YamlParser.cs
@@ -0,0 +1,155 @@
+using System.Text;
+
+namespace Hypercube.Core.IO.Parsers.Yaml;
+
+public static class YamlParser
+{
+ // Private constants for quotes, escape characters, and comment symbol
+ private const char DoubleQuote = '"';
+ private const char SingleQuote = '\'';
+ private const char EscapeChar = '\\';
+ private const char CommentChar = '#';
+
+ ///
+ /// Parses the provided YAML string into a dictionary of prototypes.
+ ///
+ /// The YAML string to be parsed.
+ /// A dictionary with prototype IDs as keys and field dictionaries as values.
+ public static Dictionary> ParseYaml(string yamlData)
+ {
+ var result = new Dictionary>();
+ var lines = yamlData.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+
+ string? currentId = null;
+
+ var currentFields = new Dictionary();
+ var inString = false;
+
+ foreach (var line in lines)
+ {
+ var cleanedLine = ProcessLine(line, ref inString);
+
+ // Ignore empty or commented-out lines
+ if (string.IsNullOrWhiteSpace(cleanedLine))
+ continue;
+
+ var parts = cleanedLine.Split(':', 2, StringSplitOptions.TrimEntries);
+
+ // Process a new prototype section (ID and fields)
+ if (!line.StartsWith(" ") && cleanedLine.Contains(":"))
+ {
+ if (currentId != null)
+ result[currentId] = currentFields;
+
+ currentId = parts[0];
+ currentFields = new Dictionary();
+ continue;
+ }
+
+ if (!line.StartsWith(" "))
+ continue;
+
+ if (parts.Length != 2)
+ continue;
+
+ currentFields[parts[0]] = RemoveQuotesAndEscape(parts[1]);
+ }
+
+ // Add the last prototype if present
+ if (currentId != null)
+ result[currentId] = currentFields;
+
+ return result;
+ }
+
+ ///
+ /// Processes a line by stripping out comments and handling quote and escape characters.
+ ///
+ /// The line to process.
+ /// Indicates whether we are currently inside a string.
+ /// A cleaned-up line with comments removed and quotes handled.
+ private static string ProcessLine(string line, ref bool inString)
+ {
+ var cleanedLine = string.Empty;
+ foreach (var ch in line)
+ {
+ // Toggle string state on encountering a quote
+ if (ch is DoubleQuote or SingleQuote)
+ inString = !inString;
+
+ // Ignore comments if not inside a string
+ // And stop processing the line at the comment symbol
+ if (ch == CommentChar && !inString)
+ break;
+
+ cleanedLine += ch;
+ }
+
+ return cleanedLine.Trim();
+ }
+
+ ///
+ /// Removes surrounding quotes and handles escape sequences inside the string.
+ ///
+ /// The value to process.
+ /// The value with quotes removed and escape sequences properly handled.
+ private static string RemoveQuotesAndEscape(string value)
+ {
+ // Check for open/close quote mismatch
+ var startsWithQuote = value.StartsWith(DoubleQuote) || value.StartsWith(SingleQuote);
+ var endsWithQuote = value.EndsWith(DoubleQuote) || value.EndsWith(SingleQuote);
+
+ if (startsWithQuote && !endsWithQuote)
+ throw new FormatException("String starts with a quote but does not end with one.");
+
+ if (!startsWithQuote && endsWithQuote)
+ throw new FormatException("String ends with a quote but does not start with one.");
+
+ // Remove surrounding quotes if they exist
+ if ((value.StartsWith(DoubleQuote) && value.EndsWith(DoubleQuote)) ||
+ (value.StartsWith(SingleQuote) && value.EndsWith(SingleQuote)))
+ {
+ value = value.Substring(1, value.Length - 2); // Remove quotes
+ }
+
+ var result = string.Empty;
+ var isEscaped = false;
+
+ // Process each character in the string
+ foreach (var currentChar in value)
+ {
+ // If we're escaping the next character
+ if (isEscaped)
+ {
+ result += currentChar switch
+ {
+ 'n' => '\n', // Handle newline escape
+ 't' => '\t', // Handle tab escape
+ 'r' => '\r', // Handle carriage return escape
+ '\\' => '\\', // Handle escaped backslash
+ '"' => '\"', // Handle escaped double quote
+ '\'' => '\'', // Handle escaped single quote
+ _ => throw new FormatException($"Invalid escape sequence: \\{currentChar}")
+ };
+
+ isEscaped = false;
+ continue;
+ }
+
+ // Detect escape sequence
+ if (currentChar == EscapeChar)
+ {
+ isEscaped = true;
+ continue;
+ }
+
+ result += currentChar;
+ }
+
+ // If we're still escaping after the loop, it's an invalid escape sequence
+ if (isEscaped)
+ throw new FormatException("String ends with an incomplete escape sequence.");
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/src/Hypercube.Core/IO/Prototypes/IPrototype.cs b/src/Hypercube.Core/IO/Prototypes/IPrototype.cs
new file mode 100644
index 0000000..24cb97f
--- /dev/null
+++ b/src/Hypercube.Core/IO/Prototypes/IPrototype.cs
@@ -0,0 +1,6 @@
+namespace Hypercube.Core.IO.Prototypes;
+
+public interface IPrototype
+{
+ string Id { get; set; }
+}
\ No newline at end of file
diff --git a/src/Hypercube.Core/IO/Prototypes/PrototypeAttribute.cs b/src/Hypercube.Core/IO/Prototypes/PrototypeAttribute.cs
new file mode 100644
index 0000000..23f5c22
--- /dev/null
+++ b/src/Hypercube.Core/IO/Prototypes/PrototypeAttribute.cs
@@ -0,0 +1,12 @@
+namespace Hypercube.Core.IO.Prototypes;
+
+[AttributeUsage(AttributeTargets.Class, Inherited = false)]
+public class PrototypeAttribute : Attribute
+{
+ public readonly string Id;
+
+ public PrototypeAttribute(string id)
+ {
+ Id = id;
+ }
+}
\ No newline at end of file
diff --git a/src/Hypercube.Core/IO/Prototypes/PrototypeId.cs b/src/Hypercube.Core/IO/Prototypes/PrototypeId.cs
new file mode 100644
index 0000000..02c170f
--- /dev/null
+++ b/src/Hypercube.Core/IO/Prototypes/PrototypeId.cs
@@ -0,0 +1,50 @@
+namespace Hypercube.Core.IO.Prototypes;
+
+public readonly struct PrototypeId where T : IPrototype
+{
+ public readonly string Id;
+
+ public PrototypeId(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ throw new ArgumentException("Prototype ID cannot be null or empty.", nameof(id));
+
+ Id = id;
+ }
+
+ public override string ToString()
+ {
+ return Id;
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is PrototypeId other && string.Equals(Id, other.Id, StringComparison.Ordinal);
+ }
+
+ public override int GetHashCode()
+ {
+ return Id.GetHashCode();
+ }
+
+ public static implicit operator string(PrototypeId prototypeId)
+ {
+ return prototypeId.Id;
+ }
+
+ public static explicit operator PrototypeId(string id)
+ {
+ return new PrototypeId(id);
+ }
+
+
+ public static bool operator ==(PrototypeId left, PrototypeId right)
+ {
+ return left.Equals(right);
+ }
+
+ public static bool operator !=(PrototypeId left, PrototypeId right)
+ {
+ return !(left == right);
+ }
+}
\ No newline at end of file
diff --git a/src/Hypercube.Core/IO/Prototypes/Storage/IPrototypeStorage.cs b/src/Hypercube.Core/IO/Prototypes/Storage/IPrototypeStorage.cs
new file mode 100644
index 0000000..7709072
--- /dev/null
+++ b/src/Hypercube.Core/IO/Prototypes/Storage/IPrototypeStorage.cs
@@ -0,0 +1,12 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace Hypercube.Core.IO.Prototypes.Storage;
+
+public interface IPrototypeStorage
+{
+ T GetPrototype(PrototypeId id) where T : class, IPrototype;
+ bool TryGetPrototype(PrototypeId id, [NotNullWhen(true)] out T? prototype) where T : class, IPrototype;
+ bool HasPrototype(PrototypeId id) where T : class, IPrototype;
+ IEnumerable EnumeratePrototypes() where T : class, IPrototype;
+ void LoadPrototypes(string yamlData);
+}
\ No newline at end of file
diff --git a/src/Hypercube.Core/IO/Prototypes/Storage/PrototypeStorage.cs b/src/Hypercube.Core/IO/Prototypes/Storage/PrototypeStorage.cs
new file mode 100644
index 0000000..abd34f6
--- /dev/null
+++ b/src/Hypercube.Core/IO/Prototypes/Storage/PrototypeStorage.cs
@@ -0,0 +1,93 @@
+using System.Collections.Frozen;
+using System.Diagnostics.CodeAnalysis;
+using Hypercube.Core.IO.Parsers.Yaml;
+using Hypercube.Utilities.Helpers;
+
+namespace Hypercube.Core.IO.Prototypes.Storage;
+
+public class PrototypeStorage : IPrototypeStorage
+{
+ private readonly FrozenDictionary _types;
+ private readonly Dictionary _prototypes = new();
+
+ public PrototypeStorage()
+ {
+ var types = new Dictionary();
+ foreach (var (type, attribute) in ReflectionHelper.GetAllTypesWithAttribute())
+ {
+ types[attribute.Id] = type;
+ }
+
+ _types = types.ToFrozenDictionary();
+ }
+
+ public T GetPrototype(PrototypeId id) where T : class, IPrototype
+ {
+ if (_prototypes.TryGetValue(id.Id, out var prototype) && prototype is T typedPrototype)
+ return typedPrototype;
+
+ throw new KeyNotFoundException($"Prototype with ID '{id.Id}' not found or is of the wrong type.");
+ }
+
+ public bool TryGetPrototype(PrototypeId id, [NotNullWhen(true)] out T? prototype) where T : class, IPrototype
+ {
+ if (_prototypes.TryGetValue(id.Id, out var proto) && proto is T typedPrototype)
+ {
+ prototype = typedPrototype;
+ return true;
+ }
+
+ prototype = null;
+ return false;
+ }
+
+ public bool HasPrototype(PrototypeId id) where T : class, IPrototype
+ {
+ return _prototypes.TryGetValue(id.Id, out var proto) && proto is T;
+ }
+
+ public IEnumerable EnumeratePrototypes() where T : class, IPrototype
+ {
+ return _prototypes.Values.OfType();
+ }
+
+ public void LoadPrototypes(string yamlData)
+ {
+ var rawPrototypes = YamlParser.ParseYaml(yamlData);
+
+ foreach (var (id, fields) in rawPrototypes)
+ {
+ if (!fields.TryGetValue("type", out var value))
+ throw new InvalidOperationException($"Prototype '{id}' is missing a 'type' field.");
+
+ if (!_types.TryGetValue(value, out var prototypeType))
+ throw new InvalidOperationException($"Unknown or incompatible type '{value}' for prototype '{id}'.");
+
+ if (Activator.CreateInstance(prototypeType) is not IPrototype prototype)
+ throw new InvalidOperationException($"Failed to create prototype '{id}' of type '{value}'.");
+
+ PopulateFields(prototype, fields);
+ var property = prototypeType.GetProperty(nameof(IPrototype.Id));
+
+ if (property is null)
+ throw new InvalidOperationException($"Prototype '{id}' does not have a valid '{nameof(IPrototype.Id)}' property.");
+
+ property.SetValue(prototype, id);
+ _prototypes[id] = prototype;
+ }
+ }
+
+ private void PopulateFields(IPrototype prototype, Dictionary fields)
+ {
+ var type = prototype.GetType();
+ foreach (var (fieldName, rawValue) in fields)
+ {
+ var property = type.GetProperty(fieldName);
+ if (property is null || !property.CanWrite)
+ continue;
+
+ var convertedValue = Convert.ChangeType(rawValue, property.PropertyType);
+ property.SetValue(prototype, convertedValue);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Hypercube.UnitTests/Core/IO/Prototypes/PrototypeStorageTests.cs b/src/Hypercube.UnitTests/Core/IO/Prototypes/PrototypeStorageTests.cs
new file mode 100644
index 0000000..dc30691
--- /dev/null
+++ b/src/Hypercube.UnitTests/Core/IO/Prototypes/PrototypeStorageTests.cs
@@ -0,0 +1,200 @@
+using Hypercube.Core.IO.Prototypes;
+using Hypercube.Core.IO.Prototypes.Storage;
+
+namespace Hypercube.UnitTests.Core.IO.Prototypes;
+
+[TestFixture]
+public sealed class PrototypeStorageTests
+{
+ private readonly string _yamlData =
+ """
+ # Also parser test
+ example1:
+ type: example
+ Name: 'Prototype One' # Parser test
+ Value: 100
+
+ example2:
+ type: example
+ Name: 'Prototype Two'
+ Value: 200
+ """;
+
+ [Test]
+ public void LoadPrototypes_ShouldLoadPrototypesCorrectly()
+ {
+ // Arrange
+ var storage = new PrototypeStorage();
+
+ // Act
+ storage.LoadPrototypes(_yamlData);
+
+ // Assert
+ Assert.That(storage.HasPrototype(new PrototypeId("example1")), Is.True);
+ Assert.That(storage.HasPrototype(new PrototypeId("example2")), Is.True);
+ }
+
+ [Test]
+ public void GetPrototype_ShouldReturnCorrectPrototype()
+ {
+ // Arrange
+ var storage = new PrototypeStorage();
+ storage.LoadPrototypes(_yamlData);
+
+ // Act
+ var prototype = storage.GetPrototype(new PrototypeId("example1"));
+
+ // Assert
+ Assert.That(prototype, Is.Not.Null);
+ Assert.Multiple(() =>
+ {
+ Assert.That(prototype.Id, Is.EqualTo("example1"));
+ Assert.That(prototype.Name, Is.EqualTo("Prototype One"));
+ Assert.That(prototype.Value, Is.EqualTo(100));
+ });
+ }
+
+ [Test]
+ public void TryGetPrototype_ShouldReturnTrue_WhenPrototypeExists()
+ {
+ // Arrange
+ var storage = new PrototypeStorage();
+ storage.LoadPrototypes(_yamlData);
+
+ // Act
+ var result = storage.TryGetPrototype(new PrototypeId("example2"), out var prototype);
+
+ // Assert
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.True);
+ Assert.That(prototype, Is.Not.Null);
+ });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(prototype.Id, Is.EqualTo("example2"));
+ Assert.That(prototype.Name, Is.EqualTo("Prototype Two"));
+ Assert.That(prototype.Value, Is.EqualTo(200));
+ });
+ }
+
+ [Test]
+ public void TryGetPrototype_ShouldReturnFalse_WhenPrototypeDoesNotExist()
+ {
+ // Arrange
+ var storage = new PrototypeStorage();
+ storage.LoadPrototypes(_yamlData);
+
+ // Act
+ var result = storage.TryGetPrototype(new PrototypeId("nonexistent"), out var prototype);
+
+ // Assert
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.False);
+ Assert.That(prototype, Is.Null);
+ });
+ }
+
+ [Test]
+ public void HasPrototype_ShouldReturnTrue_WhenPrototypeExists()
+ {
+ // Arrange
+ var storage = new PrototypeStorage();
+ storage.LoadPrototypes(_yamlData);
+
+ // Act
+ var result = storage.HasPrototype(new PrototypeId("example1"));
+
+ // Assert
+ Assert.That(result, Is.True);
+ }
+
+ [Test]
+ public void HasPrototype_ShouldReturnFalse_WhenPrototypeDoesNotExist()
+ {
+ // Arrange
+ var storage = new PrototypeStorage();
+ storage.LoadPrototypes(_yamlData);
+
+ // Act
+ var result = storage.HasPrototype(new PrototypeId("nonexistent"));
+
+ // Assert
+ Assert.That(result, Is.False);
+ }
+
+ [Test]
+ public void EnumeratePrototypes_ShouldReturnAllPrototypesOfType()
+ {
+ // Arrange
+ var storage = new PrototypeStorage();
+ storage.LoadPrototypes(_yamlData);
+
+ // Act
+ var prototypes = storage.EnumeratePrototypes().ToList();
+
+ // Assert
+ Assert.Multiple(() =>
+ {
+ Assert.That(prototypes.Count, Is.EqualTo(2));
+ Assert.That(prototypes.Any(p => p.Id == "example1"), Is.True);
+ Assert.That(prototypes.Any(p => p.Id == "example2"), Is.True);
+ });
+ }
+
+ [Test]
+ public void GetPrototype_ShouldThrowException_WhenPrototypeDoesNotExist()
+ {
+ // Arrange
+ var storage = new PrototypeStorage();
+ storage.LoadPrototypes(_yamlData);
+
+ // Act & Assert
+ Assert.Throws(() =>
+ storage.GetPrototype(new PrototypeId("nonexistent"))
+ );
+ }
+
+ [Test]
+ public void LoadPrototypes_ShouldThrowException_WhenTypeIsMissing()
+ {
+ // Arrange
+ var invalidYamlData = @"
+example1:
+ Name: 'Invalid Prototype'
+ Value: 50
+";
+
+ var storage = new PrototypeStorage();
+
+ // Act & Assert
+ Assert.Throws(() => storage.LoadPrototypes(invalidYamlData));
+ }
+
+ [Test]
+ public void LoadPrototypes_ShouldThrowException_WhenTypeIsUnknown()
+ {
+ // Arrange
+ var invalidYamlData = @"
+example1:
+ type: UnknownPrototype
+ Name: 'Invalid Prototype'
+ Value: 50
+";
+
+ var storage = new PrototypeStorage();
+
+ // Act & Assert
+ Assert.Throws(() => storage.LoadPrototypes(invalidYamlData));
+ }
+
+ [Prototype("example")]
+ private class ExamplePrototype : IPrototype
+ {
+ public string Id { get; set; }
+ public string Name { get; set; }
+ public int Value { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/Hypercube.UnitTests/Core/IO/Yaml/YamlParserTests.cs b/src/Hypercube.UnitTests/Core/IO/Yaml/YamlParserTests.cs
new file mode 100644
index 0000000..54082f9
--- /dev/null
+++ b/src/Hypercube.UnitTests/Core/IO/Yaml/YamlParserTests.cs
@@ -0,0 +1,184 @@
+using Hypercube.Core.IO.Parsers.Yaml;
+
+namespace Hypercube.UnitTests.Core.IO.Yaml;
+
+[TestFixture]
+public class YamlParserTests
+{
+ [Test]
+ public void ParseYaml_ValidYaml_ShouldParseCorrectly()
+ {
+ // Arrange
+ const string yamlData =
+ """
+ # This is a comment
+ prototype1:
+ type: SomeType
+ field1: 'Value with # and \" quotes'
+ field2: "Escaped value\\nNew line"
+ prototype2:
+ type: AnotherType
+ field1: 'Another field'
+ """;
+
+ var expected = new Dictionary>
+ {
+ {
+ "prototype1", new Dictionary
+ {
+ { "type", "SomeType" },
+ { "field1", "Value with # and \" quotes" },
+ { "field2", "Escaped value\\nNew line" }
+ }
+ },
+ {
+ "prototype2", new Dictionary
+ {
+ { "type", "AnotherType" },
+ { "field1", "Another field" }
+ }
+ }
+ };
+
+ // Act
+ var result = YamlParser.ParseYaml(yamlData);
+
+ // Assert
+ CollectionAssert.AreEqual(expected, result);
+ }
+
+ [Test]
+ public void ParseYaml_YamlWithComments_ShouldIgnoreComments()
+ {
+ // Arrange
+ const string yamlData =
+ """
+ # This is a comment
+ prototype1:
+ type: SomeType
+ field1: 'Field1 value'
+ # Another comment
+ prototype2:
+ type: AnotherType
+ field2: 'Field2 value'
+ """;
+
+ var expected = new Dictionary>
+ {
+ {
+ "prototype1", new Dictionary
+ {
+ { "type", "SomeType" },
+ { "field1", "Field1 value" }
+ }
+ },
+ {
+ "prototype2", new Dictionary
+ {
+ { "type", "AnotherType" },
+ { "field2", "Field2 value" }
+ }
+ }
+ };
+
+ // Act
+ var result = YamlParser.ParseYaml(yamlData);
+
+ // Assert
+ CollectionAssert.AreEqual(expected, result);
+ }
+
+ [Test]
+ public void ParseYaml_EmptyString_ShouldReturnEmptyDictionary()
+ {
+ // Arrange
+ var yamlData = string.Empty;
+
+ // Act
+ var result = YamlParser.ParseYaml(yamlData);
+
+ // Assert
+ Assert.IsEmpty(result);
+ }
+
+ [Test]
+ public void ParseYaml_SinglePrototype_ShouldParseCorrectly()
+ {
+ // Arrange
+ const string yamlData =
+ """
+ prototype1:
+ type: MyType
+ field1: 'Some value'
+ """;
+
+ var expected = new Dictionary>
+ {
+ {
+ "prototype1", new Dictionary
+ {
+ { "type", "MyType" },
+ { "field1", "Some value" }
+ }
+ }
+ };
+
+ // Act
+ var result = YamlParser.ParseYaml(yamlData);
+
+ // Assert
+ CollectionAssert.AreEqual(expected, result);
+ }
+
+ // It's fucking test be damned,
+ // fucking parsing after parsing doesn't want to move the string stub
+ // I don't fucking know why
+ [Test]
+ public void ParseYaml_ValidYamlWithEscapedCharacters_ShouldHandleEscapeSequences()
+ {
+ // Arrange
+ const string yamlData =
+ """
+ prototype1:
+ type: SomeType
+ field1: 'Value with escaped \\n new line'
+ field2: 'Another value with \\t tab'
+ """;
+
+ var expected = new Dictionary>
+ {
+ {
+ "prototype1", new Dictionary
+ {
+ { "type", "SomeType" },
+ { "field1", "Value with escaped \\n new line" },
+ { "field2", "Another value with \\t tab" }
+ }
+ }
+ };
+
+ // Act
+ var result = YamlParser.ParseYaml(yamlData);
+
+ // Assert
+ CollectionAssert.AreEqual(expected, result);
+ }
+
+ [Test]
+ public void ParseYaml_InvalidYaml_ShouldThrowException()
+ {
+ // Arrange
+ const string yamlData =
+ """
+ prototype1:
+ type: SomeType
+ field1: 'Unmatched quote
+ prototype2:
+ type: AnotherType
+ field2: 'Valid value'
+ """;
+
+ // Act & Assert
+ Assert.Throws(() => YamlParser.ParseYaml(yamlData));
+ }
+}
\ No newline at end of file
diff --git a/src/Hypercube.UnitTests/GlobalUsings.cs b/src/Hypercube.UnitTests/GlobalUsings.cs
new file mode 100644
index 0000000..cefced4
--- /dev/null
+++ b/src/Hypercube.UnitTests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using NUnit.Framework;
\ No newline at end of file
diff --git a/src/Hypercube.UnitTests/Hypercube.UnitTests.csproj b/src/Hypercube.UnitTests/Hypercube.UnitTests.csproj
new file mode 100644
index 0000000..23732ac
--- /dev/null
+++ b/src/Hypercube.UnitTests/Hypercube.UnitTests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Hypercube.sln b/src/Hypercube.sln
index 710137c..f5bbe56 100644
--- a/src/Hypercube.sln
+++ b/src/Hypercube.sln
@@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hypercube.Utilities", "Hype
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hypercube.Graphics", "Hypercube.Graphics\Hypercube.Graphics.csproj", "{35E973E0-51C3-4398-906A-17D5D08E3589}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hypercube.UnitTests", "Hypercube.UnitTests\Hypercube.UnitTests.csproj", "{39A315E6-21A0-4308-8A68-525C6DD1A8F5}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -42,5 +44,9 @@ Global
{35E973E0-51C3-4398-906A-17D5D08E3589}.Debug|Any CPU.Build.0 = Debug|Any CPU
{35E973E0-51C3-4398-906A-17D5D08E3589}.Release|Any CPU.ActiveCfg = Release|Any CPU
{35E973E0-51C3-4398-906A-17D5D08E3589}.Release|Any CPU.Build.0 = Release|Any CPU
+ {39A315E6-21A0-4308-8A68-525C6DD1A8F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {39A315E6-21A0-4308-8A68-525C6DD1A8F5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {39A315E6-21A0-4308-8A68-525C6DD1A8F5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {39A315E6-21A0-4308-8A68-525C6DD1A8F5}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal