Browse Source

Merge pull request #88 from ManlyMarco/master

Overhaul config system
Geoffrey Horsington 5 years ago
parent
commit
851c045d6d

+ 14 - 17
BepInEx.Preloader/Patching/AssemblyPatcher.cs

@@ -277,23 +277,20 @@ namespace BepInEx.Preloader.Patching
 
 		#region Config
 
-		private static readonly ConfigWrapper<bool> ConfigDumpAssemblies = ConfigFile.CoreConfig.Wrap(
-			"Preloader",
-			"DumpAssemblies",
-			"If enabled, BepInEx will save patched assemblies into BepInEx/DumpedAssemblies.\nThis can be used by developers to inspect and debug preloader patchers.",
-			false);
-
-		private static readonly ConfigWrapper<bool> ConfigLoadDumpedAssemblies = ConfigFile.CoreConfig.Wrap(
-			"Preloader",
-			"LoadDumpedAssemblies",
-			"If enabled, BepInEx will load patched assemblies from BepInEx/DumpedAssemblies instead of memory.\nThis can be used to be able to load patched assemblies into debuggers like dnSpy.\nIf set to true, will override DumpAssemblies.",
-			false);
-
-		private static readonly ConfigWrapper<bool> ConfigBreakBeforeLoadAssemblies = ConfigFile.CoreConfig.Wrap(
-			"Preloader",
-			"BreakBeforeLoadAssemblies",
-			"If enabled, BepInEx will call Debugger.Break() once before loading patched assemblies.\nThis can be used with debuggers like dnSpy to install breakpoints into patched assemblies before they are loaded.",
-			false);
+		private static readonly ConfigEntry<bool> ConfigDumpAssemblies = ConfigFile.CoreConfig.AddSetting(
+			"Preloader", "DumpAssemblies",
+			false,
+			"If enabled, BepInEx will save patched assemblies into BepInEx/DumpedAssemblies.\nThis can be used by developers to inspect and debug preloader patchers.");
+
+		private static readonly ConfigEntry<bool> ConfigLoadDumpedAssemblies = ConfigFile.CoreConfig.AddSetting(
+			"Preloader", "LoadDumpedAssemblies",
+			false,
+			"If enabled, BepInEx will load patched assemblies from BepInEx/DumpedAssemblies instead of memory.\nThis can be used to be able to load patched assemblies into debuggers like dnSpy.\nIf set to true, will override DumpAssemblies.");
+
+		private static readonly ConfigEntry<bool> ConfigBreakBeforeLoadAssemblies = ConfigFile.CoreConfig.AddSetting(
+			"Preloader", "BreakBeforeLoadAssemblies",
+			false,
+			"If enabled, BepInEx will call Debugger.Break() once before loading patched assemblies.\nThis can be used with debuggers like dnSpy to install breakpoints into patched assemblies before they are loaded.");
 
 		#endregion
 	}

+ 29 - 36
BepInEx.Preloader/Preloader.cs

@@ -237,42 +237,35 @@ namespace BepInEx.Preloader
 
 		#region Config
 
-		private static readonly ConfigWrapper<string> ConfigEntrypointAssembly = ConfigFile.CoreConfig.Wrap(
-			"Preloader.Entrypoint",
-			"Assembly",
-			"The local filename of the assembly to target.",
-			IsPostUnity2017 ? "UnityEngine.CoreModule.dll" : "UnityEngine.dll"
-		);
-
-		private static readonly ConfigWrapper<string> ConfigEntrypointType = ConfigFile.CoreConfig.Wrap(
-			"Preloader.Entrypoint",
-			"Type",
-			"The name of the type in the entrypoint assembly to search for the entrypoint method.",
-			"Application");
-
-		private static readonly ConfigWrapper<string> ConfigEntrypointMethod = ConfigFile.CoreConfig.Wrap(
-			"Preloader.Entrypoint",
-			"Method",
-			"The name of the method in the specified entrypoint assembly and type to hook and load Chainloader from.",
-			".cctor");
-
-		private static readonly ConfigWrapper<bool> ConfigApplyRuntimePatches = ConfigFile.CoreConfig.Wrap(
-			"Preloader",
-			"ApplyRuntimePatches",
-			"Enables or disables runtime patches.\nThis should always be true, unless you cannot start the game due to a Harmony related issue (such as running .NET Standard runtime) or you know what you're doing.",
-			true);
-
-		private static readonly ConfigWrapper<bool> ConfigShimHarmony = ConfigFile.CoreConfig.Wrap(
-			"Preloader",
-			"ShimHarmonySupport",
-			"If enabled, basic Harmony functionality is patched to use MonoMod's RuntimeDetour instead.\nTry using this if Harmony does not work in a game.",
-			!Utility.CLRSupportsDynamicAssemblies);
-
-		private static readonly ConfigWrapper<bool> ConfigPreloaderCOutLogging = ConfigFile.CoreConfig.Wrap(
-			"Logging",
-			"PreloaderConsoleOutRedirection",
-			"Redirects text from Console.Out during preloader patch loading to the BepInEx logging system.",
-			true);
+		private static readonly ConfigEntry<string> ConfigEntrypointAssembly = ConfigFile.CoreConfig.AddSetting(
+			"Preloader.Entrypoint", "Assembly",
+			IsPostUnity2017 ? "UnityEngine.CoreModule.dll" : "UnityEngine.dll",
+			"The local filename of the assembly to target.");
+
+		private static readonly ConfigEntry<string> ConfigEntrypointType = ConfigFile.CoreConfig.AddSetting(
+			"Preloader.Entrypoint", "Type",
+			"Application",
+			"The name of the type in the entrypoint assembly to search for the entrypoint method.");
+
+		private static readonly ConfigEntry<string> ConfigEntrypointMethod = ConfigFile.CoreConfig.AddSetting(
+			"Preloader.Entrypoint", "Method",
+			".cctor",
+			"The name of the method in the specified entrypoint assembly and type to hook and load Chainloader from.");
+
+		private static readonly ConfigEntry<bool> ConfigApplyRuntimePatches = ConfigFile.CoreConfig.AddSetting(
+			"Preloader", "ApplyRuntimePatches",
+			true,
+			"Enables or disables runtime patches.\nThis should always be true, unless you cannot start the game due to a Harmony related issue (such as running .NET Standard runtime) or you know what you're doing.");
+
+		private static readonly ConfigEntry<bool> ConfigShimHarmony = ConfigFile.CoreConfig.AddSetting(
+			"Preloader", "ShimHarmonySupport",
+			!Utility.CLRSupportsDynamicAssemblies,
+			"If enabled, basic Harmony functionality is patched to use MonoMod's RuntimeDetour instead.\nTry using this if Harmony does not work in a game.");
+
+		private static readonly ConfigEntry<bool> ConfigPreloaderCOutLogging = ConfigFile.CoreConfig.AddSetting(
+			"Logging", "PreloaderConsoleOutRedirection",
+			true,
+			"Redirects text from Console.Out during preloader patch loading to the BepInEx logging system.");
 
 		#endregion
 	}

+ 25 - 0
BepInEx.sln

@@ -27,27 +27,52 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MonoMod.RuntimeDetour", "su
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MonoMod.Utils", "submodules\MonoMod\MonoMod.Utils\MonoMod.Utils.csproj", "{1839CFE2-3DB0-45A8-B03D-9AA797479A3A}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BepInExTests", "BepInExTests\BepInExTests.csproj", "{E7CD429A-D057-48E3-8C51-E5C934E8E07B}"
+	ProjectSection(ProjectDependencies) = postProject
+		{4FFBA620-F5ED-47F9-B90C-DAD1316FD9B9} = {4FFBA620-F5ED-47F9-B90C-DAD1316FD9B9}
+	EndProjectSection
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|Any CPU = Debug|Any CPU
 		Release|Any CPU = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{4FFBA620-F5ED-47F9-B90C-DAD1316FD9B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{4FFBA620-F5ED-47F9-B90C-DAD1316FD9B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{4FFBA620-F5ED-47F9-B90C-DAD1316FD9B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{4FFBA620-F5ED-47F9-B90C-DAD1316FD9B9}.Release|Any CPU.Build.0 = Release|Any CPU
+		{DC89F18B-235B-4C01-AB31-AF40DCE5C4C7}.Debug|Any CPU.ActiveCfg = Release|Any CPU
+		{DC89F18B-235B-4C01-AB31-AF40DCE5C4C7}.Debug|Any CPU.Build.0 = Release|Any CPU
 		{DC89F18B-235B-4C01-AB31-AF40DCE5C4C7}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{DC89F18B-235B-4C01-AB31-AF40DCE5C4C7}.Release|Any CPU.Build.0 = Release|Any CPU
+		{6E6BC1E5-5BE8-4566-B3AE-52C4CB218AEB}.Debug|Any CPU.ActiveCfg = Release|Any CPU
+		{6E6BC1E5-5BE8-4566-B3AE-52C4CB218AEB}.Debug|Any CPU.Build.0 = Release|Any CPU
 		{6E6BC1E5-5BE8-4566-B3AE-52C4CB218AEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{6E6BC1E5-5BE8-4566-B3AE-52C4CB218AEB}.Release|Any CPU.Build.0 = Release|Any CPU
+		{54161CFE-FF42-4DDE-B161-3A49545DB5CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{54161CFE-FF42-4DDE-B161-3A49545DB5CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{54161CFE-FF42-4DDE-B161-3A49545DB5CD}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{54161CFE-FF42-4DDE-B161-3A49545DB5CD}.Release|Any CPU.Build.0 = Release|Any CPU
+		{A15D6EE6-F954-415B-8605-8A8470CC87DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{A15D6EE6-F954-415B-8605-8A8470CC87DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{A15D6EE6-F954-415B-8605-8A8470CC87DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{A15D6EE6-F954-415B-8605-8A8470CC87DC}.Release|Any CPU.Build.0 = Release|Any CPU
+		{F7ABBE07-C02F-4F7C-BF6E-C6656BF588CA}.Debug|Any CPU.ActiveCfg = Release|Any CPU
+		{F7ABBE07-C02F-4F7C-BF6E-C6656BF588CA}.Debug|Any CPU.Build.0 = Release|Any CPU
 		{F7ABBE07-C02F-4F7C-BF6E-C6656BF588CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{F7ABBE07-C02F-4F7C-BF6E-C6656BF588CA}.Release|Any CPU.Build.0 = Release|Any CPU
+		{D0C584C0-81D7-486E-B70E-D7F9256E0909}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{D0C584C0-81D7-486E-B70E-D7F9256E0909}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{D0C584C0-81D7-486E-B70E-D7F9256E0909}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{D0C584C0-81D7-486E-B70E-D7F9256E0909}.Release|Any CPU.Build.0 = Release|Any CPU
+		{1839CFE2-3DB0-45A8-B03D-9AA797479A3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{1839CFE2-3DB0-45A8-B03D-9AA797479A3A}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{1839CFE2-3DB0-45A8-B03D-9AA797479A3A}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{1839CFE2-3DB0-45A8-B03D-9AA797479A3A}.Release|Any CPU.Build.0 = Release|Any CPU
+		{E7CD429A-D057-48E3-8C51-E5C934E8E07B}.Debug|Any CPU.ActiveCfg = Debug|x86
+		{E7CD429A-D057-48E3-8C51-E5C934E8E07B}.Debug|Any CPU.Build.0 = Debug|x86
+		{E7CD429A-D057-48E3-8C51-E5C934E8E07B}.Release|Any CPU.ActiveCfg = Release|x86
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE

+ 3 - 1
BepInEx.sln.DotSettings

@@ -211,5 +211,7 @@
 	<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpKeepExistingMigration/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpPlaceEmbeddedOnSameLineMigration/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpUseContinuousIndentInsideBracesMigration/@EntryIndexedValue">True</s:Boolean>
+	<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EAddAccessorOwnerDeclarationBracesMigration/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EAlwaysTreatStructAsNotReorderableMigration/@EntryIndexedValue">True</s:Boolean>
-	<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
+	<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean>
+	<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateThisQualifierSettings/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

+ 21 - 0
BepInEx/BepInEx.csproj

@@ -26,6 +26,18 @@
   <PropertyGroup>
     <StartupObject />
   </PropertyGroup>
+  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|AnyCPU'">
+    <OutputPath>bin\Debug\</OutputPath>
+    <DefineConstants>TRACE;DEBUG</DefineConstants>
+    <DocumentationFile>
+    </DocumentationFile>
+    <Optimize>false</Optimize>
+    <PlatformTarget>x86</PlatformTarget>
+    <ErrorReport>prompt</ErrorReport>
+    <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet>
+    <DebugType>full</DebugType>
+    <DebugSymbols>true</DebugSymbols>
+  </PropertyGroup>
   <ItemGroup>
     <Reference Include="Mono.Cecil, Version=0.10.3.0, Culture=neutral, PublicKeyToken=50cebf1cceb9d05e, processorArchitecture=MSIL">
       <HintPath>..\packages\Mono.Cecil.0.10.3\lib\net35\Mono.Cecil.dll</HintPath>
@@ -37,11 +49,19 @@
     </Reference>
   </ItemGroup>
   <ItemGroup>
+    <Compile Include="Configuration\AcceptableValueBase.cs" />
+    <Compile Include="Configuration\AcceptableValueList.cs" />
+    <Compile Include="Configuration\AcceptableValueRange.cs" />
+    <Compile Include="Configuration\ConfigEntryBase.cs" />
     <Compile Include="Contract\PluginInfo.cs" />
     <Compile Include="Configuration\ConfigDefinition.cs" />
+    <Compile Include="Configuration\ConfigDescription.cs" />
     <Compile Include="Configuration\ConfigFile.cs" />
     <Compile Include="Configuration\ConfigWrapper.cs" />
+    <Compile Include="Configuration\KeyboardShortcut.cs" />
+    <Compile Include="Configuration\SettingChangedEventArgs.cs" />
     <Compile Include="Configuration\TomlTypeConverter.cs" />
+    <Compile Include="Configuration\TypeConverter.cs" />
     <Compile Include="Contract\Attributes.cs" />
     <Compile Include="ConsoleUtil\ConsoleEncoding\ConsoleEncoding.Buffers.cs" />
     <Compile Include="ConsoleUtil\ConsoleEncoding\ConsoleEncoding.cs" />
@@ -65,6 +85,7 @@
     <Compile Include="Paths.cs" />
     <Compile Include="Properties\AssemblyInfo.cs" />
     <Compile Include="Bootstrap\TypeLoader.cs" />
+    <Compile Include="ThreadingHelper.cs" />
     <Compile Include="Utility.cs" />
   </ItemGroup>
   <ItemGroup>

+ 36 - 43
BepInEx/Bootstrap/Chainloader.cs

@@ -7,7 +7,6 @@ using System.Linq;
 using System.Reflection;
 using System.Text;
 using System.Text.RegularExpressions;
-using BepInEx.Contract;
 using Mono.Cecil;
 using UnityEngine;
 using UnityInjector.ConsoleUtil;
@@ -46,6 +45,8 @@ namespace BepInEx.Bootstrap
 			if (_initialized)
 				return;
 
+			ThreadingHelper.Initialize();
+
 			// Set vitals
 			if (gameExePath != null)
 			{
@@ -74,10 +75,7 @@ namespace BepInEx.Bootstrap
 			Logger.Listeners.Add(new UnityLogListener());
 
 			if (ConfigDiskLogging.Value)
-			{
-				var logLevel = (LogLevel)Enum.Parse(typeof(LogLevel), ConfigDiskConsoleDisplayedLevel.Value, true);
-				Logger.Listeners.Add(new DiskLogListener("LogOutput.log", logLevel, ConfigDiskAppend.Value, ConfigDiskWriteUnityLog.Value));
-			}
+				Logger.Listeners.Add(new DiskLogListener("LogOutput.log", ConfigDiskConsoleDisplayedLevel.Value, ConfigDiskAppend.Value, ConfigDiskWriteUnityLog.Value));
 
 			if (!TraceLogSource.IsListening)
 				Logger.Sources.Add(TraceLogSource.CreateSource());
@@ -262,7 +260,7 @@ namespace BepInEx.Bootstrap
 
 					if (dependsOnInvalidPlugin)
 					{
-						string message = $"Skipping [{pluginInfo.Metadata.Name}] because it has a dependency that was not loaded. See above errors for details.";
+						string message = $"Skipping [{pluginInfo.Metadata.Name}] because it has a dependency that was not loaded. See previous errors for details.";
 						DependencyErrors.Add(message);
 						Logger.LogWarning(message);
 						continue;
@@ -270,14 +268,11 @@ namespace BepInEx.Bootstrap
 
 					if (missingDependencies.Count != 0)
 					{
-						string ToMissingString(BepInDependency s)
-						{
-							bool emptyVersion = s.MinimumVersion.Major == 0 && s.MinimumVersion.Minor == 0 && s.MinimumVersion.Build == 0 && s.MinimumVersion.Revision == 0;
-							if (emptyVersion) return "- " + s.DependencyGUID;
-							return $"- {s.DependencyGUID} (at least v{s.MinimumVersion})";
-						}
+						bool IsEmptyVersion(Version v) => v.Major == 0 && v.Minor == 0 && v.Build <= 0 && v.Revision <= 0;
 
-						string message = $@"Could not load [{pluginInfo.Metadata.Name}] because it has missing dependencies: {string.Join(", ", missingDependencies.Select(ToMissingString).ToArray())}";
+						string message = $@"Could not load [{pluginInfo.Metadata.Name}] because it has missing dependencies: {
+							string.Join(", ", missingDependencies.Select(s => IsEmptyVersion(s.MinimumVersion) ? s.DependencyGUID : $"{s.DependencyGUID} (v{s.MinimumVersion} or newer)").ToArray())
+							}";
 						DependencyErrors.Add(message);
 						Logger.LogError(message);
 
@@ -287,7 +282,9 @@ namespace BepInEx.Bootstrap
 
 					if (incompatibilities.Count != 0)
 					{
-						string message = $@"Could not load [{pluginInfo.Metadata.Name}] because it is incompatible with: {string.Join(", ", incompatibilities.Select(i => i.IncompatibilityGUID).ToArray())}";
+						string message = $@"Could not load [{pluginInfo.Metadata.Name}] because it is incompatible with: {
+							string.Join(", ", incompatibilities.Select(i => i.IncompatibilityGUID).ToArray())
+							}";
 						DependencyErrors.Add(message);
 						Logger.LogError(message);
 
@@ -335,35 +332,31 @@ namespace BepInEx.Bootstrap
 
 		#region Config
 
-		private static readonly ConfigWrapper<bool> ConfigUnityLogging = ConfigFile.CoreConfig.Wrap(
-			"Logging",
-			"UnityLogListening",
-			"Enables showing unity log messages in the BepInEx logging system.",
-			true);
-
-		private static readonly ConfigWrapper<bool> ConfigDiskLogging = ConfigFile.CoreConfig.Wrap(
-			"Logging.Disk",
-			"Enabled",
-			"Enables writing log messages to disk.",
-			true);
-
-		private static readonly ConfigWrapper<string> ConfigDiskConsoleDisplayedLevel = ConfigFile.CoreConfig.Wrap(
-			"Logging.Disk",
-			"DisplayedLogLevel",
-			"Only displays the specified log level and above in the console output.",
-			"Info");
-
-		private static readonly ConfigWrapper<bool> ConfigDiskWriteUnityLog = ConfigFile.CoreConfig.Wrap(
-			"Logging.Disk",
-			"WriteUnityLog",
-			"Include unity log messages in log file output.",
-			false);
-
-		private static readonly ConfigWrapper<bool> ConfigDiskAppend = ConfigFile.CoreConfig.Wrap(
-			"Logging.Disk",
-			"AppendLog",
-			"Appends to the log file instead of overwriting, on game startup.",
-			false);
+
+		private static readonly ConfigEntry<bool> ConfigUnityLogging = ConfigFile.CoreConfig.AddSetting(
+			"Logging", "UnityLogListening",
+			true,
+			"Enables showing unity log messages in the BepInEx logging system.");
+
+		private static readonly ConfigEntry<bool> ConfigDiskWriteUnityLog = ConfigFile.CoreConfig.AddSetting(
+			"Logging.Disk", "WriteUnityLog",
+			false,
+			"Include unity log messages in log file output.");
+
+		private static readonly ConfigEntry<bool> ConfigDiskAppend = ConfigFile.CoreConfig.AddSetting(
+			"Logging.Disk", "AppendLog",
+			false,
+			"Appends to the log file instead of overwriting, on game startup.");
+
+		private static readonly ConfigEntry<bool> ConfigDiskLogging = ConfigFile.CoreConfig.AddSetting(
+			"Logging.Disk", "Enabled",
+			true,
+			"Enables writing log messages to disk.");
+
+		private static readonly ConfigEntry<LogLevel> ConfigDiskConsoleDisplayedLevel = ConfigFile.CoreConfig.AddSetting(
+			"Logging.Disk", "DisplayedLogLevel",
+			LogLevel.Info,
+			"Only displays the specified log level and above in the console output.");
 		#endregion
 	}
 }

+ 4 - 5
BepInEx/Bootstrap/TypeLoader.cs

@@ -257,11 +257,10 @@ namespace BepInEx.Bootstrap
 
 		#region Config
 
-		private static ConfigWrapper<bool> EnableAssemblyCache = ConfigFile.CoreConfig.Wrap(
-			"Caching", 
-			"EnableAssemblyCache", 
-			"Enable/disable assembly metadata cache\nEnabling this will speed up discovery of plugins and patchers by caching the metadata of all types BepInEx discovers.", 
-			true);
+		private static readonly ConfigEntry<bool> EnableAssemblyCache = ConfigFile.CoreConfig.AddSetting(
+			"Caching", "EnableAssemblyCache", 
+			true, 
+			"Enable/disable assembly metadata cache\nEnabling this will speed up discovery of plugins and patchers by caching the metadata of all types BepInEx discovers.");
 
 		#endregion
 	}

+ 36 - 0
BepInEx/Configuration/AcceptableValueBase.cs

@@ -0,0 +1,36 @@
+using System;
+
+namespace BepInEx.Configuration
+{
+	/// <summary>
+	/// Base type of all classes representing and enforcing acceptable values of config settings.
+	/// </summary>
+	public abstract class AcceptableValueBase
+	{
+		/// <param name="valueType">Type of values that this class can Clamp.</param>
+		protected AcceptableValueBase(Type valueType)
+		{
+			ValueType = valueType;
+		}
+
+		/// <summary>
+		/// Change the value to be acceptable, if it's not already.
+		/// </summary>
+		public abstract object Clamp(object value);
+
+		/// <summary>
+		/// Check if the value is an acceptable value.
+		/// </summary>
+		public abstract bool IsValid(object value);
+
+		/// <summary>
+		/// Type of the supported values.
+		/// </summary>
+		public Type ValueType { get; } 
+
+		/// <summary>
+		/// Get the string for use in config files.
+		/// </summary>
+		public abstract string ToDescriptionString();
+	}
+}

+ 49 - 0
BepInEx/Configuration/AcceptableValueList.cs

@@ -0,0 +1,49 @@
+using System;
+using System.Linq;
+
+namespace BepInEx.Configuration
+{
+	/// <summary>
+	/// Specify the list of acceptable values for a setting.
+	/// </summary>
+	public class AcceptableValueList<T> : AcceptableValueBase where T : IEquatable<T>
+	{
+		/// <summary>
+		/// List of values that a setting can take.
+		/// </summary>
+		public virtual T[] AcceptableValues { get; }
+
+		/// <summary>
+		/// Specify the list of acceptable values for a setting.
+		/// If the setting does not equal any of the values, it will be set to the first one.
+		/// </summary>
+		public AcceptableValueList(params T[] acceptableValues) : base(typeof(T))
+		{
+			if (acceptableValues == null) throw new ArgumentNullException(nameof(acceptableValues));
+			if (acceptableValues.Length == 0) throw new ArgumentException("At least one acceptable value is needed", nameof(acceptableValues));
+
+			AcceptableValues = acceptableValues;
+		}
+
+		/// <inheritdoc />
+		public override object Clamp(object value)
+		{
+			if (IsValid(value))
+				return value;
+
+			return AcceptableValues[0];
+		}
+
+		/// <inheritdoc />
+		public override bool IsValid(object value)
+		{
+			return value is T v && AcceptableValues.Any(x => x.Equals(v));
+		}
+
+		/// <inheritdoc />
+		public override string ToDescriptionString()
+		{
+			return "# Acceptable values: " + string.Join(", ", AcceptableValues.Select(x => x.ToString()).ToArray());
+		}
+	}
+}

+ 59 - 0
BepInEx/Configuration/AcceptableValueRange.cs

@@ -0,0 +1,59 @@
+using System;
+
+namespace BepInEx.Configuration
+{
+	/// <summary>
+	/// Specify the range of acceptable values for a setting.
+	/// </summary>
+	public class AcceptableValueRange<T> : AcceptableValueBase where T : IComparable
+	{
+		/// <param name="minValue">Lowest acceptable value</param>
+		/// <param name="maxValue">Highest acceptable value</param>
+		public AcceptableValueRange(T minValue, T maxValue) : base(typeof(T))
+		{
+			if (maxValue == null)
+				throw new ArgumentNullException(nameof(maxValue));
+			if (minValue == null)
+				throw new ArgumentNullException(nameof(minValue));
+			if (minValue.CompareTo(maxValue) >= 0)
+				throw new ArgumentException($"{nameof(minValue)} has to be lower than {nameof(maxValue)}");
+
+			MinValue = minValue;
+			MaxValue = maxValue;
+		}
+
+		/// <summary>
+		/// Lowest acceptable value
+		/// </summary>
+		public virtual T MinValue { get; }
+
+		/// <summary>
+		/// Highest acceptable value
+		/// </summary>
+		public virtual T MaxValue { get; }
+
+		/// <inheritdoc />
+		public override object Clamp(object value)
+		{
+			if (MinValue.CompareTo(value) > 0)
+				return MinValue;
+
+			if (MaxValue.CompareTo(value) < 0)
+				return MaxValue;
+
+			return value;
+		}
+
+		/// <inheritdoc />
+		public override bool IsValid(object value)
+		{
+			return MinValue.CompareTo(value) <= 0 && MaxValue.CompareTo(value) >= 0;
+		}
+
+		/// <inheritdoc />
+		public override string ToDescriptionString()
+		{
+			return $"# Acceptable value range: From {MinValue} to {MaxValue}";
+		}
+	}
+}

+ 63 - 13
BepInEx/Configuration/ConfigDefinition.cs

@@ -1,37 +1,75 @@
-namespace BepInEx.Configuration
+using System;
+using System.Linq;
+
+namespace BepInEx.Configuration
 {
-	public class ConfigDefinition
+	/// <summary>
+	/// Section and key of a setting. Used as a unique key for identification within a <see cref="T:BepInEx.Configuration.ConfigFile" />.
+	/// The same definition can be used in multiple config files, it will point to different settings then.
+	/// </summary>
+	/// <inheritdoc />
+	public class ConfigDefinition : IEquatable<ConfigDefinition>
 	{
+		/// <summary>
+		/// Group of the setting. All settings within a config file are grouped by this.
+		/// </summary>
 		public string Section { get; }
 
+		/// <summary>
+		/// Name of the setting.
+		/// </summary>
 		public string Key { get; }
 
-		public string Description { get; internal set; }
-
-		public ConfigDefinition(string section, string key, string description = null)
+		/// <summary>
+		/// Create a new definition. Definitions with same section and key are equal.
+		/// </summary>
+		/// <param name="section">Group of the setting, case sensitive.</param>
+		/// <param name="key">Name of the setting, case sensitive.</param>
+		public ConfigDefinition(string section, string key)
 		{
+			CheckInvalidConfigChars(section, nameof(section));
+			CheckInvalidConfigChars(key, nameof(key));
 			Key = key;
 			Section = section;
+		}
 
-			Description = description;
+		private static readonly char[] _invalidConfigChars = { '=', '\n', '\t', '\\', '"', '\'' };
+		private static void CheckInvalidConfigChars(string val, string name)
+		{
+			if (val == null) throw new ArgumentNullException(name);
+			if (val != val.Trim()) throw new ArgumentException("Cannot use whitespace characters at start or end of section and key names", name);
+			if (val.Any(c => _invalidConfigChars.Contains(c))) throw new ArgumentException(@"Cannot use any of the following characters in section and key names: = \n \t \ "" '", name);
+		}
+
+		/// <inheritdoc />
+		[Obsolete("description argument is no longer used, put it in a ConfigDescription instead")]
+		public ConfigDefinition(string section, string key, string description) : this(section, key) { }
+
+		/// <summary>
+		/// Check if the definitions are the same.
+		/// </summary>
+		/// <inheritdoc />
+		public bool Equals(ConfigDefinition other)
+		{
+			if (other == null) return false;
+			return string.Equals(Key, other.Key)
+				   && string.Equals(Section, other.Section);
 		}
 
+		/// <summary>
+		/// Check if the definitions are the same.
+		/// </summary>
 		public override bool Equals(object obj)
 		{
 			if (ReferenceEquals(null, obj))
 				return false;
 			if (ReferenceEquals(this, obj))
 				return true;
-			if (obj.GetType() != this.GetType())
-				return false;
 
-			if (!(obj is ConfigDefinition other))
-				return false;
-
-			return string.Equals(Key, other.Key)
-				   && string.Equals(Section, other.Section);
+			return Equals(obj as ConfigDefinition);
 		}
 
+		/// <inheritdoc />
 		public override int GetHashCode()
 		{
 			unchecked
@@ -42,10 +80,22 @@
 			}
 		}
 
+		/// <summary>
+		/// Check if the definitions are the same.
+		/// </summary>
 		public static bool operator ==(ConfigDefinition left, ConfigDefinition right)
 			=> Equals(left, right);
 
+		/// <summary>
+		/// Check if the definitions are the same.
+		/// </summary>
 		public static bool operator !=(ConfigDefinition left, ConfigDefinition right)
 			=> !Equals(left, right);
+
+		/// <inheritdoc />
+		public override string ToString()
+		{
+			return Section + "." + Key;
+		}
 	}
 }

+ 43 - 0
BepInEx/Configuration/ConfigDescription.cs

@@ -0,0 +1,43 @@
+using System;
+
+namespace BepInEx.Configuration
+{
+	/// <summary>
+	/// Metadata of a <see cref="ConfigEntryBase"/>.
+	/// </summary>
+	public class ConfigDescription
+	{
+		/// <summary>
+		/// Create a new description.
+		/// </summary>
+		/// <param name="description">Text describing the function of the setting and any notes or warnings.</param>
+		/// <param name="acceptableValues">Range of values that this setting can take. The setting's value will be automatically clamped.</param>
+		/// <param name="tags">Objects that can be used by user-made classes to add functionality.</param>
+		public ConfigDescription(string description, AcceptableValueBase acceptableValues = null, params object[] tags)
+		{
+			AcceptableValues = acceptableValues;
+			Tags = tags;
+			Description = description ?? throw new ArgumentNullException(nameof(description));
+		}
+
+		/// <summary>
+		/// Text describing the function of the setting and any notes or warnings.
+		/// </summary>
+		public string Description { get; }
+
+		/// <summary>
+		/// Range of acceptable values for a setting.
+		/// </summary>
+		public AcceptableValueBase AcceptableValues { get; }
+
+		/// <summary>
+		/// Objects that can be used by user-made classes to add functionality.
+		/// </summary>
+		public object[] Tags { get; }
+
+		/// <summary>
+		/// An empty description.
+		/// </summary>
+		public static ConfigDescription Empty { get; } = new ConfigDescription("");
+	}
+}

+ 176 - 0
BepInEx/Configuration/ConfigEntryBase.cs

@@ -0,0 +1,176 @@
+using System;
+using System.IO;
+using System.Linq;
+using BepInEx.Logging;
+
+namespace BepInEx.Configuration
+{
+	/// <summary>
+	/// Provides access to a single setting inside of a <see cref="Configuration.ConfigFile"/>.
+	/// </summary>
+	/// <typeparam name="T">Type of the setting.</typeparam>
+	public sealed class ConfigEntry<T> : ConfigEntryBase
+	{
+		/// <summary>
+		/// Fired when the setting is changed. Does not detect changes made outside from this object.
+		/// </summary>
+		public event EventHandler SettingChanged;
+
+		private T _typedValue;
+
+		/// <summary>
+		/// Value of this setting.
+		/// </summary>
+		public T Value
+		{
+			get => _typedValue;
+			set
+			{
+				value = ClampValue(value);
+				if (Equals(_typedValue, value))
+					return;
+
+				_typedValue = value;
+				OnSettingChanged(this);
+			}
+		}
+
+		/// <inheritdoc />
+		public override object BoxedValue
+		{
+			get => Value;
+			set => Value = (T)value;
+		}
+
+		internal ConfigEntry(ConfigFile configFile, ConfigDefinition definition, T defaultValue, ConfigDescription configDescription) : base(configFile, definition, typeof(T), defaultValue, configDescription)
+		{
+			configFile.SettingChanged += (sender, args) =>
+			{
+				if (args.ChangedSetting == this) SettingChanged?.Invoke(sender, args);
+			};
+		}
+	}
+
+	/// <summary>
+	/// Container for a single setting of a <see cref="Configuration.ConfigFile"/>. 
+	/// Each config entry is linked to one config file.
+	/// </summary>
+	public abstract class ConfigEntryBase
+	{
+		/// <summary>
+		/// Types of defaultValue and definition.AcceptableValues have to be the same as settingType.
+		/// </summary>
+		internal ConfigEntryBase(ConfigFile configFile, ConfigDefinition definition, Type settingType, object defaultValue, ConfigDescription configDescription)
+		{
+			ConfigFile = configFile ?? throw new ArgumentNullException(nameof(configFile));
+			Definition = definition ?? throw new ArgumentNullException(nameof(definition));
+			SettingType = settingType ?? throw new ArgumentNullException(nameof(settingType));
+
+			Description = configDescription ?? ConfigDescription.Empty;
+			if (Description.AcceptableValues != null && !SettingType.IsAssignableFrom(Description.AcceptableValues.ValueType))
+				throw new ArgumentException("configDescription.AcceptableValues is for a different type than the type of this setting");
+
+			DefaultValue = defaultValue;
+
+			// Free type check and automatically calls ClampValue in case AcceptableValues were provided
+			BoxedValue = defaultValue;
+		}
+
+		/// <summary>
+		/// Config file this entry is a part of.
+		/// </summary>
+		public ConfigFile ConfigFile { get; }
+
+		/// <summary>
+		/// Category and name of this setting. Used as a unique key for identification within a <see cref="Configuration.ConfigFile"/>.
+		/// </summary>
+		public ConfigDefinition Definition { get; }
+
+		/// <summary>
+		/// Description / metadata of this setting.
+		/// </summary>
+		public ConfigDescription Description { get; }
+
+		/// <summary>
+		/// Type of the <see cref="BoxedValue"/> that this setting holds.
+		/// </summary>
+		public Type SettingType { get; }
+
+		/// <summary>
+		/// Default value of this setting (set only if the setting was not changed before).
+		/// </summary>
+		public object DefaultValue { get; }
+
+		/// <summary>
+		/// Get or set the value of the setting.
+		/// </summary>
+		public abstract object BoxedValue { get; set; }
+
+		/// <summary>
+		/// Get the serialized representation of the value.
+		/// </summary>
+		public string GetSerializedValue()
+		{
+			return TomlTypeConverter.ConvertToString(BoxedValue, SettingType);
+		}
+
+		/// <summary>
+		/// Set the value by using its serialized form.
+		/// </summary>
+		public void SetSerializedValue(string value)
+		{
+			try
+			{
+				var newValue = TomlTypeConverter.ConvertToValue(value, SettingType);
+				BoxedValue = newValue;
+			}
+			catch (Exception e)
+			{
+				Logger.Log(LogLevel.Warning, $"Config value of setting \"{Definition}\" could not be parsed and will be ignored. Reason: {e.Message}; Value: {value}");
+			}
+		}
+
+		/// <summary>
+		/// If necessary, clamp the value to acceptable value range. T has to be equal to settingType.
+		/// </summary>
+		protected T ClampValue<T>(T value)
+		{
+			if (Description.AcceptableValues != null)
+				return (T)Description.AcceptableValues.Clamp(value);
+			return value;
+		}
+
+		/// <summary>
+		/// Trigger setting changed event.
+		/// </summary>
+		protected void OnSettingChanged(object sender)
+		{
+			ConfigFile.OnSettingChanged(sender, this);
+		}
+
+		/// <summary>
+		/// Write a description of this setting using all available metadata.
+		/// </summary>
+		public void WriteDescription(StreamWriter writer)
+		{
+			if (!string.IsNullOrEmpty(Description.Description))
+				writer.WriteLine($"## {Description.Description.Replace("\n", "\n## ")}");
+
+			writer.WriteLine("# Setting type: " + SettingType.Name);
+
+			writer.WriteLine("# Default value: " + DefaultValue);
+
+			if (Description.AcceptableValues != null)
+			{
+				writer.WriteLine(Description.AcceptableValues.ToDescriptionString());
+			}
+			else if (SettingType.IsEnum)
+			{
+				writer.WriteLine("# Acceptable values: " + string.Join(", ", Enum.GetNames(SettingType)));
+
+				if (SettingType.GetCustomAttributes(typeof(FlagsAttribute), true).Any())
+					writer.WriteLine("# Multiple values can be set at the same time by separating them with , (e.g. Debug, Warning)");
+			}
+		}
+	}
+}

+ 250 - 53
BepInEx/Configuration/ConfigFile.cs

@@ -3,37 +3,75 @@ using System.Collections.Generic;
 using System.Collections.ObjectModel;
 using System.IO;
 using System.Linq;
-using System.Text.RegularExpressions;
+using System.Text;
+using BepInEx.Logging;
 
 namespace BepInEx.Configuration
 {
 	/// <summary>
-	/// A helper class to handle persistent data.
+	/// A helper class to handle persistent data. All public methods are thread-safe.
 	/// </summary>
 	public class ConfigFile
 	{
-		private static readonly Regex sanitizeKeyRegex = new Regex(@"[^a-zA-Z0-9\-\.]+");
+		private readonly BepInPlugin _ownerMetadata;
 
 		internal static ConfigFile CoreConfig { get; } = new ConfigFile(Paths.BepInExConfigPath, true);
 
-		protected internal Dictionary<ConfigDefinition, string> Cache { get; } = new Dictionary<ConfigDefinition, string>();
+		/// <summary>
+		/// All config entries inside 
+		/// </summary>
+		protected Dictionary<ConfigDefinition, ConfigEntryBase> Entries { get; } = new Dictionary<ConfigDefinition, ConfigEntryBase>();
 
-		public ReadOnlyCollection<ConfigDefinition> ConfigDefinitions => Cache.Keys.ToList().AsReadOnly();
+		private Dictionary<ConfigDefinition, string> HomelessEntries { get; } = new Dictionary<ConfigDefinition, string>();
 
 		/// <summary>
-		/// An event that is fired every time the config is reloaded.
+		/// Create a list with all config entries inside of this config file.
 		/// </summary>
-		public event EventHandler ConfigReloaded;
+		[Obsolete("Use GetConfigEntries instead")]
+		public ReadOnlyCollection<ConfigDefinition> ConfigDefinitions
+		{
+			get
+			{
+				lock (_ioLock) return Entries.Keys.ToList().AsReadOnly();
+			}
+		}
+
+		/// <summary>
+		/// Create an array with all config entries inside of this config file. Should be only used for metadata purposes.
+		/// If you want to access and modify an existing setting then use <see cref="AddSetting{T}(ConfigDefinition,T,ConfigDescription)"/> 
+		/// instead with no description.
+		/// </summary>
+		public ConfigEntryBase[] GetConfigEntries()
+		{
+			lock (_ioLock) return Entries.Values.ToArray();
+		}
 
+		/// <summary>
+		/// Full path to the config file. The file might not exist until a setting is added and changed, or <see cref="Save"/> is called.
+		/// </summary>
 		public string ConfigFilePath { get; }
 
 		/// <summary>
-		/// If enabled, writes the config to disk every time a value is set.
+		/// If enabled, writes the config to disk every time a value is set. 
+		/// If disabled, you have to manually use <see cref="Save"/> or the changes will be lost!
 		/// </summary>
 		public bool SaveOnConfigSet { get; set; } = true;
 
-		public ConfigFile(string configPath, bool saveOnInit)
+		/// <inheritdoc cref="ConfigFile(string, bool, BepInPlugin)"/>
+		public ConfigFile(string configPath, bool saveOnInit) : this(configPath, saveOnInit, null) { }
+
+		/// <summary>
+		/// Create a new config file at the specified config path.
+		/// </summary>
+		/// <param name="configPath">Full path to a file that contains settings. The file will be created as needed.</param>
+		/// <param name="saveOnInit">If the config file/directory doesn't exist, create it immediately.</param>
+		/// <param name="ownerMetadata">Information about the plugin that owns this setting file.</param>
+		public ConfigFile(string configPath, bool saveOnInit, BepInPlugin ownerMetadata)
 		{
+			_ownerMetadata = ownerMetadata;
+
+			if (configPath == null) throw new ArgumentNullException(nameof(configPath));
+			configPath = Path.GetFullPath(configPath);
 			ConfigFilePath = configPath;
 
 			if (File.Exists(ConfigFilePath))
@@ -46,7 +84,10 @@ namespace BepInEx.Configuration
 			}
 		}
 
-		private object _ioLock = new object();
+		#region Save/Load
+
+		private readonly object _ioLock = new object();
+		private bool _disableSaving;
 
 		/// <summary>
 		/// Reloads the config from disk. Unsaved changes are lost.
@@ -55,40 +96,51 @@ namespace BepInEx.Configuration
 		{
 			lock (_ioLock)
 			{
-				Dictionary<ConfigDefinition, string> descriptions = Cache.ToDictionary(x => x.Key, x => x.Key.Description);
+				HomelessEntries.Clear();
 
-				string currentSection = "";
-
-				foreach (string rawLine in File.ReadAllLines(ConfigFilePath))
+				try
 				{
-					string line = rawLine.Trim();
+					_disableSaving = true;
 
-					if (line.StartsWith("#")) //comment
-						continue;
+					string currentSection = string.Empty;
 
-					if (line.StartsWith("[") && line.EndsWith("]")) //section
+					foreach (string rawLine in File.ReadAllLines(ConfigFilePath))
 					{
-						currentSection = line.Substring(1, line.Length - 2);
-						continue;
-					}
+						string line = rawLine.Trim();
+
+						if (line.StartsWith("#")) //comment
+							continue;
 
-					string[] split = line.Split('='); //actual config line
-					if (split.Length != 2)
-						continue; //empty/invalid line
+						if (line.StartsWith("[") && line.EndsWith("]")) //section
+						{
+							currentSection = line.Substring(1, line.Length - 2);
+							continue;
+						}
 
-					string currentKey = split[0].Trim();
-					string currentValue = split[1].Trim();
+						string[] split = line.Split('='); //actual config line
+						if (split.Length != 2)
+							continue; //empty/invalid line
 
-					var definition = new ConfigDefinition(currentSection, currentKey);
+						string currentKey = split[0].Trim();
+						string currentValue = split[1].Trim();
 
-					if (descriptions.ContainsKey(definition))
-						definition.Description = descriptions[definition];
+						var definition = new ConfigDefinition(currentSection, currentKey);
 
-					Cache[definition] = currentValue;
-				}
+						Entries.TryGetValue(definition, out ConfigEntryBase entry);
 
-				ConfigReloaded?.Invoke(this, EventArgs.Empty);
+						if (entry != null)
+							entry.SetSerializedValue(currentValue);
+						else
+							HomelessEntries[definition] = currentValue;
+					}
+				}
+				finally
+				{
+					_disableSaving = false;
+				}
 			}
+
+			OnConfigReloaded();
 		}
 
 		/// <summary>
@@ -98,51 +150,196 @@ namespace BepInEx.Configuration
 		{
 			lock (_ioLock)
 			{
-				if (!Directory.Exists(Paths.ConfigPath))
-					Directory.CreateDirectory(Paths.ConfigPath);
+				if (_disableSaving) return;
+
+				string directoryName = Path.GetDirectoryName(ConfigFilePath);
+				if (directoryName != null) Directory.CreateDirectory(directoryName);
 
-				using (StreamWriter writer = new StreamWriter(File.Create(ConfigFilePath), System.Text.Encoding.UTF8))
-					foreach (var sectionKv in Cache.GroupBy(x => x.Key.Section).OrderBy(x => x.Key))
+				using (var writer = new StreamWriter(File.Create(ConfigFilePath), Encoding.UTF8))
+				{
+					if (_ownerMetadata != null)
 					{
+						writer.WriteLine($"## Settings file was created by plugin {_ownerMetadata.Name} v{_ownerMetadata.Version}");
+						writer.WriteLine($"## Plugin GUID: {_ownerMetadata.GUID}");
+						writer.WriteLine();
+					}
+
+					var allConfigEntries = Entries.Select(x => new { x.Key, entry = x.Value, value = x.Value.GetSerializedValue() })
+						.Concat(HomelessEntries.Select(x => new { x.Key, entry = (ConfigEntryBase)null, value = x.Value }));
+
+					foreach (var sectionKv in allConfigEntries.GroupBy(x => x.Key.Section).OrderBy(x => x.Key))
+					{
+						// Section heading
 						writer.WriteLine($"[{sectionKv.Key}]");
 
-						foreach (var entryKv in sectionKv)
+						foreach (var configEntry in sectionKv)
 						{
 							writer.WriteLine();
 
-							if (!string.IsNullOrEmpty(entryKv.Key.Description))
-								writer.WriteLine($"# {entryKv.Key.Description.Replace("\n", "\n# ")}");
+							configEntry.entry?.WriteDescription(writer);
 
-							writer.WriteLine($"{entryKv.Key.Key} = {entryKv.Value}");
+							writer.WriteLine($"{configEntry.Key.Key} = {configEntry.value}");
 						}
 
 						writer.WriteLine();
 					}
+				}
 			}
 		}
 
-		public ConfigWrapper<T> Wrap<T>(ConfigDefinition configDefinition, T defaultValue = default(T))
+		#endregion
+
+		#region Wraps
+
+		/// <summary>
+		/// Access one of the existing settings. If the setting has not been added yet, null is returned.
+		/// If the setting exists but has a different type than T, an exception is thrown.
+		/// New settings should be added with <see cref="AddSetting{T}(ConfigDefinition,T,ConfigDescription)"/>.
+		/// </summary>
+		/// <typeparam name="T">Type of the value contained in this setting.</typeparam>
+		/// <param name="section">Section/category/group of the setting. Settings are grouped by this.</param>
+		/// <param name="key">Name of the setting.</param>
+		public ConfigEntry<T> GetSetting<T>(string section, string key)
 		{
-			if (!Cache.ContainsKey(configDefinition))
+			lock (_ioLock)
 			{
-				Cache.Add(configDefinition, TomlTypeConverter.ConvertToString(defaultValue));
-				Save();
+				Entries.TryGetValue(new ConfigDefinition(section, key), out var entry);
+				return (ConfigEntry<T>)entry;
 			}
-			else
+		}
+
+        /// <summary>
+        /// Create a new setting. The setting is saved to drive and loaded automatically.
+        /// Each definition can be used to add only one setting, trying to add a second setting will throw an exception.
+        /// </summary>
+        /// <typeparam name="T">Type of the value contained in this setting.</typeparam>
+        /// <param name="configDefinition">Section and Key of the setting.</param>
+        /// <param name="defaultValue">Value of the setting if the setting was not created yet.</param>
+        /// <param name="configDescription">Description of the setting shown to the user and other metadata.</param>
+        public ConfigEntry<T> AddSetting<T>(ConfigDefinition configDefinition, T defaultValue, ConfigDescription configDescription = null)
+		{
+			if (!TomlTypeConverter.CanConvert(typeof(T)))
+				throw new ArgumentException($"Type {typeof(T)} is not supported by the config system. Supported types: {string.Join(", ", TomlTypeConverter.GetSupportedTypes().Select(x => x.Name).ToArray())}");
+
+			lock (_ioLock)
 			{
-				var original = Cache.Keys.First(x => x.Equals(configDefinition));
+				if (Entries.ContainsKey(configDefinition))
+					throw new ArgumentException("The setting " + configDefinition + " has already been created. Use GetSetting to get it.");
 
-				if (original.Description != configDefinition.Description)
+				try
 				{
-					original.Description = configDefinition.Description;
-					Save();
+					_disableSaving = true;
+
+					var entry = new ConfigEntry<T>(this, configDefinition, defaultValue, configDescription);
+					Entries[configDefinition] = entry;
+
+					if (HomelessEntries.TryGetValue(configDefinition, out string homelessValue))
+					{
+						entry.SetSerializedValue(homelessValue);
+						HomelessEntries.Remove(configDefinition);
+					}
+
+					_disableSaving = false;
+					if (SaveOnConfigSet)
+						Save();
+
+					return entry;
+				}
+				finally
+				{
+					_disableSaving = false;
 				}
 			}
-
-			return new ConfigWrapper<T>(this, configDefinition);
 		}
 
+		/// <summary>
+		/// Create a new setting. The setting is saved to drive and loaded automatically.
+		/// Each section and key pair can be used to add only one setting, trying to add a second setting will throw an exception.
+		/// </summary>
+		/// <typeparam name="T">Type of the value contained in this setting.</typeparam>
+		/// <param name="section">Section/category/group of the setting. Settings are grouped by this.</param>
+		/// <param name="key">Name of the setting.</param>
+		/// <param name="defaultValue">Value of the setting if the setting was not created yet.</param>
+		/// <param name="configDescription">Description of the setting shown to the user and other metadata.</param>
+		public ConfigEntry<T> AddSetting<T>(string section, string key, T defaultValue, ConfigDescription configDescription = null)
+			=> AddSetting(new ConfigDefinition(section, key), defaultValue, configDescription);
+
+		/// <summary>
+		/// Create a new setting. The setting is saved to drive and loaded automatically.
+		/// Each section and key pair can be used to add only one setting, trying to add a second setting will throw an exception.
+		/// </summary>
+		/// <typeparam name="T">Type of the value contained in this setting.</typeparam>
+		/// <param name="section">Section/category/group of the setting. Settings are grouped by this.</param>
+		/// <param name="key">Name of the setting.</param>
+		/// <param name="defaultValue">Value of the setting if the setting was not created yet.</param>
+		/// <param name="description">Simple description of the setting shown to the user.</param>
+		public ConfigEntry<T> AddSetting<T>(string section, string key, T defaultValue, string description)
+			=> AddSetting(new ConfigDefinition(section, key), defaultValue, new ConfigDescription(description));
+
+        /// <summary>
+        /// Access a setting. Use AddSetting and GetSetting instead.
+        /// </summary>
+        [Obsolete("Use AddSetting and GetSetting instead")]
 		public ConfigWrapper<T> Wrap<T>(string section, string key, string description = null, T defaultValue = default(T))
-			=> Wrap<T>(new ConfigDefinition(section, key, description), defaultValue);
+		{
+			lock (_ioLock)
+			{
+				var setting = GetSetting<T>(section, key) ?? AddSetting(section, key, defaultValue, string.IsNullOrEmpty(description) ? null : new ConfigDescription(description));
+				return new ConfigWrapper<T>(setting);
+			}
+		}
+
+		/// <summary>
+		/// Access a setting. Use AddSetting and GetSetting instead.
+		/// </summary>
+		[Obsolete("Use AddSetting and GetSetting instead")]
+		public ConfigWrapper<T> Wrap<T>(ConfigDefinition configDefinition, T defaultValue = default(T))
+			=> Wrap(configDefinition.Section, configDefinition.Key, null, defaultValue);
+
+		#endregion
+
+		#region Events
+
+		/// <summary>
+		/// An event that is fired every time the config is reloaded.
+		/// </summary>
+		public event EventHandler ConfigReloaded;
+
+		/// <summary>
+		/// Fired when one of the settings is changed.
+		/// </summary>
+		public event EventHandler<SettingChangedEventArgs> SettingChanged;
+
+		internal void OnSettingChanged(object sender, ConfigEntryBase changedEntryBase)
+		{
+			if (changedEntryBase == null) throw new ArgumentNullException(nameof(changedEntryBase));
+
+			if (SaveOnConfigSet)
+				Save();
+
+			var settingChanged = SettingChanged;
+			if (settingChanged == null) return;
+
+			var args = new SettingChangedEventArgs(changedEntryBase);
+			foreach (var callback in settingChanged.GetInvocationList().Cast<EventHandler<SettingChangedEventArgs>>())
+			{
+				try { callback(sender, args); }
+				catch (Exception e) { Logger.Log(LogLevel.Error, e); }
+			}
+		}
+
+		private void OnConfigReloaded()
+		{
+			var configReloaded = ConfigReloaded;
+			if (configReloaded == null) return;
+
+			foreach (var callback in configReloaded.GetInvocationList().Cast<EventHandler>())
+			{
+				try { callback(this, EventArgs.Empty); }
+				catch (Exception e) { Logger.Log(LogLevel.Error, e); }
+			}
+		}
+
+		#endregion
 	}
-}
+}

+ 31 - 19
BepInEx/Configuration/ConfigWrapper.cs

@@ -2,38 +2,50 @@
 
 namespace BepInEx.Configuration
 {
-	public class ConfigWrapper<T>
+	/// <summary>
+	/// Provides access to a single setting inside of a <see cref="Configuration.ConfigFile"/>.
+	/// </summary>
+	/// <typeparam name="T">Type of the setting.</typeparam>
+	[Obsolete("Use ConfigFile from new AddSetting overloads instead")]
+	public sealed class ConfigWrapper<T>
 	{
-		public ConfigDefinition Definition { get; protected set; }
+		/// <summary>
+		/// Entry of this setting in the <see cref="Configuration.ConfigFile"/>.
+		/// </summary>
+		public ConfigEntry<T> ConfigEntry { get; }
 
-		public ConfigFile ConfigFile { get; protected set; }
+		/// <summary>
+		/// Unique definition of this setting.
+		/// </summary>
+		public ConfigDefinition Definition => ConfigEntry.Definition;
+
+		/// <summary>
+		/// Config file this setting is inside of.
+		/// </summary>
+		public ConfigFile ConfigFile => ConfigEntry.ConfigFile;
 
 		/// <summary>
 		/// Fired when the setting is changed. Does not detect changes made outside from this object.
 		/// </summary>
 		public event EventHandler SettingChanged;
 
+		/// <summary>
+		/// Value of this setting.
+		/// </summary>
 		public T Value
 		{
-			get => TomlTypeConverter.ConvertToValue<T>(ConfigFile.Cache[Definition]);
-			set
-			{
-				ConfigFile.Cache[Definition] = TomlTypeConverter.ConvertToString(value);
-
-				if (ConfigFile.SaveOnConfigSet)
-					ConfigFile.Save();
-
-				SettingChanged?.Invoke(this, EventArgs.Empty);
-			}
+			get => ConfigEntry.Value;
+			set => ConfigEntry.Value = value;
 		}
 
-		public ConfigWrapper(ConfigFile configFile, ConfigDefinition definition)
+		internal ConfigWrapper(ConfigEntry<T> configEntry)
 		{
-			if (!TomlTypeConverter.TypeConverters.ContainsKey(typeof(T)))
-				throw new ArgumentException("Unsupported config wrapper type");
+			ConfigEntry = configEntry ?? throw new ArgumentNullException(nameof(configEntry));
 
-			ConfigFile = configFile;
-			Definition = definition;
+			configEntry.ConfigFile.SettingChanged += (sender, args) =>
+			{
+				if (args.ChangedSetting == configEntry) SettingChanged?.Invoke(sender, args);
+			};
 		}
 	}
-}
+}

+ 180 - 0
BepInEx/Configuration/KeyboardShortcut.cs

@@ -0,0 +1,180 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using BepInEx.Logging;
+using UnityEngine;
+using Logger = BepInEx.Logging.Logger;
+
+namespace BepInEx.Configuration
+{
+	/// <summary>
+	/// A keyboard shortcut that can be used in Update method to check if user presses a key combo. The shortcut is only
+	/// triggered when the user presses the exact combination. For example, <c>F + LeftCtrl</c> will trigger only if user 
+	/// presses and holds only LeftCtrl, and then presses F. If any other keys are pressed, the shortcut will not trigger.
+	/// 
+	/// Can be used as a value of a setting in <see cref="ConfigFile.AddSetting{T}(ConfigDefinition,T,ConfigDescription)"/> 
+	/// to allow user to change this shortcut and have the changes saved.
+	/// 
+	/// How to use: Use <see cref="IsDown"/> in this class instead of <see cref="Input.GetKeyDown(KeyCode)"/> in the Update loop.
+	/// </summary>
+	public struct KeyboardShortcut
+	{
+		static KeyboardShortcut()
+		{
+			TomlTypeConverter.AddConverter(
+				typeof(KeyboardShortcut),
+				new TypeConverter
+				{
+					ConvertToString = (o, type) => ((KeyboardShortcut)o).Serialize(),
+					ConvertToObject = (s, type) => Deserialize(s)
+				});
+		}
+
+		/// <summary>
+		/// Shortcut that never triggers.
+		/// </summary>
+		public static readonly KeyboardShortcut Empty = new KeyboardShortcut();
+
+		/// <summary>
+		/// All KeyCode values that can be used in a keyboard shortcut.
+		/// </summary>
+		public static readonly IEnumerable<KeyCode> AllKeyCodes = (KeyCode[])Enum.GetValues(typeof(KeyCode));
+
+		private readonly KeyCode[] _allKeys;
+		private readonly HashSet<KeyCode> _allKeysLookup;
+
+		/// <summary>
+		/// Create a new keyboard shortcut.
+		/// </summary>
+		/// <param name="mainKey">Main key to press</param>
+		/// <param name="modifiers">Keys that should be held down before main key is registered</param>
+		public KeyboardShortcut(KeyCode mainKey, params KeyCode[] modifiers) : this(new[] { mainKey }.Concat(modifiers).ToArray())
+		{
+			if (mainKey == KeyCode.None && modifiers.Any())
+				throw new ArgumentException($"Can't set {nameof(mainKey)} to KeyCode.None if there are any {nameof(modifiers)}");
+		}
+
+		private KeyboardShortcut(KeyCode[] keys)
+		{
+			_allKeys = SanitizeKeys(keys);
+			_allKeysLookup = new HashSet<KeyCode>(_allKeys);
+		}
+
+		private static KeyCode[] SanitizeKeys(params KeyCode[] keys)
+		{
+			if (keys.Length == 0 || keys[0] == KeyCode.None)
+				return new[] { KeyCode.None };
+
+			return new[] { keys[0] }.Concat(keys.Skip(1).Distinct().Where(x => x != keys[0]).OrderBy(x => (int)x)).ToArray();
+		}
+
+		/// <summary>
+		/// Main key of the key combination. It has to be pressed / let go last for the combination to be triggered.
+		/// If the combination is empty, <see cref="KeyCode.None"/> is returned.
+		/// </summary>
+		public KeyCode MainKey => _allKeys != null && _allKeys.Length > 0 ? _allKeys[0] : KeyCode.None;
+
+		/// <summary>
+		/// Modifiers of the key combination, if any.
+		/// </summary>
+		public IEnumerable<KeyCode> Modifiers => _allKeys?.Skip(1) ?? Enumerable.Empty<KeyCode>();
+
+		/// <summary>
+		/// Attempt to deserialize key combination from the string.
+		/// </summary>
+		public static KeyboardShortcut Deserialize(string str)
+		{
+			try
+			{
+				var parts = str.Split(new[] { ' ', '+', ',', ';', '|' }, StringSplitOptions.RemoveEmptyEntries)
+							   .Select(x => (KeyCode)Enum.Parse(typeof(KeyCode), x)).ToArray();
+				return new KeyboardShortcut(parts);
+			}
+			catch (SystemException ex)
+			{
+				Logger.Log(LogLevel.Error, "Failed to read keybind from settings: " + ex.Message);
+				return Empty;
+			}
+		}
+
+		/// <summary>
+		/// Serialize the key combination into a user readable string.
+		/// </summary>
+		public string Serialize()
+		{
+			if (_allKeys == null) return string.Empty;
+			return string.Join(" + ", _allKeys.Select(x => x.ToString()).ToArray());
+		}
+
+		/// <summary>
+		/// Check if the main key was just pressed (Input.GetKeyDown), and specified modifier keys are all pressed
+		/// </summary>
+		public bool IsDown()
+		{
+			var mainKey = MainKey;
+			if (mainKey == KeyCode.None) return false;
+
+			return Input.GetKeyDown(mainKey) && ModifierKeyTest();
+		}
+
+		/// <summary>
+		/// Check if the main key is currently held down (Input.GetKey), and specified modifier keys are all pressed
+		/// </summary>
+		public bool IsPressed()
+		{
+			var mainKey = MainKey;
+			if (mainKey == KeyCode.None) return false;
+
+			return Input.GetKey(mainKey) && ModifierKeyTest();
+		}
+
+		/// <summary>
+		/// Check if the main key was just lifted (Input.GetKeyUp), and specified modifier keys are all pressed.
+		/// </summary>
+		public bool IsUp()
+		{
+			var mainKey = MainKey;
+			if (mainKey == KeyCode.None) return false;
+
+			return Input.GetKeyUp(mainKey) && ModifierKeyTest();
+		}
+
+		private bool ModifierKeyTest()
+		{
+			var lookup = _allKeysLookup;
+			var mainKey = MainKey;
+			return AllKeyCodes.All(c =>
+			{
+				if (lookup.Contains(c))
+				{
+					if (mainKey == c)
+						return true;
+					return Input.GetKey(c);
+				}
+				return !Input.GetKey(c);
+			});
+		}
+
+		/// <inheritdoc />
+		public override string ToString()
+		{
+			if (MainKey == KeyCode.None) return "Not set";
+
+			return string.Join(" + ", _allKeys.Select(c => c.ToString()).ToArray());
+		}
+
+		/// <inheritdoc />
+		public override bool Equals(object obj)
+		{
+			return obj is KeyboardShortcut shortcut && MainKey == shortcut.MainKey && Modifiers.SequenceEqual(shortcut.Modifiers);
+		}
+
+		/// <inheritdoc />
+		public override int GetHashCode()
+		{
+			if (MainKey == KeyCode.None) return 0;
+
+			return _allKeys.Aggregate(_allKeys.Length, (current, item) => unchecked(current * 31 + (int)item));
+		}
+	}
+}

+ 22 - 0
BepInEx/Configuration/SettingChangedEventArgs.cs

@@ -0,0 +1,22 @@
+using System;
+
+namespace BepInEx.Configuration
+{
+	/// <summary>
+	/// Arguments for events concerning a change of a setting.
+	/// </summary>
+	/// <inheritdoc />
+	public sealed class SettingChangedEventArgs : EventArgs
+	{
+		/// <inheritdoc />
+		public SettingChangedEventArgs(ConfigEntryBase changedSetting)
+		{
+			ChangedSetting = changedSetting;
+		}
+
+		/// <summary>
+		/// Setting that was changed
+		/// </summary>
+		public ConfigEntryBase ChangedSetting { get; }
+	}
+}

+ 275 - 44
BepInEx/Configuration/TomlTypeConverter.cs

@@ -1,113 +1,344 @@
 using System;
 using System.Collections.Generic;
 using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.RegularExpressions;
+using UnityEngine;
+using Logger = BepInEx.Logging.Logger;
 
 namespace BepInEx.Configuration
 {
-	public class TypeConverter
+	/// <summary>
+	/// Serializer/deserializer used by the config system.
+	/// </summary>
+	public static class TomlTypeConverter
 	{
-		public Func<object, string> ConvertToString { get; set; }
-		public Func<string, object> ConvertToObject { get; set; }
-	}
-
-	internal static class TomlTypeConverter
-	{
-		public static Dictionary<Type, TypeConverter> TypeConverters { get; } = new Dictionary<Type, TypeConverter>
+		// Don't put anything from UnityEngine here or it will break preloader, use LazyTomlConverterLoader instead
+		private static Dictionary<Type, TypeConverter> TypeConverters { get; } = new Dictionary<Type, TypeConverter>
 		{
 			[typeof(string)] = new TypeConverter
 			{
-				ConvertToString = (obj) => (string)obj,
-				ConvertToObject = (str) => str,
+				ConvertToString = (obj, type) => Escape((string)obj),
+				ConvertToObject = (str, type) =>
+				{
+					// Check if the string is a file path with unescaped \ path separators (e.g. D:\test and not D:\\test)
+					if (Regex.IsMatch(str, @"^""?\w:\\(?!\\)(?!.+\\\\)"))
+						return str;
+					return Unescape(str);
+				},
 			},
 			[typeof(bool)] = new TypeConverter
 			{
-				ConvertToString = (obj) => obj.ToString().ToLowerInvariant(),
-				ConvertToObject = (str) => bool.Parse(str),
+				ConvertToString = (obj, type) => obj.ToString().ToLowerInvariant(),
+				ConvertToObject = (str, type) => bool.Parse(str),
 			},
 			[typeof(byte)] = new TypeConverter
 			{
-				ConvertToString = (obj) => obj.ToString(),
-				ConvertToObject = (str) => byte.Parse(str),
+				ConvertToString = (obj, type) => obj.ToString(),
+				ConvertToObject = (str, type) => byte.Parse(str),
 			},
 
 			//integral types
 
 			[typeof(sbyte)] = new TypeConverter
 			{
-				ConvertToString = (obj) => obj.ToString(),
-				ConvertToObject = (str) => sbyte.Parse(str),
+				ConvertToString = (obj, type) => obj.ToString(),
+				ConvertToObject = (str, type) => sbyte.Parse(str),
 			},
 			[typeof(byte)] = new TypeConverter
 			{
-				ConvertToString = (obj) => obj.ToString(),
-				ConvertToObject = (str) => byte.Parse(str),
+				ConvertToString = (obj, type) => obj.ToString(),
+				ConvertToObject = (str, type) => byte.Parse(str),
 			},
 			[typeof(short)] = new TypeConverter
 			{
-				ConvertToString = (obj) => obj.ToString(),
-				ConvertToObject = (str) => short.Parse(str),
+				ConvertToString = (obj, type) => obj.ToString(),
+				ConvertToObject = (str, type) => short.Parse(str),
 			},
 			[typeof(ushort)] = new TypeConverter
 			{
-				ConvertToString = (obj) => obj.ToString(),
-				ConvertToObject = (str) => ushort.Parse(str),
+				ConvertToString = (obj, type) => obj.ToString(),
+				ConvertToObject = (str, type) => ushort.Parse(str),
 			},
 			[typeof(int)] = new TypeConverter
 			{
-				ConvertToString = (obj) => obj.ToString(),
-				ConvertToObject = (str) => int.Parse(str),
+				ConvertToString = (obj, type) => obj.ToString(),
+				ConvertToObject = (str, type) => int.Parse(str),
 			},
 			[typeof(uint)] = new TypeConverter
 			{
-				ConvertToString = (obj) => obj.ToString(),
-				ConvertToObject = (str) => uint.Parse(str),
+				ConvertToString = (obj, type) => obj.ToString(),
+				ConvertToObject = (str, type) => uint.Parse(str),
 			},
 			[typeof(long)] = new TypeConverter
 			{
-				ConvertToString = (obj) => obj.ToString(),
-				ConvertToObject = (str) => long.Parse(str),
+				ConvertToString = (obj, type) => obj.ToString(),
+				ConvertToObject = (str, type) => long.Parse(str),
 			},
 			[typeof(ulong)] = new TypeConverter
 			{
-				ConvertToString = (obj) => obj.ToString(),
-				ConvertToObject = (str) => ulong.Parse(str),
+				ConvertToString = (obj, type) => obj.ToString(),
+				ConvertToObject = (str, type) => ulong.Parse(str),
 			},
 
 			//floating point types
 
 			[typeof(float)] = new TypeConverter
 			{
-				ConvertToString = (obj) => ((float)obj).ToString(NumberFormatInfo.InvariantInfo),
-				ConvertToObject = (str) => float.Parse(str, NumberFormatInfo.InvariantInfo),
+				ConvertToString = (obj, type) => ((float)obj).ToString(NumberFormatInfo.InvariantInfo),
+				ConvertToObject = (str, type) => float.Parse(str, NumberFormatInfo.InvariantInfo),
 			},
 			[typeof(double)] = new TypeConverter
 			{
-				ConvertToString = (obj) => ((double)obj).ToString(NumberFormatInfo.InvariantInfo),
-				ConvertToObject = (str) => double.Parse(str, NumberFormatInfo.InvariantInfo),
+				ConvertToString = (obj, type) => ((double)obj).ToString(NumberFormatInfo.InvariantInfo),
+				ConvertToObject = (str, type) => double.Parse(str, NumberFormatInfo.InvariantInfo),
 			},
 			[typeof(decimal)] = new TypeConverter
 			{
-				ConvertToString = (obj) => ((decimal)obj).ToString(NumberFormatInfo.InvariantInfo),
-				ConvertToObject = (str) => decimal.Parse(str, NumberFormatInfo.InvariantInfo),
+				ConvertToString = (obj, type) => ((decimal)obj).ToString(NumberFormatInfo.InvariantInfo),
+				ConvertToObject = (str, type) => decimal.Parse(str, NumberFormatInfo.InvariantInfo),
+			},
+
+			//enums are special
+
+			[typeof(Enum)] = new TypeConverter
+			{
+				ConvertToString = (obj, type) => obj.ToString(),
+				ConvertToObject = (str, type) => Enum.Parse(type, str, true),
 			},
 		};
 
-		public static string ConvertToString(object value)
+		/// <summary>
+		/// Convert object of a given type to a string using available converters.
+		/// </summary>
+		public static string ConvertToString(object value, Type valueType)
 		{
-			Type valueType = value.GetType();
-
-			if (!TypeConverters.ContainsKey(valueType))
+			var conv = GetConverter(valueType);
+			if (conv == null)
 				throw new InvalidOperationException($"Cannot convert from type {valueType}");
 
-			return TypeConverters[valueType].ConvertToString(value);
+			return conv.ConvertToString(value, valueType);
 		}
 
+		/// <summary>
+		/// Convert string to an object of a given type using available converters.
+		/// </summary>
 		public static T ConvertToValue<T>(string value)
 		{
-			if (!TypeConverters.ContainsKey(typeof(T)))
-				throw new InvalidOperationException($"Cannot convert to type {typeof(T)}");
+			return (T)ConvertToValue(value, typeof(T));
+		}
+
+		/// <summary>
+		/// Convert string to an object of a given type using available converters.
+		/// </summary>
+		public static object ConvertToValue(string value, Type valueType)
+		{
+			var conv = GetConverter(valueType);
+			if (conv == null)
+				throw new InvalidOperationException($"Cannot convert to type {valueType.Name}");
+
+			return conv.ConvertToObject(value, valueType);
+		}
+
+		/// <summary>
+		/// Get a converter for a given type if there is any.
+		/// </summary>
+		public static TypeConverter GetConverter(Type valueType)
+		{
+			if (valueType == null) throw new ArgumentNullException(nameof(valueType));
+
+			if (valueType.IsEnum)
+				return TypeConverters[typeof(Enum)];
+
+			if (!TypeConverters.TryGetValue(valueType, out var result) && !_lazyLoadedConverters)
+			{
+				_lazyLoadedConverters = true;
+				LazyLoadConverters();
+				TypeConverters.TryGetValue(valueType, out result);
+			}
+			return result;
+		}
+
+		/// <summary>
+		/// Add a new type converter for a given type. 
+		/// If a different converter is already added, this call is ignored and false is returned.
+		/// </summary>
+		public static bool AddConverter(Type type, TypeConverter converter)
+		{
+			if (type == null) throw new ArgumentNullException(nameof(type));
+			if (converter == null) throw new ArgumentNullException(nameof(converter));
+			if (CanConvert(type))
+			{
+				Logger.LogWarning("Tried to add a TomlConverter when one already exists for type " + type.FullName);
+				return false;
+			}
+
+			TypeConverters.Add(type, converter);
+			return true;
+		}
+
+		/// <summary>
+		/// Check if a given type can be converted to and from string.
+		/// </summary>
+		public static bool CanConvert(Type type)
+		{
+			return GetConverter(type) != null;
+		}
+
+		/// <summary>		
+		/// Give a list of types with registered converters.
+		/// </summary>
+		public static IEnumerable<Type> GetSupportedTypes()
+		{
+			return TypeConverters.Keys;
+		}
+
+		private static bool _lazyLoadedConverters;
+		private static void LazyLoadConverters()
+		{
+			try { LazyTomlConverterLoader.AddUnityEngineConverters(); }
+			catch (Exception ex) { Logger.LogWarning("Failed to load UnityEngine Toml converters - " + ex.Message); }
+		}
+
+		private static string Escape(this string txt)
+		{
+			var stringBuilder = new StringBuilder(txt.Length + 2);
+			foreach (char c in txt)
+				switch (c)
+				{
+					case '\0':
+						stringBuilder.Append(@"\0");
+						break;
+					case '\a':
+						stringBuilder.Append(@"\a");
+						break;
+					case '\b':
+						stringBuilder.Append(@"\b");
+						break;
+					case '\t':
+						stringBuilder.Append(@"\t");
+						break;
+					case '\n':
+						stringBuilder.Append(@"\n");
+						break;
+					case '\v':
+						stringBuilder.Append(@"\v");
+						break;
+					case '\f':
+						stringBuilder.Append(@"\f");
+						break;
+					case '\r':
+						stringBuilder.Append(@"\r");
+						break;
+					case '\'':
+						stringBuilder.Append(@"\'");
+						break;
+					case '\\':
+						stringBuilder.Append(@"\");
+						break;
+					case '\"':
+						stringBuilder.Append(@"\""");
+						break;
+					default:
+						stringBuilder.Append(c);
+						break;
+				}
+			return stringBuilder.ToString();
+		}
+
+		private static string Unescape(this string txt)
+		{
+			if (string.IsNullOrEmpty(txt))
+				return txt;
+			var stringBuilder = new StringBuilder(txt.Length);
+			for (int i = 0; i < txt.Length;)
+			{
+				int num = txt.IndexOf('\\', i);
+				if (num < 0 || num == txt.Length - 1)
+					num = txt.Length;
+				stringBuilder.Append(txt, i, num - i);
+				if (num >= txt.Length)
+					break;
+				char c = txt[num + 1];
+				switch (c)
+				{
+					case '0':
+						stringBuilder.Append('\0');
+						break;
+					case 'a':
+						stringBuilder.Append('\a');
+						break;
+					case 'b':
+						stringBuilder.Append('\b');
+						break;
+					case 't':
+						stringBuilder.Append('\t');
+						break;
+					case 'n':
+						stringBuilder.Append('\n');
+						break;
+					case 'v':
+						stringBuilder.Append('\v');
+						break;
+					case 'f':
+						stringBuilder.Append('\f');
+						break;
+					case 'r':
+						stringBuilder.Append('\r');
+						break;
+					case '\'':
+						stringBuilder.Append('\'');
+						break;
+					case '\"':
+						stringBuilder.Append('\"');
+						break;
+					case '\\':
+						stringBuilder.Append('\\');
+						break;
+					default:
+						stringBuilder.Append('\\').Append(c);
+						break;
+				}
+
+				i = num + 2;
+			}
+
+			return stringBuilder.ToString();
+		}
+	}
+
+	/// <summary>
+	/// For types that are in assemblies that can't get loaded before preloader runs (or it won't work on these assemblies)
+	/// </summary>
+	internal static class LazyTomlConverterLoader
+	{
+		[MethodImpl(MethodImplOptions.NoInlining)]
+		public static void AddUnityEngineConverters()
+		{
+			var colorConverter = new TypeConverter
+			{
+				ConvertToString = (obj, type) => ColorUtility.ToHtmlStringRGBA((Color)obj),
+				ConvertToObject = (str, type) =>
+				{
+					if (!ColorUtility.TryParseHtmlString("#" + str.Trim('#', ' '), out var c))
+						throw new FormatException("Invalid color string, expected hex #RRGGBBAA");
+					return c;
+				},
+			};
+
+			TomlTypeConverter.AddConverter(typeof(Color), colorConverter);
+
+			var jsonConverter = new TypeConverter
+			{
+				ConvertToString = (obj, type) => JsonUtility.ToJson(obj),
+				ConvertToObject = (str, type) => JsonUtility.FromJson(type: type, json: str),
+			};
 
-			return (T)TypeConverters[typeof(T)].ConvertToObject(value);
+			TomlTypeConverter.AddConverter(typeof(Vector2), jsonConverter);
+			TomlTypeConverter.AddConverter(typeof(Vector3), jsonConverter);
+			TomlTypeConverter.AddConverter(typeof(Vector4), jsonConverter);
+			TomlTypeConverter.AddConverter(typeof(Quaternion), jsonConverter);
 		}
 	}
 }

+ 22 - 0
BepInEx/Configuration/TypeConverter.cs

@@ -0,0 +1,22 @@
+using System;
+
+namespace BepInEx.Configuration
+{
+	/// <summary>
+	/// A serializer/deserializer combo for some type(s). Used by the config system.
+	/// </summary>
+	public class TypeConverter
+	{
+		/// <summary>
+		/// Used to serialize the type into a (hopefully) human-readable string.
+		/// Object is the instance to serialize, Type is the object's type.
+		/// </summary>
+		public Func<object, Type, string> ConvertToString { get; set; }
+
+		/// <summary>
+		/// Used to deserialize the type from a string.
+		/// String is the data to deserialize, Type is the object's type, should return instance to an object of Type.
+		/// </summary>
+		public Func<string, Type, object> ConvertToObject { get; set; }
+	}
+}

+ 10 - 12
BepInEx/ConsoleUtil/ConsoleWindow.cs

@@ -13,17 +13,15 @@ namespace UnityInjector.ConsoleUtil
 {
 	internal class ConsoleWindow
 	{
-		public static readonly ConfigWrapper<bool> ConfigConsoleEnabled = ConfigFile.CoreConfig.Wrap(
-			"Logging.Console",
-			"Enabled",
-			"Enables showing a console for log output.",
-			false);
-
-		public static readonly ConfigWrapper<bool> ConfigConsoleShiftJis = ConfigFile.CoreConfig.Wrap(
-			"Logging.Console",
-			"ShiftJisEncoding",
-			"If true, console is set to the Shift-JIS encoding, otherwise UTF-8 encoding.",
-			false);
+		public static readonly ConfigEntry<bool> ConfigConsoleEnabled = ConfigFile.CoreConfig.AddSetting(
+			"Logging.Console", "Enabled",
+			false,
+			"Enables showing a console for log output.");
+
+		public static readonly ConfigEntry<bool> ConfigConsoleShiftJis = ConfigFile.CoreConfig.AddSetting(
+			"Logging.Console", "ShiftJisEncoding",
+			false,
+			"If true, console is set to the Shift-JIS encoding, otherwise UTF-8 encoding.");
 
 		public static bool IsAttached { get; private set; }
 		private static IntPtr _cOut;
@@ -106,7 +104,7 @@ namespace UnityInjector.ConsoleUtil
 
 		[DllImport("user32.dll")]
 		[return: MarshalAs(UnmanagedType.Bool)]
-		static extern bool SetForegroundWindow(IntPtr hWnd);
+		private static extern bool SetForegroundWindow(IntPtr hWnd);
 
 		[DllImport("kernel32.dll", SetLastError = true)]
 		private static extern bool AllocConsole();

+ 2 - 11
BepInEx/Contract/Attributes.cs

@@ -241,7 +241,7 @@ namespace BepInEx
 		/// <summary>
 		/// Retrieves the BepInPlugin metadata from a plugin type.
 		/// </summary>
-		/// <param name="plugin">The plugin type.</param>
+		/// <param name="pluginType">The plugin type.</param>
 		/// <returns>The BepInPlugin metadata of the plugin type.</returns>
 		public static BepInPlugin GetMetadata(Type pluginType)
 		{
@@ -265,7 +265,7 @@ namespace BepInEx
 		/// Gets the specified attributes of a type, if they exist.
 		/// </summary>
 		/// <typeparam name="T">The attribute type to retrieve.</typeparam>
-		/// <param name="plugin">The plugin type.</param>
+		/// <param name="pluginType">The plugin type.</param>
 		/// <returns>The attributes of the type, if existing.</returns>
 		public static T[] GetAttributes<T>(Type pluginType) where T : Attribute
 		{
@@ -281,7 +281,6 @@ namespace BepInEx
 		public static IEnumerable<T> GetAttributes<T>(object plugin) where T : Attribute
 			=> GetAttributes<T>(plugin.GetType());
 
-
 		/// <summary>
 		/// Retrieves the dependencies of the specified plugin type.
 		/// </summary>
@@ -293,14 +292,6 @@ namespace BepInEx
 		}
 	}
 
-	/// <summary>
-	/// An exception which is thrown when a plugin's dependencies cannot be found.
-	/// </summary>
-	public class MissingDependencyException : Exception
-	{
-		public MissingDependencyException(string message) : base(message) { }
-	}
-
 	#endregion
 
 	#region Build configuration

+ 16 - 5
BepInEx/Contract/BaseUnityPlugin.cs

@@ -1,6 +1,5 @@
 using BepInEx.Bootstrap;
 using BepInEx.Configuration;
-using BepInEx.Contract;
 using BepInEx.Logging;
 using UnityEngine;
 
@@ -11,12 +10,24 @@ namespace BepInEx
 	/// </summary>
 	public abstract class BaseUnityPlugin : MonoBehaviour
 	{
+		/// <summary>
+		/// Information about this plugin as it was loaded.
+		/// </summary>
+		public PluginInfo Info { get; }
+		/// <summary>
+		/// Logger instance tied to this plugin.
+		/// </summary>
 		protected ManualLogSource Logger { get; }
 
-		protected ConfigFile Config { get; }
-
-		protected PluginInfo Info { get; }
+		/// <summary>
+		/// Default config file tied to this plugin. The config file will not be created until 
+		/// any settings are added and changed, or <see cref="ConfigFile.Save"/> is called.
+		/// </summary>
+		public ConfigFile Config { get; }
 
+		/// <summary>
+		/// Create a new instance of a plugin and all of its tied in objects.
+		/// </summary>
 		protected BaseUnityPlugin()
 		{
 			var metadata = MetadataHelper.GetMetadata(this);
@@ -37,7 +48,7 @@ namespace BepInEx
 
 			Logger = Logging.Logger.CreateLogSource(metadata.Name);
 
-			Config = new ConfigFile(Utility.CombinePaths(Paths.ConfigPath, metadata.GUID + ".cfg"), false);
+			Config = new ConfigFile(Utility.CombinePaths(Paths.ConfigPath, metadata.GUID + ".cfg"), false, metadata);
 		}
 	}
 }

+ 2 - 2
BepInEx/Contract/PluginInfo.cs

@@ -3,7 +3,7 @@ using System.IO;
 using System.Linq;
 using BepInEx.Bootstrap;
 
-namespace BepInEx.Contract
+namespace BepInEx
 {
 	public class PluginInfo : ICacheable
 	{
@@ -80,4 +80,4 @@ namespace BepInEx.Contract
 			Incompatibilities = incList;
 		}
 	}
-}
+}

+ 5 - 8
BepInEx/Logging/ConsoleLogListener.cs

@@ -9,11 +9,9 @@ namespace BepInEx.Logging
 	/// </summary>
 	public class ConsoleLogListener : ILogListener
 	{
-		protected LogLevel DisplayedLogLevel = (LogLevel)Enum.Parse(typeof(LogLevel), ConfigConsoleDisplayedLevel.Value, true);
-
 		public void LogEvent(object sender, LogEventArgs eventArgs)
 		{
-			if (eventArgs.Level.GetHighestLevel() > DisplayedLogLevel)
+			if (eventArgs.Level.GetHighestLevel() > ConfigConsoleDisplayedLevel.Value)
 				return;
 
 			string log = $"[{eventArgs.Level,-7}:{((ILogSource)sender).SourceName,10}] {eventArgs.Data}\r\n";
@@ -25,10 +23,9 @@ namespace BepInEx.Logging
 
 		public void Dispose() { }
 
-		private static readonly ConfigWrapper<string> ConfigConsoleDisplayedLevel = ConfigFile.CoreConfig.Wrap(
-			"Logging.Console",
-			"DisplayedLogLevel",
-			"Only displays the specified log level and above in the console output.",
-			"Info");
+		private static readonly ConfigEntry<LogLevel> ConfigConsoleDisplayedLevel = ConfigFile.CoreConfig.AddSetting(
+			"Logging.Console","DisplayedLogLevel",
+			LogLevel.Info,
+			"Only displays the specified log level and above in the console output.");
 	}
 }

+ 1 - 0
BepInEx/Properties/AssemblyInfo.cs

@@ -25,6 +25,7 @@ using BepInEx;
 [assembly: Guid("4ffba620-f5ed-47f9-b90c-dad1316fd9b9")]
 
 [assembly: InternalsVisibleTo("BepInEx.Preloader")]
+[assembly: InternalsVisibleTo("BepInExTests")]
 
 // Version information for an assembly consists of the following four values:
 //

+ 180 - 0
BepInEx/ThreadingHelper.cs

@@ -0,0 +1,180 @@
+using System;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading;
+using BepInEx.Logging;
+using UnityEngine;
+
+namespace BepInEx
+{
+	/// <summary>
+	/// Provides methods for running code on other threads and synchronizing with the main thread.
+	/// </summary>
+	[DefaultExecutionOrder(int.MinValue)]
+	public sealed class ThreadingHelper : MonoBehaviour, ISynchronizeInvoke
+	{
+		private readonly object _invokeLock = new object();
+		private Action _invokeList;
+		private Thread _mainThread;
+
+		/// <summary>
+		/// Current instance of the helper.
+		/// </summary>
+		public static ThreadingHelper Instance { get; private set; }
+
+		/// <summary>
+		/// Gives methods for invoking delegates on the main unity thread, both synchronously and asynchronously.
+		/// Can be used in many built-in framework types, for example <see cref="System.IO.FileSystemWatcher.SynchronizingObject"/> 
+		/// and <see cref="System.Timers.Timer.SynchronizingObject"/> to make their events fire on the main unity thread.
+		/// </summary>
+		public static ISynchronizeInvoke SynchronizingObject => Instance;
+
+		internal static void Initialize()
+		{
+			var go = new GameObject("BepInEx_ThreadingHelper");
+			DontDestroyOnLoad(go);
+			Instance = go.AddComponent<ThreadingHelper>();
+		}
+
+		/// <summary>
+		/// Queue the delegate to be invoked on the main unity thread. Use to synchronize your threads.
+		/// </summary>
+		public void StartSyncInvoke(Action action)
+		{
+			if (action == null) throw new ArgumentNullException(nameof(action));
+
+			lock (_invokeLock) _invokeList += action;
+		}
+
+		private void Update()
+		{
+			// The CurrentThread can change between Awake and later methods, it's safest to get it here.
+			if (_mainThread == null)
+				_mainThread = Thread.CurrentThread;
+
+			// Safe to do outside of lock because nothing can remove callbacks, at worst we execute with 1 frame delay
+			if (_invokeList == null) return;
+
+			Action toRun;
+			lock (_invokeLock)
+			{
+				toRun = _invokeList;
+				_invokeList = null;
+			}
+
+			// Need to execute outside of the lock in case the callback itself calls Invoke we could deadlock
+			// The invocation would also block any threads that call Invoke
+			foreach (var action in toRun.GetInvocationList().Cast<Action>())
+			{
+				try
+				{
+					action();
+				}
+				catch (Exception ex)
+				{
+					LogInvocationException(ex);
+				}
+			}
+		}
+
+		/// <summary>
+		/// Queue the delegate to be invoked on a background thread. Use this to run slow tasks without affecting the game.
+		/// NOTE: Most of Unity API can not be accessed while running on another thread!
+		/// </summary>
+		/// <param name="action">
+		/// Task to be executed on another thread. Can optionally return an Action that will be executed on the main thread.
+		/// You can use this action to return results of your work safely. Return null if this is not needed.
+		/// </param>
+		public void StartAsyncInvoke(Func<Action> action)
+		{
+			void DoWork(object _)
+			{
+				try
+				{
+					var result = action();
+
+					if (result != null)
+						StartSyncInvoke(result);
+				}
+				catch (Exception ex)
+				{
+					LogInvocationException(ex);
+				}
+			}
+
+			if (!ThreadPool.QueueUserWorkItem(DoWork))
+				throw new NotSupportedException("Failed to queue the action on ThreadPool");
+		}
+
+		private static void LogInvocationException(Exception ex)
+		{
+			Logging.Logger.Log(LogLevel.Error, ex);
+			if (ex.InnerException != null) Logging.Logger.Log(LogLevel.Error, "INNER: " + ex.InnerException);
+		}
+
+		#region ISynchronizeInvoke
+
+		IAsyncResult ISynchronizeInvoke.BeginInvoke(Delegate method, object[] args)
+		{
+			object Invoke()
+			{
+				try { return method.DynamicInvoke(args); }
+				catch (Exception ex) { return ex; }
+			}
+
+			var result = new InvokeResult();
+
+			if (!InvokeRequired)
+				result.Finish(Invoke(), true);
+			else
+				StartSyncInvoke(() => result.Finish(Invoke(), false));
+
+			return result;
+		}
+
+		object ISynchronizeInvoke.EndInvoke(IAsyncResult result)
+		{
+			result.AsyncWaitHandle.WaitOne();
+
+			if (result.AsyncState is Exception ex)
+				throw ex;
+			return result.AsyncState;
+		}
+
+		object ISynchronizeInvoke.Invoke(Delegate method, object[] args)
+		{
+			var invokeResult = ((ISynchronizeInvoke)this).BeginInvoke(method, args);
+			return ((ISynchronizeInvoke)this).EndInvoke(invokeResult);
+		}
+
+		/// <summary>
+		/// False if current code is executing on the main unity thread, otherwise True.
+		/// Warning: Will return false before the first frame finishes (i.e. inside plugin Awake and Start methods).
+		/// </summary>
+		/// <inheritdoc />
+		public bool InvokeRequired => _mainThread == null || _mainThread != Thread.CurrentThread;
+
+		private sealed class InvokeResult : IAsyncResult
+		{
+			public InvokeResult()
+			{
+				AsyncWaitHandle = new EventWaitHandle(false, EventResetMode.ManualReset);
+			}
+
+			public void Finish(object result, bool completedSynchronously)
+			{
+				AsyncState = result;
+				CompletedSynchronously = completedSynchronously;
+				IsCompleted = true;
+				((EventWaitHandle)AsyncWaitHandle).Set();
+			}
+
+			public bool IsCompleted { get; private set; }
+			public WaitHandle AsyncWaitHandle { get; }
+			public object AsyncState { get; private set; }
+			public bool CompletedSynchronously { get; private set; }
+		}
+
+		#endregion
+	}
+}

+ 29 - 0
BepInExTests/AssemblyInit.cs

@@ -0,0 +1,29 @@
+using System.IO;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace BepInEx.Tests
+{
+	[TestClass]
+	public class AssemblyInit
+	{
+		private static string _testPath;
+
+		[AssemblyInitialize]
+		public static void InitAss(TestContext context)
+		{
+			_testPath = Path.Combine(Path.GetTempPath(), "BepinexTestDir");
+			Directory.CreateDirectory(_testPath);
+			
+			string exePath = Path.Combine(_testPath, "Text.exe");
+			File.WriteAllBytes(exePath, new byte[] { });
+
+			Paths.SetExecutablePath(_testPath);
+		}
+
+		[AssemblyCleanup]
+		public static void CleanupAss()
+		{
+			Directory.Delete(_testPath, true);
+		}
+	}
+}

+ 116 - 0
BepInExTests/BepInExTests.csproj

@@ -0,0 +1,116 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <Import Project="..\packages\MSTest.TestAdapter.1.3.2\build\net45\MSTest.TestAdapter.props" Condition="Exists('..\packages\MSTest.TestAdapter.1.3.2\build\net45\MSTest.TestAdapter.props')" />
+  <PropertyGroup>
+    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
+    <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
+    <ProjectGuid>{E7CD429A-D057-48E3-8C51-E5C934E8E07B}</ProjectGuid>
+    <OutputType>Library</OutputType>
+    <AppDesignerFolder>Properties</AppDesignerFolder>
+    <RootNamespace>BepInExTests</RootNamespace>
+    <AssemblyName>BepInExTests</AssemblyName>
+    <TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
+    <FileAlignment>512</FileAlignment>
+    <ProjectTypeGuids>{3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
+    <VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">10.0</VisualStudioVersion>
+    <VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
+    <ReferencePath>$(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages</ReferencePath>
+    <IsCodedUITest>False</IsCodedUITest>
+    <TestProjectType>UnitTest</TestProjectType>
+    <NuGetPackageImportStamp>
+    </NuGetPackageImportStamp>
+    <TargetFrameworkProfile />
+    <FileUpgradeFlags>
+    </FileUpgradeFlags>
+    <UpgradeBackupLocation>
+    </UpgradeBackupLocation>
+    <OldToolsVersion>15.0</OldToolsVersion>
+  </PropertyGroup>
+  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'">
+    <DebugSymbols>true</DebugSymbols>
+    <OutputPath>bin\x86\Debug\</OutputPath>
+    <DefineConstants>DEBUG;TRACE</DefineConstants>
+    <DebugType>full</DebugType>
+    <PlatformTarget>x86</PlatformTarget>
+    <ErrorReport>prompt</ErrorReport>
+    <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet>
+  </PropertyGroup>
+  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'">
+    <OutputPath>bin\x86\Release\</OutputPath>
+    <DefineConstants>TRACE</DefineConstants>
+    <Optimize>true</Optimize>
+    <DebugType>pdbonly</DebugType>
+    <PlatformTarget>x86</PlatformTarget>
+    <ErrorReport>prompt</ErrorReport>
+    <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet>
+  </PropertyGroup>
+  <ItemGroup>
+    <Reference Include="Microsoft.VisualStudio.TestPlatform.TestFramework, Version=14.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
+      <HintPath>..\packages\MSTest.TestFramework.1.3.2\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.dll</HintPath>
+    </Reference>
+    <Reference Include="Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions, Version=14.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
+      <HintPath>..\packages\MSTest.TestFramework.1.3.2\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll</HintPath>
+    </Reference>
+    <Reference Include="System" />
+    <Reference Include="UnityEngine">
+      <HintPath>..\lib\UnityEngine.dll</HintPath>
+    </Reference>
+  </ItemGroup>
+  <Choose>
+    <When Condition="('$(VisualStudioVersion)' == '10.0' or '$(VisualStudioVersion)' == '') and '$(TargetFrameworkVersion)' == 'v3.5'">
+      <ItemGroup>
+        <Reference Include="Microsoft.VisualStudio.QualityTools.UnitTestFramework, Version=10.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL" />
+      </ItemGroup>
+    </When>
+    <Otherwise />
+  </Choose>
+  <ItemGroup>
+    <Compile Include="AssemblyInit.cs" />
+    <Compile Include="Configuration\ConfigFileTests.cs" />
+    <Compile Include="Properties\AssemblyInfo.cs" />
+  </ItemGroup>
+  <ItemGroup>
+    <None Include="packages.config" />
+  </ItemGroup>
+  <ItemGroup>
+    <ProjectReference Include="..\BepInEx\BepInEx.csproj">
+      <Project>{4ffba620-f5ed-47f9-b90c-dad1316fd9b9}</Project>
+      <Name>BepInEx</Name>
+    </ProjectReference>
+  </ItemGroup>
+  <Choose>
+    <When Condition="'$(VisualStudioVersion)' == '10.0' And '$(IsCodedUITest)' == 'True'">
+      <ItemGroup>
+        <Reference Include="Microsoft.VisualStudio.QualityTools.CodedUITestFramework, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
+          <Private>False</Private>
+        </Reference>
+        <Reference Include="Microsoft.VisualStudio.TestTools.UITest.Common, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
+          <Private>False</Private>
+        </Reference>
+        <Reference Include="Microsoft.VisualStudio.TestTools.UITest.Extension, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
+          <Private>False</Private>
+        </Reference>
+        <Reference Include="Microsoft.VisualStudio.TestTools.UITesting, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
+          <Private>False</Private>
+        </Reference>
+      </ItemGroup>
+    </When>
+  </Choose>
+  <Import Project="$(VSToolsPath)\TeamTest\Microsoft.TestTools.targets" Condition="Exists('$(VSToolsPath)\TeamTest\Microsoft.TestTools.targets')" />
+  <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
+  <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
+    <PropertyGroup>
+      <ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them.  For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText>
+    </PropertyGroup>
+    <Error Condition="!Exists('..\packages\MSTest.TestAdapter.1.3.2\build\net45\MSTest.TestAdapter.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\MSTest.TestAdapter.1.3.2\build\net45\MSTest.TestAdapter.props'))" />
+    <Error Condition="!Exists('..\packages\MSTest.TestAdapter.1.3.2\build\net45\MSTest.TestAdapter.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\MSTest.TestAdapter.1.3.2\build\net45\MSTest.TestAdapter.targets'))" />
+  </Target>
+  <Import Project="..\packages\MSTest.TestAdapter.1.3.2\build\net45\MSTest.TestAdapter.targets" Condition="Exists('..\packages\MSTest.TestAdapter.1.3.2\build\net45\MSTest.TestAdapter.targets')" />
+  <!-- To modify your build process, add your task inside one of the targets below and uncomment it. 
+       Other similar extension points exist, see Microsoft.Common.targets.
+  <Target Name="BeforeBuild">
+  </Target>
+  <Target Name="AfterBuild">
+  </Target>
+  -->
+</Project>

+ 321 - 0
BepInExTests/Configuration/ConfigFileTests.cs

@@ -0,0 +1,321 @@
+using System;
+using System.Collections.Concurrent;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using UnityEngine;
+
+namespace BepInEx.Configuration.Tests
+{
+	[TestClass]
+	public class ConfigFileTests
+	{
+		private static ConcurrentBag<ConfigFile> _toRemove;
+
+		[ClassInitialize]
+		public static void Init(TestContext context)
+		{
+			_toRemove = new ConcurrentBag<ConfigFile>();
+		}
+
+		[ClassCleanup]
+		public static void Cleanup()
+		{
+			foreach (var configFile in _toRemove)
+				File.Delete(configFile.ConfigFilePath);
+		}
+
+		private static ConfigFile MakeConfig()
+		{
+			string configPath = Path.GetTempFileName();
+			if (configPath == null) throw new InvalidOperationException("Wtf");
+			var config = new ConfigFile(configPath, true);
+			_toRemove.Add(config);
+			return config;
+		}
+
+		[TestMethod]
+		public void SaveTest()
+		{
+			MakeConfig().Save();
+		}
+
+		[TestMethod]
+		public void SaveTestValueChange()
+		{
+			var c = MakeConfig();
+
+			var w = c.AddSetting("Cat", "Key", 0, new ConfigDescription("Test"));
+			var lines = File.ReadAllLines(c.ConfigFilePath);
+			Assert.AreEqual(1, lines.Count(x => x.Equals("[Cat]")));
+			Assert.AreEqual(1, lines.Count(x => x.Equals("## Test")));
+			Assert.AreEqual(1, lines.Count(x => x.Equals("Key = 0")));
+
+			c.Save();
+			lines = File.ReadAllLines(c.ConfigFilePath);
+			Assert.AreEqual(1, lines.Count(x => x.Equals("[Cat]")));
+			Assert.AreEqual(1, lines.Count(x => x.Equals("## Test")));
+			Assert.AreEqual(1, lines.Count(x => x.Equals("Key = 0")));
+
+			w.Value = 69;
+			lines = File.ReadAllLines(c.ConfigFilePath);
+			Assert.AreEqual(1, lines.Count(x => x.Equals("[Cat]")));
+			Assert.AreEqual(1, lines.Count(x => x.Equals("## Test")));
+			Assert.AreEqual(1, lines.Count(x => x.Equals("Key = 69")));
+		}
+
+		[TestMethod]
+		public void AutoSaveTest()
+		{
+			var c = MakeConfig();
+			c.AddSetting("Cat", "Key", 0, new ConfigDescription("Test"));
+
+			var eventFired = new AutoResetEvent(false);
+			c.ConfigReloaded += (sender, args) => eventFired.Set();
+
+			c.Save();
+
+			Assert.IsFalse(eventFired.WaitOne(200));
+		}
+
+		[TestMethod]
+		public void ReadTest()
+		{
+			var c = MakeConfig();
+			File.WriteAllText(c.ConfigFilePath, "[Cat]\n# Test\nKey=1\n");
+			c.Reload();
+			var w = c.AddSetting("Cat", "Key", 0, "Test");
+			Assert.AreEqual(w.Value, 1);
+			var w2 = c.AddSetting("Cat", "Key2", 0, new ConfigDescription("Test"));
+			Assert.AreEqual(w2.Value, 0);
+		}
+
+		[TestMethod]
+		public void ReadTest2()
+		{
+			var c = MakeConfig();
+			var w = c.AddSetting("Cat", "Key", 0, new ConfigDescription("Test"));
+			Assert.AreEqual(w.Value, 0);
+
+			File.WriteAllText(c.ConfigFilePath, "[Cat]\n# Test\nKey = 1 \n");
+
+			c.Reload();
+			Assert.AreEqual(w.Value, 1);
+		}
+
+		[TestMethod]
+		public void FileWatchTestNoSelfReload()
+		{
+			var c = MakeConfig();
+
+			var eventFired = new AutoResetEvent(false);
+			c.ConfigReloaded += (sender, args) => eventFired.Set();
+
+			c.Save();
+
+			Assert.IsFalse(eventFired.WaitOne(200));
+		}
+
+		[TestMethod]
+		public void EventTestWrapper()
+		{
+			var c = MakeConfig();
+			var w = c.AddSetting("Cat", "Key", 0, new ConfigDescription("Test"));
+
+			File.WriteAllText(c.ConfigFilePath, "[Cat]\n# Test\nKey=1\n");
+
+			var eventFired = false;
+			w.SettingChanged += (sender, args) => eventFired = true;
+
+			c.Reload();
+
+			Assert.IsTrue(eventFired);
+		}
+
+		[TestMethod]
+		public void PersistHomeless()
+		{
+			var c = MakeConfig();
+
+			File.WriteAllText(c.ConfigFilePath, "[Cat]\n# Test\nKey=1\nHomeless=0");
+			c.Reload();
+
+			var w = c.AddSetting("Cat", "Key", 0, new ConfigDescription("Test"));
+
+			c.Save();
+
+			Assert.IsTrue(File.ReadAllLines(c.ConfigFilePath).Single(x => x.StartsWith("Homeless") && x.EndsWith("0")) != null);
+		}
+
+		[TestMethod]
+		public void EventTestReload()
+		{
+			var c = MakeConfig();
+			var eventFired = false;
+
+			var w = c.AddSetting("Cat", "Key", 0, new ConfigDescription("Test"));
+			w.SettingChanged += (sender, args) => eventFired = true;
+
+			Assert.IsFalse(eventFired);
+
+			File.WriteAllText(c.ConfigFilePath, "[Cat]\n# Test\nKey=1\n");
+			c.Reload();
+
+			Assert.IsTrue(eventFired);
+		}
+
+		[TestMethod]
+		public void ValueRangeTest()
+		{
+			var c = MakeConfig();
+			var w = c.AddSetting("Cat", "Key", 0, new ConfigDescription("Test", new AcceptableValueRange<int>(0, 2)));
+
+			Assert.AreEqual(0, w.Value);
+			w.Value = 2;
+			Assert.AreEqual(2, w.Value);
+			w.Value = -2;
+			Assert.AreEqual(0, w.Value);
+			w.Value = 4;
+			Assert.AreEqual(2, w.Value);
+		}
+
+		[TestMethod]
+		[ExpectedException(typeof(ArgumentException))]
+		public void ValueRangeBadTypeTest()
+		{
+			var c = MakeConfig();
+			c.AddSetting("Cat", "Key", 0, new ConfigDescription("Test", new AcceptableValueRange<float>(1, 2)));
+			Assert.Fail();
+		}
+
+		[TestMethod]
+		public void ValueRangeDefaultTest()
+		{
+			var c = MakeConfig();
+			var w = c.AddSetting("Cat", "Key", 0, new ConfigDescription("Test", new AcceptableValueRange<int>(1, 2)));
+
+			Assert.AreEqual(w.Value, 1);
+		}
+
+		[TestMethod]
+		public void ValueRangeLoadTest()
+		{
+			var c = MakeConfig();
+
+			File.WriteAllText(c.ConfigFilePath, "[Cat]\nKey = 1\n");
+			c.Reload();
+
+			var w = c.AddSetting("Cat", "Key", 0, new ConfigDescription("Test", new AcceptableValueRange<int>(0, 2)));
+
+			Assert.AreEqual(w.Value, 1);
+
+			File.WriteAllText(c.ConfigFilePath, "[Cat]\nKey = 5\n");
+			c.Reload();
+
+			Assert.AreEqual(w.Value, 2);
+		}
+
+		[TestMethod]
+		public void ValueListTest()
+		{
+			var c = MakeConfig();
+			var w = c.AddSetting("Cat", "Key", "kek", new ConfigDescription("Test", new AcceptableValueList<string>("lel", "kek", "wew", "why")));
+
+			Assert.AreEqual("kek", w.Value);
+			w.Value = "wew";
+			Assert.AreEqual("wew", w.Value);
+			w.Value = "no";
+			Assert.AreEqual("lel", w.Value);
+			w.Value = null;
+			Assert.AreEqual("lel", w.Value);
+		}
+
+		[TestMethod]
+		public void KeyboardShortcutTest()
+		{
+			var shortcut = new KeyboardShortcut(KeyCode.H, KeyCode.O, KeyCode.R, KeyCode.S, KeyCode.E, KeyCode.Y);
+			var s = shortcut.Serialize();
+			var d = KeyboardShortcut.Deserialize(s);
+			Assert.AreEqual(shortcut, d);
+
+			var c = MakeConfig();
+			var w = c.AddSetting("Cat", "Key", new KeyboardShortcut(KeyCode.A, KeyCode.LeftShift));
+			Assert.AreEqual(new KeyboardShortcut(KeyCode.A, KeyCode.LeftShift), w.Value);
+
+			w.Value = shortcut;
+			c.Reload();
+			Assert.AreEqual(shortcut, w.Value);
+		}
+
+		[TestMethod]
+		public void KeyboardShortcutTest2()
+		{
+			Assert.AreEqual(KeyboardShortcut.Empty, new KeyboardShortcut());
+
+			var c = MakeConfig();
+
+			var w = c.AddSetting("Cat", "Key", KeyboardShortcut.Empty, new ConfigDescription("Test"));
+
+			Assert.AreEqual("", w.GetSerializedValue());
+
+			w.SetSerializedValue(w.GetSerializedValue());
+			Assert.AreEqual(KeyboardShortcut.Empty, w.Value);
+
+			var testShortcut = new KeyboardShortcut(KeyCode.A, KeyCode.B, KeyCode.C);
+			w.Value = testShortcut;
+
+			w.SetSerializedValue(w.GetSerializedValue());
+			Assert.AreEqual(testShortcut, w.Value);
+
+			c.Save();
+			c.Reload();
+
+			Assert.AreEqual(testShortcut, w.Value);
+		}
+
+		[TestMethod]
+		public void StringEscapeChars()
+		{
+			const string testVal = "new line\n test \t\0";
+
+			var c = MakeConfig();
+			var w = c.AddSetting("Cat", "Key", testVal, new ConfigDescription("Test"));
+
+			Assert.AreEqual(testVal, w.Value);
+			Assert.IsFalse(w.GetSerializedValue().Any(x => x == '\n'));
+
+			w.SetSerializedValue(w.GetSerializedValue());
+			Assert.AreEqual(testVal, w.Value);
+
+			c.Save();
+			c.Reload();
+
+			Assert.AreEqual(testVal, w.Value);
+		}
+
+		[TestMethod]
+		public void UnescapedPathString()
+		{
+			var c = MakeConfig();
+			var w = c.AddSetting("Cat", "Key", "", new ConfigDescription("Test"));
+
+			var unescaped = @"D:\test\p ath";
+			foreach (string testVal in new[] { unescaped, @"D:\\test\\p ath" })
+			{
+				File.WriteAllText(c.ConfigFilePath, $"[Cat]\n# Test\nKey={testVal}\n");
+				c.Reload();
+
+				Assert.AreEqual(unescaped, w.Value);
+
+				w.SetSerializedValue(w.GetSerializedValue());
+				Assert.AreEqual(unescaped, w.Value);
+
+				c.Save();
+				c.Reload();
+
+				Assert.AreEqual(unescaped, w.Value);
+			}
+		}
+	}
+}

+ 36 - 0
BepInExTests/Properties/AssemblyInfo.cs

@@ -0,0 +1,36 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+// General Information about an assembly is controlled through the following 
+// set of attributes. Change these attribute values to modify the information
+// associated with an assembly.
+[assembly: AssemblyTitle("BepInExTests")]
+[assembly: AssemblyDescription("")]
+[assembly: AssemblyConfiguration("")]
+[assembly: AssemblyCompany("")]
+[assembly: AssemblyProduct("BepInExTests")]
+[assembly: AssemblyCopyright("Copyright ©  2019")]
+[assembly: AssemblyTrademark("")]
+[assembly: AssemblyCulture("")]
+
+// Setting ComVisible to false makes the types in this assembly not visible 
+// to COM components.  If you need to access a type in this assembly from 
+// COM, set the ComVisible attribute to true on that type.
+[assembly: ComVisible(false)]
+
+// The following GUID is for the ID of the typelib if this project is exposed to COM
+[assembly: Guid("e7cd429a-d057-48e3-8c51-e5c934e8e07b")]
+
+// Version information for an assembly consists of the following four values:
+//
+//      Major Version
+//      Minor Version 
+//      Build Number
+//      Revision
+//
+// You can specify all the values or you can default the Build and Revision Numbers 
+// by using the '*' as shown below:
+// [assembly: AssemblyVersion("1.0.*")]
+[assembly: AssemblyVersion("1.0.0.0")]
+[assembly: AssemblyFileVersion("1.0.0.0")]

+ 5 - 0
BepInExTests/packages.config

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<packages>
+  <package id="MSTest.TestAdapter" version="1.3.2" targetFramework="net45" />
+  <package id="MSTest.TestFramework" version="1.3.2" targetFramework="net45" />
+</packages>

+ 1 - 1
submodules/MonoMod

@@ -1 +1 @@
-Subproject commit 9462a0f75b669606b59a4648a3461338d2be32c2
+Subproject commit a70072cdf759ac0cfa80991fcd2cca67d3eec130