Browse Source

Finished Features implimentation

Added support for custom Init injection
pull/46/head
DaNike 5 years ago
parent
commit
afc5ca7b47
12 changed files with 428 additions and 46 deletions
  1. +1
    -0
      BSIPA.sln.DotSettings
  2. +6
    -1
      IPA.Loader/IPA.Loader.csproj
  3. +66
    -0
      IPA.Loader/Loader/Features/DefineFeature.cs
  4. +150
    -5
      IPA.Loader/Loader/Features/Feature.cs
  5. +12
    -0
      IPA.Loader/Loader/Features/NoUpdateFeature.cs
  6. +14
    -0
      IPA.Loader/Loader/Features/PrintFeature.cs
  7. +89
    -0
      IPA.Loader/Loader/PluginInitInjector.cs
  8. +82
    -39
      IPA.Loader/Loader/PluginLoader.cs
  9. +3
    -1
      IPA.Loader/Loader/manifest.json
  10. +1
    -0
      IPA.Loader/Logging/Logger.cs
  11. +4
    -0
      IPA.Loader/Utilities/BeatSaber.cs
  12. BIN
      Refs/UnityEngine.CoreModule.dll

+ 1
- 0
BSIPA.sln.DotSettings View File

@ -23,6 +23,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=deps/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=deps/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Modsaber/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Modsaber/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Pdbs/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Pdbs/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=plugin_0027s/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Prefs/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Prefs/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=unpatch/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=unpatch/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Virtualize/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Virtualize/@EntryIndexedValue">True</s:Boolean>

+ 6
- 1
IPA.Loader/IPA.Loader.csproj View File

@ -14,6 +14,7 @@
<Deterministic>true</Deterministic> <Deterministic>true</Deterministic>
<PathMap>$(SolutionDir)=C:\</PathMap> <PathMap>$(SolutionDir)=C:\</PathMap>
<DebugType>portable</DebugType> <DebugType>portable</DebugType>
<TargetFrameworkProfile />
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols> <DebugSymbols>true</DebugSymbols>
@ -61,6 +62,10 @@
<Compile Include="Config\IConfigProvider.cs" /> <Compile Include="Config\IConfigProvider.cs" />
<Compile Include="Config\SelfConfig.cs" /> <Compile Include="Config\SelfConfig.cs" />
<Compile Include="Loader\Composite\CompositeBSPlugin.cs" /> <Compile Include="Loader\Composite\CompositeBSPlugin.cs" />
<Compile Include="Loader\Features\DefineFeature.cs" />
<Compile Include="Loader\Features\NoUpdateFeature.cs" />
<Compile Include="Loader\Features\PrintFeature.cs" />
<Compile Include="Loader\PluginInitInjector.cs" />
<Compile Include="Loader\LibLoader.cs" /> <Compile Include="Loader\LibLoader.cs" />
<Compile Include="Loader\Features\Feature.cs" /> <Compile Include="Loader\Features\Feature.cs" />
<Compile Include="Loader\PluginLoader.cs" /> <Compile Include="Loader\PluginLoader.cs" />
@ -96,7 +101,7 @@
<Compile Include="Updating\ModSaber\Updater.cs" /> <Compile Include="Updating\ModSaber\Updater.cs" />
<Compile Include="Updating\SelfPlugin.cs" /> <Compile Include="Updating\SelfPlugin.cs" />
<Compile Include="Utilities\Extensions.cs" /> <Compile Include="Utilities\Extensions.cs" />
<Compile Include="Utilities\LoneFunctions.cs" />
<Compile Include="Utilities\Utils.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Ionic.Zip"> <PackageReference Include="Ionic.Zip">


+ 66
- 0
IPA.Loader/Loader/Features/DefineFeature.cs View File

@ -0,0 +1,66 @@
using System;
using System.IO;
namespace IPA.Loader.Features
{
internal class DefineFeature : Feature
{
public static bool NewFeature = true;
public override bool Initialize(PluginLoader.PluginMetadata meta, string[] parameters)
{ // parameters should be (name, fully qualified type)
if (parameters.Length != 2)
{
InvalidMessage = "Incorrect number of parameters";
return false;
}
RequireLoaded(meta);
Type type;
try
{
type = meta.Assembly.GetType(parameters[1]);
}
catch (ArgumentException)
{
InvalidMessage = $"Invalid type name {parameters[1]}";
return false;
}
catch (Exception e) when (e is FileNotFoundException || e is FileLoadException || e is BadImageFormatException)
{
var filename = "";
switch (e)
{
case FileNotFoundException fn:
filename = fn.FileName;
break;
case FileLoadException fl:
filename = fl.FileName;
break;
case BadImageFormatException bi:
filename = bi.FileName;
break;
}
InvalidMessage = $"Could not find {filename} while loading type";
return false;
}
try
{
if (RegisterFeature(parameters[0], type)) return NewFeature = true;
InvalidMessage = $"Feature with name {parameters[0]} already exists";
return false;
}
catch (ArgumentException)
{
InvalidMessage = $"{type.FullName} not a subclass of {nameof(Feature)}";
return false;
}
}
}
}

+ 150
- 5
IPA.Loader/Loader/Features/Feature.cs View File

@ -1,38 +1,183 @@
namespace IPA.Loader.Features
using System;
using System.Collections.Generic;
using System.Text;
namespace IPA.Loader.Features
{ {
/// <summary> /// <summary>
/// The root interface for a mod Feature. /// The root interface for a mod Feature.
/// </summary> /// </summary>
/// <remarks>
/// Avoid storing any data in any subclasses. If you do, it may result in a failure to load the feature.
/// </remarks>
public abstract class Feature public abstract class Feature
{ {
/// <summary> /// <summary>
/// Initializes the feature with the parameters provided in the definition. /// Initializes the feature with the parameters provided in the definition.
/// ///
/// Note: When no parenthesis are provided, <paramref name="parameters"/> is null.
/// Note: When no parenthesis are provided, <paramref name="parameters"/> is an empty array.
/// </summary> /// </summary>
/// <remarks>
/// Returning <see langword="false" /> does *not* prevent the plugin from being loaded. It simply prevents the feature from being used.
/// </remarks>
/// <param name="meta">the metadata of the plugin that is being prepared</param> /// <param name="meta">the metadata of the plugin that is being prepared</param>
/// <param name="parameters">the parameters passed to the feature definition, or null</param> /// <param name="parameters">the parameters passed to the feature definition, or null</param>
/// <returns><see langword="true"/> if the feature is valid for the plugin, <see langword="false"/> otherwise</returns> /// <returns><see langword="true"/> if the feature is valid for the plugin, <see langword="false"/> otherwise</returns>
public abstract bool Initialize(PluginLoader.PluginMetadata meta, string[] parameters); public abstract bool Initialize(PluginLoader.PluginMetadata meta, string[] parameters);
/// <summary> /// <summary>
/// Called before a plugin is loaded.
/// The message to be logged when the feature is not valid for a plugin.
/// This should also be set whenever either <see cref="BeforeLoad"/> or <see cref="BeforeInit"/> returns false.
/// </summary> /// </summary>
public virtual string InvalidMessage { get; protected set; }
/// <summary>
/// Called before a plugin is loaded. This should never throw an exception. An exception will abort the loading of the plugin with an error.
/// </summary>
/// <remarks>
/// The assembly will still be loaded, but the plugin will not be constructed if this returns <see langword="false" />.
/// Any features it defines, for example, will still be loaded.
/// </remarks>
/// <param name="plugin">the plugin about to be loaded</param> /// <param name="plugin">the plugin about to be loaded</param>
/// <returns>whether or not the plugin should be loaded</returns> /// <returns>whether or not the plugin should be loaded</returns>
public virtual bool BeforeLoad(PluginLoader.PluginMetadata plugin) => true; public virtual bool BeforeLoad(PluginLoader.PluginMetadata plugin) => true;
/// <summary> /// <summary>
/// Called before a plugin's Init method is called.
/// Called before a plugin's Init method is called. This will not be called if there is no Init method. This should never throw an exception. An exception will abort the loading of the plugin with an error.
/// </summary> /// </summary>
/// <param name="plugin">the plugin to be initialized</param> /// <param name="plugin">the plugin to be initialized</param>
/// <returns>whether or not to call the Init method</returns> /// <returns>whether or not to call the Init method</returns>
public virtual bool BeforeInit(PluginLoader.PluginInfo plugin) => true; public virtual bool BeforeInit(PluginLoader.PluginInfo plugin) => true;
/// <summary> /// <summary>
/// Called after a plugin has been fully initialized, whether or not there is an Init method.
/// Called after a plugin has been fully initialized, whether or not there is an Init method. This should never throw an exception.
/// </summary> /// </summary>
/// <param name="plugin">the plugin that was just initialized</param> /// <param name="plugin">the plugin that was just initialized</param>
public virtual void AfterInit(PluginLoader.PluginInfo plugin) { } public virtual void AfterInit(PluginLoader.PluginInfo plugin) { }
/// <summary>
/// Ensures a plugin's assembly is loaded. Do not use unless you need to.
/// </summary>
/// <param name="plugin">the plugin to ensure is loaded.</param>
protected void RequireLoaded(PluginLoader.PluginMetadata plugin) => PluginLoader.Load(plugin);
private static readonly Dictionary<string, Type> featureTypes = new Dictionary<string, Type>
{
{ "define-feature", typeof(DefineFeature) }
};
internal static bool RegisterFeature(string name, Type type)
{
if (!typeof(Feature).IsAssignableFrom(type))
throw new ArgumentException($"Feature type not subclass of {nameof(Feature)}", nameof(type));
if (featureTypes.ContainsKey(name)) return false;
featureTypes.Add(name, type);
return true;
}
internal struct FeatureParse
{
public readonly string Name;
public readonly string[] Parameters;
public FeatureParse(string name, string[] parameters)
{
Name = name;
Parameters = parameters;
}
}
// returns false with both outs null for no such feature
internal static bool TryParseFeature(string featureString, PluginLoader.PluginMetadata plugin,
out Feature feature, out Exception failException, out bool featureValid, out FeatureParse parsed,
FeatureParse? preParsed = null)
{
failException = null;
feature = null;
featureValid = false;
if (preParsed == null)
{
var builder = new StringBuilder();
string name = null;
var parameters = new List<string>();
bool escape = false;
bool readingParams = false;
bool removeWhitespace = true;
foreach (var chr in featureString)
{
if (escape)
{
builder.Append(chr);
escape = false;
}
else
{
switch (chr)
{
case '\\':
escape = true;
break;
case '(' when !readingParams:
removeWhitespace = true;
readingParams = true;
name = builder.ToString();
builder.Clear();
break;
case ')' when readingParams:
readingParams = false;
goto case ',';
case ',':
parameters.Add(builder.ToString());
if (!readingParams) break;
builder.Clear();
removeWhitespace = true;
break;
default:
if (removeWhitespace && !char.IsWhiteSpace(chr))
removeWhitespace = false;
if (!removeWhitespace)
builder.Append(chr);
break;
}
}
}
if (name == null)
name = builder.ToString();
parsed = new FeatureParse(name, parameters.ToArray());
if (readingParams)
{
failException = new Exception("Malformed feature definition");
return false;
}
}
else
parsed = preParsed.Value;
if (!featureTypes.TryGetValue(parsed.Name, out var featureType))
return false;
try
{
if (!(Activator.CreateInstance(featureType) is Feature aFeature))
{
failException = new InvalidCastException("Feature type not a subtype of Feature");
return false;
}
featureValid = aFeature.Initialize(plugin, parsed.Parameters);
feature = aFeature;
return true;
}
catch (Exception e)
{
failException = e;
return false;
}
}
} }
} }

+ 12
- 0
IPA.Loader/Loader/Features/NoUpdateFeature.cs View File

@ -0,0 +1,12 @@
namespace IPA.Loader.Features
{
internal class NoUpdateFeature : Feature
{
public override bool Initialize(PluginLoader.PluginMetadata meta, string[] parameters)
{
return meta.Id != null;
}
public override string InvalidMessage { get; protected set; } = "No ID specified; cannot update anyway";
}
}

+ 14
- 0
IPA.Loader/Loader/Features/PrintFeature.cs View File

@ -0,0 +1,14 @@

using IPA.Logging;
namespace IPA.Loader.Features
{
internal class PrintFeature : Feature
{
public override bool Initialize(PluginLoader.PluginMetadata meta, string[] parameters)
{
Logger.features.Info($"{meta.Name}: {string.Join(" ", parameters)}");
return true;
}
}
}

+ 89
- 0
IPA.Loader/Loader/PluginInitInjector.cs View File

@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using IPA.Config;
using IPA.Logging;
using IPA.Utilities;
namespace IPA.Loader
{
/// <summary>
/// The type that handles value injecting into a plugin's Init.
/// </summary>
public static class PluginInitInjector
{
/// <summary>
/// A typed injector for a plugin's Init method. When registered, called for all associated types. If it returns null, the default for the type will be used.
/// </summary>
/// <param name="previous">the previous return value of the function, or <see langword="null"/> if never called for plugin.</param>
/// <param name="param">the <see cref="ParameterInfo"/> of the parameter being injected.</param>
/// <param name="meta">the <see cref="PluginLoader.PluginMetadata"/> for the plugin being loaded.</param>
/// <returns>the value to inject into that parameter.</returns>
public delegate object InjectParameter(object previous, ParameterInfo param, PluginLoader.PluginMetadata meta);
/// <summary>
/// Adds an injector to be used when calling future plugins' Init methods.
/// </summary>
/// <param name="type">the type of the parameter.</param>
/// <param name="injector">the function to call for injection.</param>
public static void AddInjector(Type type, InjectParameter injector)
{
injectors.Add(new Tuple<Type, InjectParameter>(type, injector));
}
private static readonly List<Tuple<Type, InjectParameter>> injectors = new List<Tuple<Type, InjectParameter>>
{
new Tuple<Type, InjectParameter>(typeof(Logger), (prev, param, meta) => prev ?? new StandardLogger(meta.Name)),
new Tuple<Type, InjectParameter>(typeof(IModPrefs), (prev, param, meta) => prev ?? new ModPrefs(meta)),
new Tuple<Type, InjectParameter>(typeof(IConfigProvider), (prev, param, meta) =>
{
if (prev != null) return prev;
var cfgProvider = Config.Config.GetProviderFor(meta.Name, param);
cfgProvider.Load();
return cfgProvider;
})
};
internal static void Inject(MethodInfo init, PluginLoader.PluginInfo info)
{
var instance = info.Plugin;
var meta = info.Metadata;
var initArgs = new List<object>();
var initParams = init.GetParameters();
Dictionary<Tuple<Type, InjectParameter>, object> previousValues =
new Dictionary<Tuple<Type, InjectParameter>, object>(injectors.Count);
foreach (var param in initParams)
{
var paramType = param.ParameterType;
var value = paramType.GetDefault();
foreach (var pair in injectors.Where(t => paramType.IsAssignableFrom(t.Item1)))
{
object prev = null;
if (previousValues.ContainsKey(pair))
prev = previousValues[pair];
var val = pair.Item2?.Invoke(prev, param, meta);
if (previousValues.ContainsKey(pair))
previousValues[pair] = val;
else
previousValues.Add(pair, val);
if (val == null) continue;
value = val;
break;
}
initArgs.Add(value);
}
init.Invoke(instance, initArgs.ToArray());
}
}
}

+ 82
- 39
IPA.Loader/Loader/PluginLoader.cs View File

@ -1,5 +1,4 @@
using IPA.Config;
using IPA.Loader.Features;
using IPA.Loader.Features;
using IPA.Logging; using IPA.Logging;
using IPA.Utilities; using IPA.Utilities;
using Mono.Cecil; using Mono.Cecil;
@ -24,6 +23,7 @@ namespace IPA.Loader
LoadMetadata(); LoadMetadata();
Resolve(); Resolve();
ComputeLoadOrder(); ComputeLoadOrder();
InitFeatures();
}); });
/// <summary> /// <summary>
@ -84,7 +84,7 @@ namespace IPA.Loader
} }
/// <inheritdoc /> /// <inheritdoc />
public override string ToString() => $"{Name}({Id}@{Version})({PluginType?.FullName}) from '{LoneFunctions.GetRelativePath(File?.FullName, BeatSaber.InstallPath)}'";
public override string ToString() => $"{Name}({Id}@{Version})({PluginType?.FullName}) from '{Utils.GetRelativePath(File?.FullName, BeatSaber.InstallPath)}'";
} }
/// <summary> /// <summary>
@ -296,6 +296,58 @@ namespace IPA.Loader
PluginsMetadata = metadata; PluginsMetadata = metadata;
} }
internal static void InitFeatures()
{
var parsedFeatures = PluginsMetadata.Select(m =>
Tuple.Create(m,
m.Manifest.Features.Select(f =>
Tuple.Create(f, Ref.Create<Feature.FeatureParse?>(null))
).ToList()
)
).ToList();
while (DefineFeature.NewFeature)
{
DefineFeature.NewFeature = false;
foreach (var plugin in parsedFeatures)
for (var i = 0; i < plugin.Item2.Count; i++)
{
var feature = plugin.Item2[i];
var success = Feature.TryParseFeature(feature.Item1, plugin.Item1, out var featureObj,
out var exception, out var valid, out var parsed, feature.Item2.Value);
if (!success && !valid && featureObj == null && exception == null) // no feature of type found
feature.Item2.Value = parsed;
else if (success)
{
if (valid)
plugin.Item1.InternalFeatures.Add(featureObj);
else
Logger.features.Warn(
$"Feature not valid on {plugin.Item1.Name}: {featureObj.InvalidMessage}");
plugin.Item2.RemoveAt(i--);
}
else
{
Logger.features.Error($"Error parsing feature definition on {plugin.Item1.Name}");
Logger.features.Error(exception);
plugin.Item2.RemoveAt(i--);
}
}
}
foreach (var plugin in parsedFeatures)
{
if (plugin.Item2.Count <= 0) continue;
Logger.features.Warn($"On plugin {plugin.Item1.Name}:");
foreach (var feature in plugin.Item2)
Logger.features.Warn($" Feature not found with name {feature.Item1}");
}
}
internal static void Load(PluginMetadata meta) internal static void Load(PluginMetadata meta)
{ {
if (meta.Assembly == null) if (meta.Assembly == null)
@ -317,52 +369,43 @@ namespace IPA.Loader
{ {
Load(meta); Load(meta);
Feature denyingFeature = null;
if (!meta.Features.All(f => (denyingFeature = f).BeforeLoad(meta)))
{
Logger.loader.Warn(
$"Feature {denyingFeature?.GetType()} denied plugin {meta.Name} from loading! {denyingFeature?.InvalidMessage}");
return null;
}
var type = meta.Assembly.GetType(meta.PluginType.FullName); var type = meta.Assembly.GetType(meta.PluginType.FullName);
var instance = (IBeatSaberPlugin)Activator.CreateInstance(type); var instance = (IBeatSaberPlugin)Activator.CreateInstance(type);
info.Metadata = meta; info.Metadata = meta;
info.Plugin = instance; info.Plugin = instance;
var init = type.GetMethod("Init", BindingFlags.Instance | BindingFlags.Public);
if (init != null)
{ {
var init = type.GetMethod("Init", BindingFlags.Instance | BindingFlags.Public);
if (init != null)
denyingFeature = null;
if (!meta.Features.All(f => (denyingFeature = f).BeforeInit(info)))
{ {
var initArgs = new List<object>();
var initParams = init.GetParameters();
Logger modLogger = null;
IModPrefs modPrefs = null;
IConfigProvider cfgProvider = null;
Logger.loader.Warn(
$"Feature {denyingFeature?.GetType()} denied plugin {meta.Name} from initializing! {denyingFeature?.InvalidMessage}");
return null;
}
foreach (var param in initParams)
{
var paramType = param.ParameterType;
if (paramType.IsAssignableFrom(typeof(Logger)))
{
if (modLogger == null) modLogger = new StandardLogger(meta.Name);
initArgs.Add(modLogger);
}
else if (paramType.IsAssignableFrom(typeof(IModPrefs)))
{
if (modPrefs == null) modPrefs = new ModPrefs(instance);
initArgs.Add(modPrefs);
}
else if (paramType.IsAssignableFrom(typeof(IConfigProvider)))
{
if (cfgProvider == null)
{
cfgProvider = Config.Config.GetProviderFor(Path.Combine("UserData", $"{meta.Name}"), param);
cfgProvider.Load();
}
initArgs.Add(cfgProvider);
}
else
initArgs.Add(paramType.GetDefault());
}
PluginInitInjector.Inject(init, info);
}
init.Invoke(instance, initArgs.ToArray());
foreach (var feature in meta.Features)
try
{
feature.AfterInit(info);
}
catch (Exception e)
{
Logger.loader.Critical($"Feature errored in {nameof(Feature.AfterInit)}: {e}");
} }
}
} }
catch (AmbiguousMatchException) catch (AmbiguousMatchException)
{ {
@ -379,5 +422,5 @@ namespace IPA.Loader
} }
internal static List<PluginInfo> LoadPlugins() => PluginsMetadata.Select(InitPlugin).Where(p => p != null).ToList(); internal static List<PluginInfo> LoadPlugins() => PluginsMetadata.Select(InitPlugin).Where(p => p != null).ToList();
}
}
} }

+ 3
- 1
IPA.Loader/Loader/manifest.json View File

@ -7,6 +7,8 @@
"name": "BSIPA", "name": "BSIPA",
"version": "3.12.0", "version": "3.12.0",
"features": [ "features": [
"early-load"
"define-feature(print, IPA.Loader.Features.PrintFeature)",
"define-feature(no-update, IPA.Loader.Features.NoUpdateFeature)",
"print(YO! Howz it goin\\, its ya boi desinc here)"
] ]
} }

+ 1
- 0
IPA.Loader/Logging/Logger.cs View File

@ -24,6 +24,7 @@ namespace IPA.Logging
internal static Logger updater => log.GetChildLogger("Updater"); internal static Logger updater => log.GetChildLogger("Updater");
internal static Logger libLoader => log.GetChildLogger("LibraryLoader"); internal static Logger libLoader => log.GetChildLogger("LibraryLoader");
internal static Logger loader => log.GetChildLogger("Loader"); internal static Logger loader => log.GetChildLogger("Loader");
internal static Logger features => loader.GetChildLogger("Features");
internal static Logger config => log.GetChildLogger("Config"); internal static Logger config => log.GetChildLogger("Config");
internal static bool LogCreated => _log != null || UnityLogProvider.Logger != null; internal static bool LogCreated => _log != null || UnityLogProvider.Logger != null;


+ 4
- 0
IPA.Loader/Utilities/BeatSaber.cs View File

@ -52,6 +52,10 @@ namespace IPA.Utilities
/// The directory to load plugins from. /// The directory to load plugins from.
/// </summary> /// </summary>
public static string PluginsPath => Path.Combine(InstallPath, "Plugins"); public static string PluginsPath => Path.Combine(InstallPath, "Plugins");
/// <summary>
/// The path to the `UserData` folder.
/// </summary>
public static string UserDataPath => Path.Combine(InstallPath, "UserData");
private static bool FindSteamVRAsset() private static bool FindSteamVRAsset()
{ {


BIN
Refs/UnityEngine.CoreModule.dll View File


Loading…
Cancel
Save