diff --git a/.editorconfig b/.editorconfig
index 243b28e..cd4e246 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -60,34 +60,33 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
-dotnet_naming_symbols.interface.required_modifiers =
+dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
-dotnet_naming_symbols.types.required_modifiers =
+dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
-dotnet_naming_symbols.non_field_members.required_modifiers =
+dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.begins_with_i.required_prefix = I
-dotnet_naming_style.begins_with_i.required_suffix =
-dotnet_naming_style.begins_with_i.word_separator =
+dotnet_naming_style.begins_with_i.required_suffix =
+dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
-dotnet_naming_style.pascal_case.required_prefix =
-dotnet_naming_style.pascal_case.required_suffix =
-dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
-dotnet_naming_style.pascal_case.required_prefix =
-dotnet_naming_style.pascal_case.required_suffix =
-dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_style_operator_placement_when_wrapping = beginning_of_line
-tab_width = 4
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
diff --git a/.run/Attach to Resonite.run.xml b/.run/Attach to Resonite.run.xml
new file mode 100644
index 0000000..7b51fad
--- /dev/null
+++ b/.run/Attach to Resonite.run.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ResoniteModLoader/AssemblyHider.cs b/ResoniteModLoader/AssemblyHider.cs
index 6f4b33e..68352dd 100644
--- a/ResoniteModLoader/AssemblyHider.cs
+++ b/ResoniteModLoader/AssemblyHider.cs
@@ -48,27 +48,25 @@ internal static class AssemblyHider {
/// Our RML harmony instance
/// Assemblies that were loaded when RML first started
internal static void PatchResonite(Harmony harmony, HashSet initialAssemblies) {
- //if (ModLoaderConfiguration.Get().HideModTypes) {
- // initialize the static assembly sets that our patches will need later
- resoniteAssemblies = GetResoniteAssemblies(initialAssemblies);
- modAssemblies = GetModAssemblies(resoniteAssemblies);
- dotNetAssemblies = resoniteAssemblies.Where(LooksLikeDotNetAssembly).ToHashSet();
-
- // TypeHelper.FindType explicitly does a type search
- MethodInfo findTypeTarget = AccessTools.DeclaredMethod(typeof(TypeHelper), nameof(TypeHelper.FindType), new Type[] { typeof(string) });
- MethodInfo findTypePatch = AccessTools.DeclaredMethod(typeof(AssemblyHider), nameof(FindTypePostfix));
- harmony.Patch(findTypeTarget, postfix: new HarmonyMethod(findTypePatch));
-
- // ReflectionExtensions.IsValidGenericType checks a type for validity, and if it returns `true` it reveals that the type exists
- MethodInfo isValidGenericTypeTarget = AccessTools.DeclaredMethod(typeof(ReflectionExtensions), nameof(ReflectionExtensions.IsValidGenericType), new Type[] { typeof(Type), typeof(bool) });
- MethodInfo isValidGenericTypePatch = AccessTools.DeclaredMethod(typeof(AssemblyHider), nameof(IsValidTypePostfix));
- harmony.Patch(isValidGenericTypeTarget, postfix: new HarmonyMethod(isValidGenericTypePatch));
-
- // FrooxEngine likes to enumerate all types in all assemblies, which is prone to issues (such as crashing FrooxCode if a type isn't loadable)
- MethodInfo getAssembliesTarget = AccessTools.DeclaredMethod(typeof(AppDomain), nameof(AppDomain.GetAssemblies), Array.Empty());
- MethodInfo getAssembliesPatch = AccessTools.DeclaredMethod(typeof(AssemblyHider), nameof(GetAssembliesPostfix));
- harmony.Patch(getAssembliesTarget, postfix: new HarmonyMethod(getAssembliesPatch));
- //}
+ // initialize the static assembly sets that our patches will need later
+ resoniteAssemblies = GetResoniteAssemblies(initialAssemblies);
+ modAssemblies = GetModAssemblies(resoniteAssemblies);
+ dotNetAssemblies = resoniteAssemblies.Where(LooksLikeDotNetAssembly).ToHashSet();
+
+ // TypeHelper.FindType explicitly does a type search
+ MethodInfo findTypeTarget = AccessTools.DeclaredMethod(typeof(TypeHelper), nameof(TypeHelper.FindType), new Type[] { typeof(string) });
+ MethodInfo findTypePatch = AccessTools.DeclaredMethod(typeof(AssemblyHider), nameof(FindTypePostfix));
+ harmony.Patch(findTypeTarget, postfix: new HarmonyMethod(findTypePatch));
+
+ // ReflectionExtensions.IsValidGenericType checks a type for validity, and if it returns `true` it reveals that the type exists
+ MethodInfo isValidGenericTypeTarget = AccessTools.DeclaredMethod(typeof(ReflectionExtensions), nameof(ReflectionExtensions.IsValidGenericType), new Type[] { typeof(Type), typeof(bool) });
+ MethodInfo isValidGenericTypePatch = AccessTools.DeclaredMethod(typeof(AssemblyHider), nameof(IsValidTypePostfix));
+ harmony.Patch(isValidGenericTypeTarget, postfix: new HarmonyMethod(isValidGenericTypePatch));
+
+ // FrooxEngine likes to enumerate all types in all assemblies, which is prone to issues (such as crashing FrooxCode if a type isn't loadable)
+ MethodInfo getAssembliesTarget = AccessTools.DeclaredMethod(typeof(AppDomain), nameof(AppDomain.GetAssemblies), Array.Empty());
+ MethodInfo getAssembliesPatch = AccessTools.DeclaredMethod(typeof(AssemblyHider), nameof(GetAssembliesPostfix));
+ harmony.Patch(getAssembliesTarget, postfix: new HarmonyMethod(getAssembliesPatch));
}
private static HashSet GetResoniteAssemblies(HashSet initialAssemblies) {
@@ -88,6 +86,9 @@ private static HashSet GetModAssemblies(HashSet resoniteAsse
// remove assemblies that we know to have come with Resonite
assemblies.ExceptWith(resoniteAssemblies);
+ // remove ourselves because we technically aren't a mod
+ assemblies.Remove(Assembly.GetExecutingAssembly());
+
// what's left are assemblies that magically appeared during the mod loading process. So mods and their dependencies.
return assemblies;
}
@@ -104,26 +105,24 @@ private static HashSet GetModAssemblies(HashSet resoniteAsse
private static bool IsModAssembly(Assembly assembly, string typeOrAssembly, string name, bool log, bool forceShowLate) {
if (resoniteAssemblies!.Contains(assembly)) {
return false; // The assembly belongs to Resonite and shouldn't be hidden
+ } else if (modAssemblies!.Contains(assembly)) {
+ // The assembly belongs to a mod and should be hidden
+ if (log) {
+ Logger.DebugFuncInternal(() => $"Hid {typeOrAssembly} \"{name}\" from Resonite");
+ }
+ return true;
+ } else if (assembly == Assembly.GetExecutingAssembly()) {
+ // we don't want the data feed components getting hidden
+ return false;
} else {
- if (modAssemblies!.Contains(assembly)) {
- // The assembly belongs to a mod and should be hidden
- if (log) {
- Logger.DebugFuncInternal(() => $"Hid {typeOrAssembly} \"{name}\" from Resonite");
- }
- return true;
- } else {
- // an assembly was in neither resoniteAssemblies nor modAssemblies
- // this implies someone late-loaded an assembly after RML, and it was later used in-game
- // this is super weird, and probably shouldn't ever happen... but if it does, I want to know about it.
- // since this is an edge case users may want to handle in different ways, the HideLateTypes rml config option allows them to choose.
- //bool hideLate = true;// ModLoaderConfiguration.Get().HideLateTypes;
- /*if (log) {
- Logger.WarnInternal($"The \"{name}\" {typeOrAssembly} does not appear to part of Resonite or a mod. It is unclear whether it should be hidden or not. Due to the HideLateTypes config option being {hideLate} it will be {(hideLate ? "Hidden" : "Shown")}");
- }*/
- // if forceShowLate == true, then this function will always return `false` for late-loaded types
- // if forceShowLate == false, then this function will return `true` when hideLate == true
- return !forceShowLate;
+ // an assembly was in neither resoniteAssemblies nor modAssemblies
+ // this implies someone late-loaded an assembly after RML, and it was later used in-game
+ // this is super weird, and probably shouldn't ever happen... but if it does, I want to know about it.
+ if (log) {
+ Logger.WarnInternal($"The \"{name}\" {typeOrAssembly} does not appear to part of Resonite or a mod. It is unclear whether it should be hidden or not. forceShowLate is {forceShowLate}, so it will be {(forceShowLate ? "Shown" : "Hidden")}");
}
+ // if forceShowLate == true, then this function will always return `false` for late-loaded types
+ return !forceShowLate;
}
}
diff --git a/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs
index bafd0eb..fd0e5ad 100644
--- a/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs
+++ b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs
@@ -4,4 +4,25 @@ namespace ResoniteModLoader;
/// deriving from to be automatically included in that mod's configuration.
///
[AttributeUsage(AttributeTargets.Field)]
-public sealed class AutoRegisterConfigKeyAttribute : Attribute { }
+public sealed class AutoRegisterConfigKeyAttribute : Attribute {
+
+ ///
+ /// Defines a group that this configuration key belongs to, used by default configuration feed builder.
+ ///
+ public string? Group => _group;
+
+ private readonly string? _group;
+
+ ///
+ /// Flag this field to be automatically registered as a configuration key for this mod that is not grouped with any other keys.
+ ///
+ public AutoRegisterConfigKeyAttribute() { }
+
+ ///
+ /// Flag this field to be automatically registered as a configuration key for this mod that is part of a group.
+ ///
+ /// The name of the group this configuration key belongs to.
+ public AutoRegisterConfigKeyAttribute(string group) {
+ _group = group;
+ }
+}
diff --git a/ResoniteModLoader/DashScreenInjector.cs b/ResoniteModLoader/DashScreenInjector.cs
new file mode 100644
index 0000000..22401e9
--- /dev/null
+++ b/ResoniteModLoader/DashScreenInjector.cs
@@ -0,0 +1,101 @@
+using Elements.Core;
+using FrooxEngine;
+using FrooxEngine.UIX;
+using HarmonyLib;
+using Stream = System.IO.Stream;
+
+namespace ResoniteModLoader;
+
+internal sealed class DashScreenInjector {
+ internal static RadiantDashScreen? InjectedScreen;
+
+ internal static void PatchScreenManager(Harmony harmony) {
+ MethodInfo setupDefaultMethod = AccessTools.DeclaredMethod(typeof(UserspaceScreensManager), "SetupDefaults");
+ MethodInfo onLoadingMethod = AccessTools.DeclaredMethod(typeof(UserspaceScreensManager), "OnLoading");
+ MethodInfo tryInjectScreenMethod = AccessTools.DeclaredMethod(typeof(DashScreenInjector), nameof(TryInjectScreen));
+ harmony.Patch(setupDefaultMethod, postfix: new HarmonyMethod(tryInjectScreenMethod));
+ harmony.Patch(onLoadingMethod, postfix: new HarmonyMethod(tryInjectScreenMethod));
+ Logger.DebugInternal("UserspaceScreensManager patched");
+ }
+
+ internal static async void TryInjectScreen(UserspaceScreensManager __instance) {
+ ModLoaderConfiguration config = ModLoaderConfiguration.Get();
+
+ if (config.NoDashScreen) {
+ Logger.DebugInternal("Dash screen will not be injected due to configuration file");
+ return;
+ }
+ if (__instance.World != Userspace.UserspaceWorld) {
+ Logger.WarnInternal("Dash screen will not be injected because we're somehow not in userspace (WTF?)"); // it stands for What the Froox :>
+ return;
+ }
+ if (InjectedScreen is not null && !InjectedScreen.IsRemoved) {
+ Logger.WarnInternal("Dash screen will not be injected again because it already exists");
+ return;
+ }
+
+ string? overrideTemplatePath = Directory.EnumerateFiles(ModLoaderConfiguration.GetAssemblyDirectory(), "DashScreenTemplate.*").FirstOrDefault();
+ Stream screenFileStream = File.Exists(overrideTemplatePath) ? File.OpenRead(overrideTemplatePath) : Assembly.GetExecutingAssembly().GetManifestResourceStream("Resources\\DashScreenTemplate.bson");
+
+ Logger.DebugInternal("Injecting dash screen");
+
+ RadiantDash dash = __instance.Slot.GetComponentInParents();
+ InjectedScreen = dash.AttachScreen("Mods", RadiantUI_Constants.Neutrals.LIGHT, OfficialAssets.Graphics.Icons.Dash.Tools);
+
+ InjectedScreen.Slot.OrderOffset = 128;
+ InjectedScreen.Slot.PersistentSelf = false;
+
+ try {
+ DataTreeDictionary screenDict = DataTreeConverter.LoadAuto(screenFileStream);
+ Slot DashScreenTemplate = InjectedScreen.ScreenRoot.AddSlot("DashScreenTemplate");
+ DashScreenTemplate.LoadObject(screenDict, null!);
+ Canvas DashScreenCanvas = DashScreenTemplate.GetComponentInChildren();
+ InjectedScreen.ScreenCanvas.Slot.AttachComponent().TargetCanvas.Target = DashScreenCanvas;
+ }
+ catch (ArgumentNullException e) {
+ Logger.WarnInternal(
+ "Failed to load DashScreenTemplate for dash screen, falling back to platform settings.");
+ Logger.DebugInternal($"Exception message: {e.Message}");
+
+ SingleFeedView view = InjectedScreen.ScreenRoot.AttachComponent();
+ ModConfigurationDataFeed feed = InjectedScreen.ScreenRoot.AttachComponent();
+
+ Slot templates = InjectedScreen.ScreenRoot.AddSlot("Template");
+ templates.ActiveSelf = false;
+
+ if (await templates.LoadObjectAsync(__instance.Cloud.Platform.GetSpawnObjectUri("Settings"), skipHolder: true)) {
+ // we do a little bit of thievery
+ RootCategoryView rootCategoryView = templates.GetComponentInChildren();
+ rootCategoryView.Slot.GetComponentInChildren().Path.Target = view.Path;
+ rootCategoryView.CategoryManager.ContainerRoot.Target.ActiveSelf = false;
+ rootCategoryView.Slot.Children.First().Parent = InjectedScreen.ScreenCanvas.Slot;
+ view.ItemsManager.TemplateMapper.Target = rootCategoryView.ItemsManager.TemplateMapper.Target;
+ view.ItemsManager.ContainerRoot.Target = rootCategoryView.ItemsManager.ContainerRoot.Target;
+ rootCategoryView.Destroy();
+ templates.GetComponentInChildren().NameConverter.Target = view.PathSegmentName;
+ }
+ else if (config.Debug) {
+ Logger.ErrorInternal("Failed to load SettingsItemMappers for dash screen, falling back to template.");
+ DataFeedItemMapper itemMapper = templates.AttachComponent();
+ Canvas tempCanvas = templates.AttachComponent(); // Needed for next method to work
+ itemMapper.SetupTemplate();
+ tempCanvas.Destroy();
+ view.ItemsManager.TemplateMapper.Target = itemMapper;
+ view.ItemsManager.ContainerRoot.Target = InjectedScreen.ScreenCanvas.Slot;
+ InjectedScreen.ScreenCanvas.Slot.AttachComponent(); // just for debugging
+ }
+ else {
+ Logger.ErrorInternal("Failed to load SettingsItemMappers for dash screen, aborting and cleaning up.");
+ InjectedScreen.Slot.Destroy();
+ return;
+ }
+
+ view.Feed.Target = feed;
+ view.SetCategoryPath(["ResoniteModLoader"]);
+ }
+
+ InjectedScreen.ScreenCanvas.Slot.AttachComponent().Tint.Value = UserspaceRadiantDash.DEFAULT_BACKGROUND;
+
+ Logger.DebugInternal("Dash screen should be injected!");
+ }
+}
diff --git a/ResoniteModLoader/DebugInfo.cs b/ResoniteModLoader/DebugInfo.cs
index db7ec8d..b86b90f 100644
--- a/ResoniteModLoader/DebugInfo.cs
+++ b/ResoniteModLoader/DebugInfo.cs
@@ -3,6 +3,8 @@
namespace ResoniteModLoader;
internal static class DebugInfo {
+ internal static TimeSpan InitializationTime;
+
internal static void Log() {
Logger.MsgInternal($"ResoniteModLoader v{ModLoader.VERSION} starting up!{(ModLoaderConfiguration.Get().Debug ? " Debug logs will be shown." : "")}");
Logger.DebugFuncInternal(() => $"Launched with args: {string.Join(" ", Environment.GetCommandLineArgs())}");
diff --git a/ResoniteModLoader/ExceptionHook.cs b/ResoniteModLoader/ExceptionHook.cs
new file mode 100644
index 0000000..094217c
--- /dev/null
+++ b/ResoniteModLoader/ExceptionHook.cs
@@ -0,0 +1,24 @@
+using FrooxEngine;
+using HarmonyLib;
+
+namespace ResoniteModLoader;
+
+internal sealed class ExceptionHook {
+ internal static void RegisterExceptionHook(Harmony harmony) {
+ MethodInfo? preprocessExceptionMethod = typeof(DebugManager).GetMethod(nameof(DebugManager.PreprocessException), BindingFlags.Static | BindingFlags.Public)?.MakeGenericMethod(typeof(Exception));
+ MethodInfo? exceptionHookMethod = typeof(ExceptionHook).GetMethod(nameof(InterceptException), BindingFlags.Static);
+
+ if (preprocessExceptionMethod == null || exceptionHookMethod == null) {
+ Logger.ErrorInternal("Could not find DebugManager.PreprocessException. Mod exceptions may not be logged.");
+ return;
+ }
+
+ harmony.Patch(preprocessExceptionMethod, prefix: new HarmonyMethod(exceptionHookMethod));
+ Logger.DebugInternal("DebugManager.PreprocessException patched");
+ }
+
+ internal static void InterceptException(Exception exception) {
+ Logger.ProcessException(exception);
+ }
+
+}
diff --git a/ResoniteModLoader/ExecutionHook.cs b/ResoniteModLoader/ExecutionHook.cs
index 3757107..44cb137 100644
--- a/ResoniteModLoader/ExecutionHook.cs
+++ b/ResoniteModLoader/ExecutionHook.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using FrooxEngine;
namespace ResoniteModLoader;
@@ -18,6 +19,8 @@ private static DummyConnector InstantiateConnector() {
static ExecutionHook() {
Logger.DebugInternal($"Start of ExecutionHook");
try {
+ Stopwatch timer = Stopwatch.StartNew();
+ Logger.RegisterExceptionHook();
BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic;
var byName = (Dictionary)typeof(GlobalTypeRegistry).GetField("_byName", flags).GetValue(null);
@@ -39,8 +42,12 @@ static ExecutionHook() {
LoadProgressIndicator.SetCustom("Initializing");
DebugInfo.Log();
HarmonyWorker.LoadModsAndHideModAssemblies(initialAssemblies);
+ timer.Stop();
LoadProgressIndicator.SetCustom("Loaded");
+ Logger.MsgInternal($"Initialization & mod loading completed in {timer.ElapsedMilliseconds}ms.");
+ DebugInfo.InitializationTime = timer.Elapsed;
} catch (Exception e) {
+ Logger.UnregisterExceptionHook();
// it's important that this doesn't send exceptions back to Resonite
Logger.ErrorInternal($"Exception in execution hook!\n{e}");
}
diff --git a/ResoniteModLoader/HarmonyWorker.cs b/ResoniteModLoader/HarmonyWorker.cs
index c19cacb..43df723 100644
--- a/ResoniteModLoader/HarmonyWorker.cs
+++ b/ResoniteModLoader/HarmonyWorker.cs
@@ -6,8 +6,10 @@ namespace ResoniteModLoader;
internal sealed class HarmonyWorker {
internal static void LoadModsAndHideModAssemblies(HashSet initialAssemblies) {
Harmony harmony = new("com.resonitemodloader.ResoniteModLoader");
+ // ExceptionHook.RegisterExceptionHook(harmony);
ModLoader.LoadMods();
ModConfiguration.RegisterShutdownHook(harmony);
AssemblyHider.PatchResonite(harmony, initialAssemblies);
+ DashScreenInjector.PatchScreenManager(harmony);
}
}
diff --git a/ResoniteModLoader/Logger.cs b/ResoniteModLoader/Logger.cs
index 051c7c2..f541658 100644
--- a/ResoniteModLoader/Logger.cs
+++ b/ResoniteModLoader/Logger.cs
@@ -1,85 +1,296 @@
+using System.Collections;
using System.Diagnostics;
-
+using System.Runtime.ExceptionServices;
using Elements.Core;
namespace ResoniteModLoader;
-internal sealed class Logger {
+///
+/// General class that manages all RML-related log/exception processing.
+/// Use the inherited methods from the class instead of UniLog or calling these methods directly!
+/// Generally, you will only use this class to read RML/other mod logs, or directly pass exceptions to RML with .
+///
+public sealed class Logger {
+
+ ///
+ /// Represents the severity level of a log message.
+ ///
+ public enum LogLevel { TRACE, DEBUG, INFO, WARN, ERROR }
+
+ ///
+ /// Represents a single log entry.
+ ///
+ public class MessageItem {
+ internal MessageItem(ResoniteModBase? mod, LogLevel level, string message, StackTrace? trace = null) {
+ Time = DateTime.Now;
+ Mod = mod;
+ Level = level;
+ Message = message;
+ Trace = trace;
+ }
+
+ public DateTime Time { get; }
+
+ ///
+ /// The mod that created this log entry, or RML if null.
+ ///
+ public ResoniteModBase? Mod { get; }
+ public LogLevel Level { get; }
+ public string Message { get; }
+
+ ///
+ /// A stack trace relating to the log entry, if recorded.
+ ///
+ public StackTrace? Trace { get; }
+
+ ///
+ public override string ToString() => $"({Mod?.Name ?? "ResoniteModLoader"} @ {Time}) {LogTypeTag(Level)} {Message}";
+ }
+
+ ///
+ /// Represents an exception that was caught or passed for logging.
+ ///
+ public class ExceptionItem {
+ internal ExceptionItem(Exception exception) {
+ Time = DateTime.Now;
+ Exception = exception;
+ }
+
+ internal ExceptionItem(Exception exception, Assembly? assembly) {
+ Time = DateTime.Now;
+ Exception = exception;
+ Source = (assembly, null);
+ }
+
+ internal ExceptionItem(Exception exception, ResoniteModBase? mod) {
+ Time = DateTime.Now;
+ Exception = exception;
+ Source = (mod?.ModAssembly?.Assembly, mod);
+ }
+
+ internal ExceptionItem(Exception exception, Assembly? assembly, ResoniteModBase? mod) {
+ Time = DateTime.Now;
+ Exception = exception;
+ Source = (assembly, mod);
+ }
+
+ public DateTime Time { get; }
+ public Exception Exception { get; }
+
+ ///
+ /// The (possible) source of the exception. Note the assembly and mod may be unrelated if both set!
+ ///
+ public (Assembly? Assembly, ResoniteModBase? Mod)? Source { get; }
+
+ ///
+ public override string ToString() => $"({Time}) [{Source?.Assembly?.FullName} ?? Unknown assembly] {Exception.Message}\n{Exception.StackTrace}";
+ }
+
// logged for null objects
internal const string NULL_STRING = "null";
+ private static List _logBuffer = new();
+
+ ///
+ /// Stores all logs posted by mods and RML itself.
+ ///
+ public static IReadOnlyList Logs => _logBuffer.AsReadOnly();
+
+ private static List _exceptionBuffer = new();
+
+ ///
+ /// Stores all exceptions caught by RML or passed by mods for logging.
+ ///
+ public static IReadOnlyList Exceptions => _exceptionBuffer.AsReadOnly();
+
+ public delegate void MessageHandler(MessageItem message);
+
+ ///
+ /// Fired whenever a message is logged.
+ ///
+ public static event MessageHandler? OnMessagePosted;
+
+ public delegate void ExceptionHandler(ExceptionItem exception);
+
+ ///
+ /// Fired whenever an exception is caught by RML or passed by a mod.
+ ///
+ public static event ExceptionHandler? OnExceptionPosted;
+
internal static bool IsDebugEnabled() {
return ModLoaderConfiguration.Get().Debug;
}
+ internal static void TraceFuncInternal(Func messageProducer) {
+ if (IsDebugEnabled()) {
+ LogInternal(LogLevel.TRACE, messageProducer(), null, true);
+ }
+ }
+
+ internal static void TraceFuncExternal(Func messageProducer) {
+ if (IsDebugEnabled()) {
+ LogInternal(LogLevel.TRACE, messageProducer(), new(1), true);
+ }
+ }
+
+ internal static void TraceInternal(string message) {
+ if (IsDebugEnabled()) {
+ LogInternal(LogLevel.TRACE, message, null, true);
+ }
+ }
+
+ internal static void TraceExternal(object message) {
+ if (IsDebugEnabled()) {
+ LogInternal(LogLevel.TRACE, message, new(1), true);
+ }
+ }
+
+ internal static void TraceListExternal(object[] messages) {
+ if (IsDebugEnabled()) {
+ LogListInternal(LogLevel.TRACE, messages, new(1), true);
+ }
+ }
+
internal static void DebugFuncInternal(Func messageProducer) {
if (IsDebugEnabled()) {
- LogInternal(LogType.DEBUG, messageProducer());
+ LogInternal(LogLevel.DEBUG, messageProducer());
}
}
internal static void DebugFuncExternal(Func messageProducer) {
if (IsDebugEnabled()) {
- LogInternal(LogType.DEBUG, messageProducer(), SourceFromStackTrace(new(1)));
+ LogInternal(LogLevel.DEBUG, messageProducer(), new(1));
}
}
internal static void DebugInternal(string message) {
if (IsDebugEnabled()) {
- LogInternal(LogType.DEBUG, message);
+ LogInternal(LogLevel.DEBUG, message);
}
}
internal static void DebugExternal(object message) {
if (IsDebugEnabled()) {
- LogInternal(LogType.DEBUG, message, SourceFromStackTrace(new(1)));
+ LogInternal(LogLevel.DEBUG, message, new(1));
}
}
internal static void DebugListExternal(object[] messages) {
if (IsDebugEnabled()) {
- LogListInternal(LogType.DEBUG, messages, SourceFromStackTrace(new(1)));
+ LogListInternal(LogLevel.DEBUG, messages, new(1));
}
}
- internal static void MsgInternal(string message) => LogInternal(LogType.INFO, message);
- internal static void MsgExternal(object message) => LogInternal(LogType.INFO, message, SourceFromStackTrace(new(1)));
- internal static void MsgListExternal(object[] messages) => LogListInternal(LogType.INFO, messages, SourceFromStackTrace(new(1)));
- internal static void WarnInternal(string message) => LogInternal(LogType.WARN, message);
- internal static void WarnExternal(object message) => LogInternal(LogType.WARN, message, SourceFromStackTrace(new(1)));
- internal static void WarnListExternal(object[] messages) => LogListInternal(LogType.WARN, messages, SourceFromStackTrace(new(1)));
- internal static void ErrorInternal(string message) => LogInternal(LogType.ERROR, message);
- internal static void ErrorExternal(object message) => LogInternal(LogType.ERROR, message, SourceFromStackTrace(new(1)));
- internal static void ErrorListExternal(object[] messages) => LogListInternal(LogType.ERROR, messages, SourceFromStackTrace(new(1)));
+ internal static void MsgInternal(string message) => LogInternal(LogLevel.INFO, message);
+ internal static void MsgExternal(object message) => LogInternal(LogLevel.INFO, message, new(1));
+ internal static void MsgListExternal(object[] messages) => LogListInternal(LogLevel.INFO, messages, new(1));
+ internal static void WarnInternal(string message) => LogInternal(LogLevel.WARN, message);
+ internal static void WarnExternal(object message) => LogInternal(LogLevel.WARN, message, new(1));
+ internal static void WarnListExternal(object[] messages) => LogListInternal(LogLevel.WARN, messages, new(1));
+ internal static void ErrorInternal(string message) => LogInternal(LogLevel.ERROR, message);
+ internal static void ErrorExternal(object message) => LogInternal(LogLevel.ERROR, message, new(1));
+ internal static void ErrorListExternal(object[] messages) => LogListInternal(LogLevel.ERROR, messages, new(1));
- private static void LogInternal(string logTypePrefix, object message, string? source = null) {
+ private static void LogInternal(LogLevel logType, object message, StackTrace? stackTrace = null, bool includeTrace = false) {
message ??= NULL_STRING;
+ stackTrace ??= new(1);
+ ResoniteMod? source = Util.ExecutingMod(stackTrace);
+ string logTypePrefix = LogTypeTag(logType);
+ MessageItem item = new(source, logType, message.ToString(), stackTrace);
+ _logBuffer.Add(item);
+ OnMessagePosted?.SafeInvoke(item);
if (source == null) {
- UniLog.Log($"{logTypePrefix}[ResoniteModLoader] {message}");
- } else {
- UniLog.Log($"{logTypePrefix}[ResoniteModLoader/{source}] {message}");
+ UniLog.Log($"{logTypePrefix}[ResoniteModLoader] {message}", includeTrace);
+ }
+ else {
+ UniLog.Log($"{logTypePrefix}[ResoniteModLoader/{source.Name}] {message}", includeTrace);
}
}
- private static void LogListInternal(string logTypePrefix, object[] messages, string? source) {
+ private static void LogListInternal(LogLevel logType, object[] messages, StackTrace? stackTrace, bool includeTrace = false) {
if (messages == null) {
- LogInternal(logTypePrefix, NULL_STRING, source);
- } else {
+ LogInternal(logType, NULL_STRING, stackTrace, includeTrace);
+ }
+ else {
foreach (object element in messages) {
- LogInternal(logTypePrefix, element.ToString(), source);
+ LogInternal(logType, element.ToString(), stackTrace, includeTrace);
}
}
}
- private static string? SourceFromStackTrace(StackTrace stackTrace) {
- // MsgExternal() and Msg() are above us in the stack
- return Util.ExecutingMod(stackTrace)?.Name;
+ ///
+ /// Use to pass a caught exception to RML for logging purposes.
+ /// Note that calling this will not automatically produce an error message, unless debug is enabled in RML's config.
+ ///
+ /// The exception to be recorded
+ /// The assembly responsible for causing the exception, if known
+ /// The mod where the exception occurred, if known
+ public static void ProcessException(Exception exception, Assembly? assembly = null, ResoniteModBase? mod = null) {
+ ExceptionItem item = new(exception, assembly, mod);
+ _exceptionBuffer.Add(item);
+ OnExceptionPosted?.SafeInvoke(item);
+ if (IsDebugEnabled()) {
+ string? attribution = null;
+ attribution ??= mod?.Name;
+ attribution ??= assembly?.FullName;
+ attribution ??= "unknown mod/assembly";
+ LogInternal(LogLevel.ERROR, $"DEBUG EXCEPTION [{attribution}]: {exception.Message}", new StackTrace(exception), true);
+ }
+ }
+
+ private static string LogTypeTag(LogLevel logType) => $"[{Enum.GetName(typeof(LogLevel), logType)}]";
+
+ internal static void RegisterExceptionHook() {
+ // AppDomain.CurrentDomain.UnhandledException += UnhandledExceptionProcessor;
+ AppDomain.CurrentDomain.FirstChanceException += FirstChanceExceptionProcessor;
+ DebugInternal("Unhandled exception hook registered");
+ }
+
+ internal static void UnregisterExceptionHook() {
+ // AppDomain.CurrentDomain.UnhandledException -= UnhandledExceptionProcessor;
+ AppDomain.CurrentDomain.FirstChanceException -= FirstChanceExceptionProcessor;
+ DebugInternal("Unhandled exception hook unregistered");
}
- private static class LogType {
- internal const string DEBUG = "[DEBUG]";
- internal const string INFO = "[INFO] ";
- internal const string WARN = "[WARN] ";
- internal const string ERROR = "[ERROR]";
+ private static void UnhandledExceptionProcessor(object sender, UnhandledExceptionEventArgs args) {
+ Exception exception = (Exception)args.ExceptionObject;
+ StackTrace trace = new StackTrace(exception);
+ ResoniteModBase? mod = Util.ExecutingMod(trace);
+ Assembly assembly = Assembly.GetAssembly(sender.GetType());
+ // this should handle most uncaught cases in RML and mods
+ if (mod is not null || assembly == Assembly.GetExecutingAssembly()) {
+ if (IsDebugEnabled()) ErrorInternal($"Caught unhandled exception, {exception.Message}. Attributed to {mod?.Name ?? "No mod"} / {assembly.FullName}");
+ ProcessException(exception, assembly, mod);
+ }
+
+ } private static void FirstChanceExceptionProcessor(object sender, FirstChanceExceptionEventArgs args) {
+ Exception exception = args.Exception;
+ StackTrace trace = new StackTrace(exception);
+ ResoniteModBase? mod = Util.ExecutingMod(trace);
+ Assembly assembly = Assembly.GetAssembly(sender.GetType());
+ // this should handle most uncaught cases in RML and mods
+ if (mod is not null || assembly == Assembly.GetExecutingAssembly()) {
+ if (IsDebugEnabled()) ErrorInternal($"Caught mod-related exception, {exception.Message}. Attributed to {mod?.Name ?? "No mod"} / {assembly.FullName}");
+ ProcessException(exception, assembly, mod);
+ }
}
}
+
+///
+/// Extension methods to filter logs/exceptions from a single mod.
+///
+public static class LoggerExtensions {
+ ///
+ /// Gets messages that were logged by this mod.
+ ///
+ /// The mod to filter messages from
+ /// Any messages logged by this mod.
+ public static IEnumerable Logs(this ResoniteModBase mod) => Logger.Logs.Where((line) => line.Mod == mod);
+
+ ///
+ /// Gets exceptions that are related to this mod.
+ ///
+ /// The mod to filter exceptions on
+ /// Any exceptions related to this mod.
+ public static IEnumerable Exceptions(this ResoniteModBase mod) => Logger.Exceptions.Where((line) => line.Source?.Mod == mod);
+}
diff --git a/ResoniteModLoader/ModConfiguration.cs b/ResoniteModLoader/ModConfiguration.cs
index 3f8442a..8ae4066 100644
--- a/ResoniteModLoader/ModConfiguration.cs
+++ b/ResoniteModLoader/ModConfiguration.cs
@@ -134,6 +134,9 @@ public class ModConfiguration : IModConfigurationDefinition {
// The naughty list is global, while the actual debouncing is per-configuration.
private static HashSet naughtySavers = new HashSet();
+ // maps configs by their filename sans extensions
+ internal static Dictionary configNameMap = new Dictionary();
+
// used to keep track of the debouncers for this configuration.
private Dictionary> saveActionForCallee = new();
@@ -160,6 +163,8 @@ private static JsonSerializer CreateJsonSerializer() {
private ModConfiguration(ModConfigurationDefinition definition) {
Definition = definition;
+ configNameMap[Path.GetFileNameWithoutExtension(Owner.ModAssembly!.File)] = this;
+ // thank goodness ModAssembly is set literally right before this is created
}
internal static void EnsureDirectoryExists() {
diff --git a/ResoniteModLoader/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs
new file mode 100644
index 0000000..a5f5af3
--- /dev/null
+++ b/ResoniteModLoader/ModConfigurationDataFeed.cs
@@ -0,0 +1,299 @@
+using Elements.Core;
+using FrooxEngine;
+
+namespace ResoniteModLoader;
+
+///
+/// A custom data feed that can be used to show information about loaded mods, and alter their configuration. Path must start with "ResoniteModLoader"
+///
+[Category(["ResoniteModLoader"])]
+public class ModConfigurationDataFeed : Component, IDataFeedComponent, IDataFeed, IWorldElement {
+#pragma warning disable CS1591
+#if !DEBUG
+ public override bool UserspaceOnly => true;
+#endif
+
+ public bool SupportsBackgroundQuerying => true;
+#pragma warning restore CS1591
+#pragma warning disable CS8618, CA1051 // FrooxEngine weaver will take care of these
+ ///
+ /// Show mod configuration keys marked as internal.
+ ///
+ public readonly Sync IncludeInternalConfigItems;
+
+ ///
+ /// Enable or disable the use of custom configuration feeds.
+ ///
+ public readonly Sync IgnoreModDefinedEnumerate;
+#pragma warning restore CS8618, CA1051
+#pragma warning disable CS1591
+ public async IAsyncEnumerable Enumerate(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData) {
+ switch (path.Count) {
+ case 0: {
+ yield return FeedBuilder.Category("ResoniteModLoader", "ResoniteModLoader (Home page)");
+ foreach (ResoniteModBase mod in ModLoader.Mods())
+ yield return FeedBuilder.Category(KeyFromMod(mod), mod.Name, ["ResoniteModLoader", KeyFromMod(mod)]);
+ }
+ yield break;
+
+ case 1: {
+ if (path[0] != "ResoniteModLoader") yield break;
+
+ if (string.IsNullOrEmpty(searchPhrase)) {
+ yield return FeedBuilder.Group("ResoniteModLoader.InfoGroup", "RML", [
+ FeedBuilder.Label("ResoniteModLoader.Version", $"ResoniteModLoader version {ModLoader.VERSION}"),
+ #if DEBUG
+ FeedBuilder.Label("ResoniteModLoader.DebugBuild", "/!\\ RUNNING DEBUG BUILD CONFIGURATION /!\\", RadiantUI_Constants.Hero.ORANGE),
+ #endif
+ FeedBuilder.StringIndicator("ResoniteModLoader.LoadedModCount", "Loaded mods", ModLoader.Mods().Count()),
+ FeedBuilder.StringIndicator("ResoniteModLoader.InitializationTime", "Startup time", DebugInfo.InitializationTime.Milliseconds + "ms")
+ ]);
+
+ List modCategories = new();
+ foreach (ResoniteModBase mod in ModLoader.Mods())
+ modCategories.Add(FeedBuilder.Category(KeyFromMod(mod), mod.Name));
+
+ yield return FeedBuilder.Grid("ResoniteModLoader.ModsGroup", "Mods", modCategories);
+ }
+ else {
+ List filteredMods = ModLoader.Mods().Where((mod) => mod.Name.IndexOf(searchPhrase, StringComparison.InvariantCultureIgnoreCase) >= 0).ToList();
+ yield return FeedBuilder.Label("ResoniteModLoader.SearchResults", filteredMods.Any() ? $"Search results: {filteredMods.Count} mods found." : "No results!");
+
+ foreach (ResoniteModBase mod in filteredMods)
+ yield return mod.GenerateModInfoGroup(false);
+ }
+ }
+ yield break;
+
+ case 2: {
+ if (path[0] != "ResoniteModLoader" || !TryModFromKey(path[1], out var mod)) yield break;
+
+ string key = KeyFromMod(mod);
+ yield return mod.GenerateModInfoGroup(true);
+
+ List modLogs = mod.Logs().ToList();
+ if (modLogs.Any()) {
+ IReadOnlyList latestLogs = mod.GenerateModLogFeed(5, false).Append(FeedBuilder.Category("Logs", $"View full log ({modLogs.Count})")).ToList().AsReadOnly();
+ yield return FeedBuilder.Group(key + ".Logs", "Recent mod logs", latestLogs);
+ }
+
+ List modExceptions = mod.Exceptions().ToList();
+ if (modExceptions.Any()) {
+ IReadOnlyList latestException = mod.GenerateModExceptionFeed(1, false).Append(FeedBuilder.Category("Exceptions", $"View all exceptions ({modExceptions.Count})")).ToList().AsReadOnly();
+ yield return FeedBuilder.Group(key + ".Exceptions", "Latest mod exception", latestException);
+ }
+ }
+ yield break;
+
+ case 3: {
+ if (path[0] != "ResoniteModLoader" || !TryModFromKey(path[1], out var mod)) yield break;
+
+ switch (path[2].ToLower()) {
+ case "configuration": {
+ foreach (DataFeedItem item in mod.GenerateModConfigurationFeed(path.Skip(3).ToArray(), groupKeys, searchPhrase, viewData, IncludeInternalConfigItems.Value, IgnoreModDefinedEnumerate.Value))
+ yield return item;
+ }
+ yield break;
+
+ case "logs": {
+ foreach (DataFeedItem item in mod.GenerateModLogFeed(int.MaxValue, true, searchPhrase))
+ yield return item;
+ }
+ yield break;
+
+ case "exceptions": {
+ foreach (DataFeedItem item in mod.GenerateModExceptionFeed(int.MaxValue, true, searchPhrase))
+ yield return item;
+ }
+ yield break;
+
+ default: {
+ // Reserved for future use - mods defining their own subfeeds
+ }
+ yield break;
+ }
+ }
+ case > 3: {
+ if (path[0] != "ResoniteModLoader" || !TryModFromKey(path[1], out var mod)) yield break;
+
+ if (path[2].ToLower() == "configuration") {
+ foreach (DataFeedItem item in mod.GenerateModConfigurationFeed(path.Skip(3).ToArray(), groupKeys, searchPhrase, viewData, IncludeInternalConfigItems.Value, IgnoreModDefinedEnumerate.Value))
+ yield return item;
+
+ yield break;
+ }
+ else {
+ // Reserved for future use - mods defining their own subfeeds
+ }
+ }
+ yield break;
+ }
+ }
+
+ public void ListenToUpdates(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, DataFeedUpdateHandler handler, object viewData) {
+ Logger.DebugInternal($"ModConfigurationDataFeed.ListenToUpdates called, handler: {handler}\n{Environment.StackTrace}");
+ }
+
+ public LocaleString PathSegmentName(string segment, int depth) {
+ return depth switch {
+ 2 => ModFromKey(segment)?.Name ?? "INVALID",
+ 3 => segment.Capitalize(),
+ _ => segment
+ };
+ }
+
+ public object RegisterViewData() {
+ Logger.DebugInternal($"ModConfigurationDataFeed.RegisterViewData called\n{Environment.StackTrace}");
+ return this;
+ }
+
+ public void UnregisterListener(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, DataFeedUpdateHandler handler) {
+ Logger.DebugInternal($"ModConfigurationDataFeed.UnregisterListener called, handler: {handler}\n{Environment.StackTrace}");
+ }
+
+ public void UnregisterViewData(object data) {
+ Logger.DebugInternal($"ModConfigurationDataFeed.UnregisterViewData called, object: {data}\n{Environment.StackTrace}");
+ }
+#pragma warning restore CS1591
+ ///
+ /// Returns a unique key that can be used to reference this mod.
+ ///
+ /// The mod to return a key from.
+ /// A unique key representing the mod.
+ ///
+ ///
+ internal static string KeyFromMod(ResoniteModBase mod) => Path.GetFileNameWithoutExtension(mod.ModAssembly!.File);
+
+ ///
+ /// Returns the mod that corresponds to a unique key.
+ ///
+ /// A unique key from .
+ /// The mod that corresponds with the unique key, or null if one couldn't be found.
+ ///
+ internal static ResoniteModBase? ModFromKey(string key) => ModLoader.Mods().First((mod) => KeyFromMod(mod) == key);
+
+ ///
+ /// Tries to get the mod that corresponds to a unique key.
+ ///
+ /// A unique key from .
+ /// Set if a matching mod is found.
+ /// true if a matching mod is found, false otherwise.
+ internal static bool TryModFromKey(string key, out ResoniteModBase mod) {
+ mod = ModFromKey(key)!;
+ return mod is not null;
+ }
+}
+
+internal static class ModConfigurationDataFeedExtensions {
+ ///
+ /// Generates a DataFeedGroup that displays basic information about a mod.
+ ///
+ /// The target mod
+ /// Set to true if this group will be displayed on its own page
+ /// A group containing indicators for the mod's info, as well as categories to view its config/logs/exceptions.
+ internal static DataFeedGroup GenerateModInfoGroup(this ResoniteModBase mod, bool standalone) {
+ List groupChildren = new();
+ string key = ModConfigurationDataFeed.KeyFromMod(mod);
+
+ if (standalone)
+ groupChildren.Add(FeedBuilder.Indicator(key + ".Name", "Name", mod.Name));
+ groupChildren.Add(FeedBuilder.Indicator(key + ".Author", "Author", mod.Author));
+ groupChildren.Add(FeedBuilder.Indicator(key + ".Version", "Version", mod.Version));
+
+ if (standalone) {
+ groupChildren.Add(FeedBuilder.StringIndicator(key + ".InitializationTime", "Startup impact", mod.InitializationTime.Milliseconds + "ms"));
+ groupChildren.Add(FeedBuilder.Indicator(key + ".AssemblyFile", "Assembly file", Path.GetFileName(mod.ModAssembly!.File)));
+ if (Logger.IsDebugEnabled())
+ groupChildren.Add(FeedBuilder.Indicator(key + ".AssemblyHash", "Assembly hash", mod.ModAssembly!.Sha256));
+ }
+
+ if (Uri.TryCreate(mod.Link, UriKind.Absolute, out var uri))
+ groupChildren.Add(FeedBuilder.ValueAction(key + ".OpenLinkAction", $"Open mod link ({uri.Host})", (action) => action.Target = OpenURI, uri));
+ if (mod.GetConfiguration() is not null)
+ groupChildren.Add(FeedBuilder.Category(key + ".ConfigurationCategory", "Mod configuration", standalone ? ["Configuration"] : [key, "Configuration"]));
+
+ if (!standalone) {
+ List modLogs = mod.Logs().ToList();
+ List modExceptions = mod.Exceptions().ToList();
+ if (modLogs.Any())
+ groupChildren.Add(FeedBuilder.Category(key + ".LogsCategory", $"Mod logs ({modLogs.Count})", [key, "Logs"]));
+ if (modExceptions.Any())
+ groupChildren.Add(FeedBuilder.Category(key + ".ExceptionsCategory", $"Mod exceptions ({modExceptions.Count})", [key, "Exceptions"]));
+ }
+
+ return FeedBuilder.Group(key + ".Group", standalone ? "Mod info" : mod.Name, groupChildren);
+ }
+
+ internal static IEnumerable GenerateModConfigurationFeed(this ResoniteModBase mod, IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false, bool forceDefaultBuilder = false) {
+ if (path.Any() && path[0] == "ResoniteModLoader")
+ Logger.WarnInternal("Call to GenerateModConfigurationFeed may include full DataFeed path, if so expect broken behavior.");
+
+ if (!mod.TryGetConfiguration(out ModConfiguration config) || !config.ConfigurationItemDefinitions.Any()) {
+ yield return FeedBuilder.Label("NoConfig", "This mod does not define any configuration keys.", color.Red);
+ yield break;
+ }
+
+ List items = new();
+ bool failed = false;
+
+ if (!forceDefaultBuilder) {
+ try {
+ items = mod.BuildConfigurationFeed(path, groupKeys, searchPhrase, viewData, includeInternal).ToList();
+ }
+ catch (Exception ex) {
+ failed = true;
+ Logger.ProcessException(ex, mod.ModAssembly!.Assembly);
+ Logger.ErrorInternal($"Exception was thrown while running {mod.Name}'s BuildConfigurationFeed method");
+ }
+ }
+
+ if (failed || !items.Any()) {
+ ModConfigurationFeedBuilder.CachedBuilders.TryGetValue(config, out var builder);
+ builder ??= new ModConfigurationFeedBuilder(config);
+ items = builder.RootPage(searchPhrase, includeInternal).ToList();
+ }
+
+ Logger.DebugInternal($"GenerateModConfigurationFeed output for {mod.Name} @ {string.Join("/", path)}");
+ foreach (DataFeedItem item in items) {
+ Logger.DebugInternal($"\t{item.GetType().Name} : {item.ItemKey}");
+ yield return item;
+ }
+ }
+
+ private static DataFeedItem AsFeedItem(this string text, bool copyable = true, string? key = null) {
+ key ??= Guid.NewGuid().ToString();
+ if (copyable)
+ return FeedBuilder.ValueAction(key, text, (action) => action.Target = CopyText, text);
+ else
+ return FeedBuilder.Label(key, text);
+ }
+
+ internal static IEnumerable GenerateModLogFeed(this ResoniteModBase mod, int last = int.MaxValue, bool copyable = true, string? filter = null) {
+ List modLogs = mod.Logs().Reverse().Take(last).ToList();
+ if (!string.IsNullOrEmpty(filter))
+ modLogs = modLogs.Where((line) => line.Message.IndexOf(filter, StringComparison.InvariantCultureIgnoreCase) >= 0).ToList();
+ foreach (Logger.MessageItem line in modLogs)
+ yield return line.ToString().AsFeedItem(copyable);
+ }
+
+ internal static IEnumerable GenerateModExceptionFeed(this ResoniteModBase mod, int last = int.MaxValue, bool copyable = true, string? filter = null) {
+ List modExceptions = mod.Exceptions().Reverse().Take(last).ToList();
+ if (!string.IsNullOrEmpty(filter))
+ modExceptions = modExceptions.Where((line) => line.Exception.ToString().IndexOf(filter, StringComparison.InvariantCultureIgnoreCase) >= 0).ToList();
+ foreach (Logger.ExceptionItem line in modExceptions)
+ yield return line.ToString().AsFeedItem(copyable);
+ }
+
+ [SyncMethod(typeof(Action), [])]
+ private static void OpenURI(Uri uri) {
+ Slot slot = Userspace.UserspaceWorld.AddSlot("Hyperlink");
+ slot.PositionInFrontOfUser(float3.Backward);
+ slot.AttachComponent().Setup(uri, "Outgoing hyperlink");
+ }
+
+ [SyncMethod(typeof(Action), [])]
+ private static void CopyText(string text) {
+ Userspace.UserspaceWorld.InputInterface.Clipboard.SetText(text);
+ NotificationMessage.SpawnTextMessage("Copied line", colorX.White);
+ }
+}
diff --git a/ResoniteModLoader/ModConfigurationFeedBuilder.cs b/ResoniteModLoader/ModConfigurationFeedBuilder.cs
new file mode 100644
index 0000000..6044855
--- /dev/null
+++ b/ResoniteModLoader/ModConfigurationFeedBuilder.cs
@@ -0,0 +1,303 @@
+using Elements.Core;
+using FrooxEngine;
+using HarmonyLib;
+using System.Collections;
+
+namespace ResoniteModLoader;
+
+///
+/// A utility class that aids in the creation of mod configuration feeds.
+///
+public class ModConfigurationFeedBuilder {
+ ///
+ /// A cache of , indexed by the they belong to.
+ /// New builders are automatically added to this cache upon instantiation, so you should try to get a cached builder before creating a new one.
+ ///
+ ///
+ ///
+ /// ModConfigurationFeedBuilder.CachedBuilders.TryGetValue(config, out var builder);
+ /// builder ??= new ModConfigurationFeedBuilder(config);
+ ///
+ ///
+ public readonly static Dictionary CachedBuilders = new();
+
+ private readonly ModConfiguration Config;
+
+ private readonly Dictionary KeyFields = new();
+
+ private readonly Dictionary> KeyGrouping = new();
+
+ private static bool HasAutoRegisterAttribute(FieldInfo field) => field.GetCustomAttribute() is not null;
+
+ private static bool TryGetAutoRegisterAttribute(FieldInfo field, out AutoRegisterConfigKeyAttribute attribute) {
+ attribute = field.GetCustomAttribute();
+ return attribute is not null;
+ }
+
+ private static bool HasRangeAttribute(FieldInfo field) => field.GetCustomAttribute() is not null;
+
+ private static bool TryGetRangeAttribute(FieldInfo field, out RangeAttribute attribute) {
+ attribute = field.GetCustomAttribute();
+ return attribute is not null;
+ }
+
+ private void AssertChildKey(ModConfigurationKey key) {
+ if (!Config.IsKeyDefined(key))
+ throw new InvalidOperationException($"Mod key ({key}) is not owned by {Config.Owner.Name}'s config");
+ }
+
+ private static void AssertMatchingType(ModConfigurationKey key) {
+ if (key.ValueType() != typeof(T))
+ throw new InvalidOperationException($"Type of mod key ({key}) does not match field type {typeof(T)}");
+ }
+
+ private string GetKeyLabel(ModConfigurationKey key)
+ => (key.InternalAccessOnly ? "[INTERNAL] " : "")
+ + (PreferDescriptionLabels ? (key.Description ?? key.Name) : key.Name);
+
+ private string GetKeyDescription(ModConfigurationKey key)
+ => PreferDescriptionLabels ? $"Key name: {key.Name}" : (key.Description ?? "(No description)");
+
+ ///
+ /// If true , configuration key descriptions will be used as the DataFeedItem's label if they exist.
+ /// If false , the configuration key name will be used as the label.
+ /// In both cases, the description will be the opposite field of the label.
+ ///
+ public bool PreferDescriptionLabels { get; set; } = true;
+
+ ///
+ /// Instantiates and caches a new builder for a specific .
+ /// Check if a cached builder exists in before creating a new one!
+ ///
+ /// The mod configuration this builder will generate items for
+ public ModConfigurationFeedBuilder(ModConfiguration config) {
+ Config = config;
+ IEnumerable autoConfigKeys = config.Owner.GetType().GetDeclaredFields().Where(HasAutoRegisterAttribute);
+ HashSet groupedKeys = new();
+
+ foreach (FieldInfo field in autoConfigKeys) {
+ ModConfigurationKey key = (ModConfigurationKey)field.GetValue(field.IsStatic ? null : config.Owner);
+ if (key is null) continue; // dunno why this would happen
+ KeyFields[key] = field;
+
+ AutoRegisterConfigKeyAttribute attribute = field.GetCustomAttribute();
+ if (attribute.Group is string groupName) {
+ if (!KeyGrouping.ContainsKey(groupName))
+ KeyGrouping[groupName] = new();
+ KeyGrouping[groupName].Add(key);
+ groupedKeys.Add(key);
+ }
+ }
+
+ if (groupedKeys.Any()) {
+ if (!KeyGrouping.ContainsKey("Uncategorized"))
+ KeyGrouping["Uncategorized"] = new();
+
+ foreach (ModConfigurationKey key in config.ConfigurationItemDefinitions)
+ if (!groupedKeys.Contains(key))
+ KeyGrouping["Uncategorized"].Add(key);
+ }
+
+ CachedBuilders[config] = this;
+
+ if (Logger.IsDebugEnabled()) {
+ Logger.DebugInternal("--- ModConfigurationFeedBuilder instantiated ---");
+ Logger.DebugInternal($"Config owner: {config.Owner.Name}");
+ Logger.DebugInternal($"Total keys: {config.ConfigurationItemDefinitions.Count}");
+ Logger.DebugInternal($"AutoRegistered keys: {autoConfigKeys.Count()}, Grouped: {groupedKeys.Count}");
+ Logger.DebugInternal($"Key groups ({KeyGrouping.Keys.Count}): [{string.Join(", ", KeyGrouping.Keys)}]");
+ }
+ }
+
+ public IEnumerable Page(IReadOnlyList path, string searchPhrase = "", bool includeInternal = false)
+ {
+ if (path is null || !path.Any())
+ foreach (DataFeedItem item in RootPage(searchPhrase, includeInternal))
+ yield return item;
+
+ }
+
+ ///
+ /// Generates a root config page containing all defined config keys.
+ ///
+ /// If set, only show keys whose name or description contains this string
+ /// If true , also generate items for config keys marked as internal
+ /// Feed items for all defined config keys, plus buttons to save, discard, and reset the config.
+ public IEnumerable RootPage(string searchPhrase = "", bool includeInternal = false) {
+ if (KeyGrouping.Any()) {
+ foreach (string group in KeyGrouping.Keys) {
+ DataFeedGroup container = FeedBuilder.Group(group, group);
+ foreach (ModConfigurationKey key in Config.ConfigurationItemDefinitions.Where(KeyGrouping[group].Contains)) {
+ if (key.InternalAccessOnly && !includeInternal) continue;
+ if (!string.IsNullOrEmpty(searchPhrase) && string.Join("\n", key.Name, key.Description).IndexOf(searchPhrase, StringComparison.InvariantCultureIgnoreCase) < 0) continue;
+ container.AddSubitems(GenerateDataFeedItem(key));
+ }
+ if (container.SubItems?.Any() ?? false) yield return container;
+ }
+ }
+ else {
+ foreach (ModConfigurationKey key in Config.ConfigurationItemDefinitions) {
+ if (key.InternalAccessOnly && !includeInternal) continue;
+ if (!string.IsNullOrEmpty(searchPhrase) && string.Join("\n", key.Name, key.Description).IndexOf(searchPhrase, StringComparison.InvariantCultureIgnoreCase) < 0) continue;
+ yield return GenerateDataFeedItem(key);
+ }
+ }
+
+ yield return GenerateSaveControlButtons();
+ }
+
+ ///
+ /// (NOT YET IMPLEMENTED) Generates a subpage for an indexed/enumerable config key.
+ /// ie. arrays, lists, dictionaries, sets.
+ ///
+ /// A key with an enumerable type
+ /// If true , items may only be reordered, not added/removed.
+ /// A ordered feed item for each element in the key's value, plus a group of buttons to add/remove items if set.
+ private IEnumerable> EnumerablePage(ModConfigurationKey key, bool reorderOnly = false) {
+ AssertChildKey(key);
+ if (!typeof(IEnumerable).IsAssignableFrom(key.ValueType())) yield break;
+ var value = (IEnumerable)Config.GetValue(key);
+ int i = 0;
+ foreach (object item in value)
+ yield return FeedBuilder.OrderedItem(key.Name + i, key.Name, item.ToString(), i++);
+ if (reorderOnly) yield break;
+ // Group that contains input field plus buttons to prepend/append, and remove first/last item
+ }
+
+ // these generate methods need to be cleaned up and more strongly typed
+ // todo: Make all these methods use generic keys
+
+ ///
+ /// Generates a slider for the defining key if it is a float has a range attribute, otherwise generates a generic value field.
+ ///
+ /// The value type of the supplied key
+ /// The key to generate the item from
+ /// A DataFeedSlider if possible, otherwise a DataFeedValueField.
+ ///
+ public DataFeedValueField GenerateDataFeedField(ModConfigurationKey key) {
+ AssertChildKey(key);
+ AssertMatchingType(key);
+ string label = GetKeyLabel(key);
+ string description = GetKeyDescription(key);
+ if (typeof(T).IsAssignableFrom(typeof(float)) && KeyFields.TryGetValue(key, out FieldInfo field) && TryGetRangeAttribute(field, out RangeAttribute range) && range.Min is T min && range.Max is T max)
+ return FeedBuilder.Slider(key.Name, label, description, (field) => field.SyncWithModConfiguration(Config, key), min, max, range.TextFormat);
+ // If range attribute wasn't limited to floats, we could also make ClampedValueField's
+ else
+ return FeedBuilder.ValueField(key.Name, label, description, (field) => field.SyncWithModConfiguration(Config, key));
+ }
+
+ ///
+ /// Generates an enum field for a specific configuration key.
+ ///
+ /// The enum type of the supplied key
+ /// The key to generate the item from
+ /// A physical mango if it is opposite day.
+ ///
+ public DataFeedEnum GenerateDataFeedEnum(ModConfigurationKey key) where E : Enum {
+ AssertChildKey(key);
+ AssertMatchingType(key);
+ string label = GetKeyLabel(key);
+ string description = GetKeyDescription(key);
+ return FeedBuilder.Enum(key.Name, label, description, (field) => field.SyncWithModConfiguration(Config, key));
+ }
+
+ ///
+ /// Generates the appropriate DataFeedItem for any config key type.
+ ///
+ /// The key to generate the item from
+ /// Automatically picks the best item type for the config key type.
+ public DataFeedItem GenerateDataFeedItem(ModConfigurationKey key) {
+ AssertChildKey(key);
+ string label = GetKeyLabel(key);
+ string description = GetKeyDescription(key);
+ Type valueType = key.ValueType();
+ if (valueType == typeof(dummy))
+ return FeedBuilder.Label(key.Name, label, description);
+ else if (valueType == typeof(bool))
+ return FeedBuilder.Toggle(key.Name, label, description, (field) => field.SyncWithModConfiguration(Config, key));
+ else if (valueType != typeof(string) && valueType != typeof(Uri) && typeof(IEnumerable).IsAssignableFrom(valueType))
+ return FeedBuilder.Category(key.Name, label, description);
+ else if (valueType.InheritsFrom(typeof(Enum)))
+ return (DataFeedItem)typeof(ModConfigurationFeedBuilder).GetMethod(nameof(GenerateDataFeedEnum)).MakeGenericMethod(key.ValueType()).Invoke(this, [key]);
+ else
+ return (DataFeedItem)typeof(ModConfigurationFeedBuilder).GetMethod(nameof(GenerateDataFeedField)).MakeGenericMethod(key.ValueType()).Invoke(this, [key]);
+ }
+
+ ///
+ /// Generates buttons to save/discard changes, or reset all config keys to their defaults.
+ ///
+ /// A group with the aforementioned options.
+ public DataFeedGrid GenerateSaveControlButtons() {
+ string configName = Path.GetFileNameWithoutExtension(Config.Owner.ModAssembly!.File);
+ DataFeedGrid container = FeedBuilder.Grid("SaveControlButtonsGrid", "", [
+ FeedBuilder.ValueAction("Save", "Save changes", (action) => action.Target = SaveConfig, configName),
+ FeedBuilder.ValueAction("Discard", "Discard changes", (action) => action.Target = DiscardConfig, configName),
+ FeedBuilder.ValueAction("Reset", "Reset all options", (action) => action.Target = ResetConfig, configName)
+ ]);
+ return container;
+ }
+
+ [SyncMethod(typeof(Action), [])]
+ private static void SaveConfig(string configName) {
+ if (ModConfiguration.configNameMap.TryGetValue(configName, out var config)) {
+ config.SaveQueue(false, true);
+ NotificationMessage.SpawnTextMessage("Saved successfully", colorX.White);
+ } else
+ NotificationMessage.SpawnTextMessage("Failed to save!", colorX.Red);
+ }
+
+ [SyncMethod(typeof(Action), [])]
+ private static void DiscardConfig(string configName) {
+ Userspace.OpenContextMenu(
+ Userspace.UserspaceWorld.GetGloballyRegisteredComponent().Slot,
+ new ContextMenuOptions { disableFlick = true },
+ async (menu) => {
+ menu.AddItem(
+ "Really discard changes",
+ OfficialAssets.Graphics.Icons.Inspector.DestroyPreservingAssets,
+ colorX.Red
+ ).Button.LocalPressed += (_, _) => {
+ NotificationMessage.SpawnTextMessage("Not implemented", colorX.Yellow);
+ menu.Close();
+ };
+ menu.AddItem("Cancel", (Uri)null!, colorX.White)
+ .Button.LocalPressed += (_, _) => menu.Close();
+ }
+ );
+ }
+
+ [SyncMethod(typeof(Action), [])]
+ private static void ResetConfig(string configName) {
+ Userspace.OpenContextMenu(
+ Userspace.UserspaceWorld.GetGloballyRegisteredComponent().Slot,
+ new ContextMenuOptions { disableFlick = true },
+ async (menu) => {
+ menu.AddItem(
+ "Really reset configuration",
+ OfficialAssets.Graphics.Icons.Inspector.Destroy,
+ colorX.Red
+ ).Button.LocalPressed += (_, _) => {
+ NotificationMessage.SpawnTextMessage("Not implemented", colorX.Yellow);
+ menu.Close();
+ };
+ menu.AddItem("Cancel", (Uri)null!, colorX.White)
+ .Button.LocalPressed += (_, _) => menu.Close();
+ }
+ );
+ }
+}
+
+///
+/// Extentions that work with 's
+///
+public static class ModConfigurationFeedBuilderExtensions {
+ ///
+ /// Returns a cached , or creates a new one.
+ ///
+ /// The the builder belongs to
+ /// A cached or new builder.
+ public static ModConfigurationFeedBuilder ConfigurationFeedBuilder(this ModConfiguration config) {
+ ModConfigurationFeedBuilder.CachedBuilders.TryGetValue(config, out var builder);
+ return builder ?? new ModConfigurationFeedBuilder(config);
+ }
+}
diff --git a/ResoniteModLoader/ModConfigurationValueSync.cs b/ResoniteModLoader/ModConfigurationValueSync.cs
new file mode 100644
index 0000000..780176a
--- /dev/null
+++ b/ResoniteModLoader/ModConfigurationValueSync.cs
@@ -0,0 +1,155 @@
+using Elements.Core;
+using FrooxEngine;
+
+namespace ResoniteModLoader;
+
+///
+/// Bi-directionally syncs a field with a specific mod configuration key.
+///
+/// The mod configuration key type
+[Category(["ResoniteModLoader"])]
+public class ModConfigurationValueSync : Component {
+#pragma warning disable CS1591
+#if !DEBUG
+ public override bool UserspaceOnly => true;
+#endif
+#pragma warning restore CS1591
+#pragma warning disable CS8618, CA1051
+ public readonly Sync DefiningModAssembly;
+
+ public readonly Sync ConfigurationKeyName;
+
+ public readonly RawOutput DefinitionFound;
+
+ public readonly FieldDrive TargetField;
+#pragma warning restore CS8618, CA1051
+ private ResoniteModBase? _mappedMod;
+
+ private ModConfiguration? _mappedConfig;
+
+ private ModConfigurationKey? _mappedKey;
+
+ private bool _definitionFound;
+#pragma warning disable CS1591
+ protected override void OnAwake() {
+ base.OnAwake();
+ TargetField.SetupValueSetHook((IField field, T value) => {
+ if (_mappedKey is not null) {
+ if (_mappedKey.Validate(value)) {
+ TargetField.Target.Value = value;
+ _mappedConfig!.Set(_mappedKey, value);
+ }
+ }
+ });
+ }
+
+ protected override void OnChanges() {
+ base.OnChanges();
+ Unregister();
+ if (MapModConfigKey())
+ Register();
+ DefinitionFound.Value = _definitionFound;
+ }
+
+ protected override void OnDispose() {
+ Unregister();
+ base.OnDispose();
+ }
+
+ protected override void OnStart() {
+ base.OnStart();
+ if (MapModConfigKey())
+ Register();
+ }
+#pragma warning restore CS1591
+ ///
+ /// Attempts to match the supplied and fields to a mod config and key
+ ///
+ /// Success
+ private bool MapModConfigKey() {
+ if (string.IsNullOrEmpty(DefiningModAssembly.Value) || string.IsNullOrEmpty(ConfigurationKeyName.Value))
+ return false;
+ try {
+ _mappedMod = ModLoader.Mods().Single((mod) => Path.GetFileNameWithoutExtension(mod.ModAssembly?.File) == DefiningModAssembly.Value);
+ _mappedConfig = _mappedMod?.GetConfiguration();
+ _mappedKey = _mappedConfig?.ConfigurationItemDefinitions.Single((key) => key.Name == ConfigurationKeyName.Value);
+ if (_mappedMod is null || _mappedConfig is null || _mappedKey is null)
+ return false;
+ return _mappedKey.ValueType() == typeof(T);
+ }
+ catch (Exception) {
+ return false;
+ }
+ }
+
+ ///
+ /// Call AFTER mapping has been confirmed to begin syncing the target field
+ ///
+ private void Register() {
+ ConfigValueChanged(_mappedConfig.GetValue(_mappedKey));
+ _mappedKey!.OnChanged += ConfigValueChanged;
+ _definitionFound = true;
+ }
+
+ ///
+ /// Stop syncing, call whenever any field has changed to make sure the rug isn't pulled out from under us.
+ ///
+ private void Unregister() {
+ if (_mappedKey is not null)
+ _mappedKey.OnChanged -= ConfigValueChanged;
+ _mappedMod = null;
+ _mappedConfig = null;
+ _mappedKey = null;
+ _definitionFound = false;
+ }
+
+ private void ConfigValueChanged(object? value) {
+ if (TargetField.IsLinkValid)
+ TargetField.Target.Value = (T)value ?? default;
+ }
+
+ ///
+ /// Sets the and fields to match the supplied config and key.
+ ///
+ /// The configuration the key belongs to
+ /// Any key with a matching type
+ public void LoadConfigKey(ModConfiguration config, ModConfigurationKey key) {
+ if (!config.IsKeyDefined(key))
+ throw new InvalidOperationException($"Mod key ({key}) is not owned by {config.Owner.Name}'s config");
+
+ _mappedMod = config.Owner;
+ _mappedConfig = config;
+ _mappedKey = key;
+ DefiningModAssembly.Value = Path.GetFileNameWithoutExtension(config.Owner.ModAssembly!.File);
+ ConfigurationKeyName.Value = key.Name;
+ Register();
+ }
+}
+
+///
+/// Utilities methods that attaches to stuff.
+///
+public static class ModConfigurationValueSyncExtensions {
+ ///
+ /// Syncs a target IField with a mod configuration key.
+ ///
+ /// The field and key type
+ /// The field to bi-directionally sync
+ /// The configuration the key belongs to
+ /// Any key with a matching type
+ /// A new component that was attached to the same slot as the field.
+ /// Thrown if key doesn't belong to config, or is of wrong type
+ public static ModConfigurationValueSync SyncWithModConfiguration(this IField field, ModConfiguration config, ModConfigurationKey key) {
+ if (!config.IsKeyDefined(key))
+ throw new InvalidOperationException($"Mod key ({key}) is not owned by {config.Owner.Name}'s config");
+ if (key.ValueType() != typeof(T))
+ throw new InvalidOperationException($"Type of mod key ({key}) does not match field type {typeof(T)}");
+
+ Logger.DebugInternal($"Syncing field with [{key}] from {config.Owner.Name}");
+ ModConfigurationValueSync driver = field.FindNearestParent().AttachComponent>();
+ driver.LoadConfigKey(config, key as ModConfigurationKey);
+ driver.TargetField.Target = field;
+
+ return driver;
+ }
+}
diff --git a/ResoniteModLoader/ModLoader.cs b/ResoniteModLoader/ModLoader.cs
index 179dc38..2a70c07 100644
--- a/ResoniteModLoader/ModLoader.cs
+++ b/ResoniteModLoader/ModLoader.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using HarmonyLib;
namespace ResoniteModLoader;
@@ -16,6 +17,7 @@ public sealed class ModLoader {
internal static readonly Dictionary AssemblyLookupMap = new(); // used for logging
private static readonly Dictionary ModNameLookupMap = new(); // used for duplicate mod checking
+ private static readonly Stopwatch InitTimer = new(); // used to measure mod hooking duration
///
/// Returns true if ResoniteModLoader was loaded by a headless
@@ -189,9 +191,15 @@ private static void HookMod(ResoniteMod mod) {
LoadProgressIndicator.SetCustom($"Starting mod [{mod.Name}/{mod.Version}]");
Logger.DebugFuncInternal(() => $"calling OnEngineInit() for [{mod.Name}/{mod.Version}]");
try {
+ InitTimer.Start();
mod.OnEngineInit();
} catch (Exception e) {
Logger.ErrorInternal($"Mod {mod.Name} from {mod.ModAssembly?.File ?? "Unknown Assembly"} threw error from OnEngineInit():\n{e}");
}
+ finally {
+ InitTimer.Stop();
+ mod.InitializationTime = InitTimer.Elapsed;
+ InitTimer.Reset();
+ }
}
}
diff --git a/ResoniteModLoader/ModLoaderConfiguration.cs b/ResoniteModLoader/ModLoaderConfiguration.cs
index a1ba099..9291361 100644
--- a/ResoniteModLoader/ModLoaderConfiguration.cs
+++ b/ResoniteModLoader/ModLoaderConfiguration.cs
@@ -12,14 +12,12 @@ internal static ModLoaderConfiguration Get() {
_configuration = new ModLoaderConfiguration();
Dictionary> keyActions = new() {
- //{ "unsafe", (value) => _configuration.Unsafe = bool.Parse(value) },
{ "debug", (value) => _configuration.Debug = bool.Parse(value) },
{ "hidevisuals", (value) => _configuration.HideVisuals = bool.Parse(value) },
{ "nomods", (value) => _configuration.NoMods = bool.Parse(value) },
{ "advertiseversion", (value) => _configuration.AdvertiseVersion = bool.Parse(value) },
{ "logconflicts", (value) => _configuration.LogConflicts = bool.Parse(value) },
- //{ "hidemodtypes", (value) => _configuration.HideModTypes = bool.Parse(value) },
- //{ "hidelatetypes", (value) => _configuration.HideLateTypes = bool.Parse(value) }
+ { "nodashscreen", (value) => _configuration.NoDashScreen = bool.Parse(value) },
};
// .NET's ConfigurationManager is some hot trash to the point where I'm just done with it.
@@ -54,7 +52,7 @@ internal static ModLoaderConfiguration Get() {
return _configuration;
}
- private static string GetAssemblyDirectory() {
+ internal static string GetAssemblyDirectory() {
string codeBase = Assembly.GetExecutingAssembly().CodeBase;
UriBuilder uri = new(codeBase);
string path = Uri.UnescapeDataString(uri.Path);
@@ -62,13 +60,11 @@ private static string GetAssemblyDirectory() {
}
#pragma warning disable CA1805
- //public bool Unsafe { get; private set; } = false;
public bool Debug { get; private set; } = false;
public bool NoMods { get; private set; } = false;
public bool HideVisuals { get; private set; } = false;
public bool AdvertiseVersion { get; private set; } = false;
public bool LogConflicts { get; private set; } = true;
- //public bool HideModTypes { get; private set; } = true;
- //public bool HideLateTypes { get; private set; } = true;
+ public bool NoDashScreen { get; private set; } = false;
#pragma warning restore CA1805
}
diff --git a/ResoniteModLoader/Properties/AssemblyInfo.cs b/ResoniteModLoader/Properties/AssemblyInfo.cs
index 043611b..58929dd 100644
--- a/ResoniteModLoader/Properties/AssemblyInfo.cs
+++ b/ResoniteModLoader/Properties/AssemblyInfo.cs
@@ -15,7 +15,11 @@
// Prevent FrooxEngine.Weaver from modifying this assembly, as it doesn't need anything done to it
// This keeps Weaver from overwriting AssemblyVersionAttribute
-[module: Description("FROOXENGINE_WEAVED")]
+// [module: Description("FROOXENGINE_WEAVED")]
//Mark as DataModelAssembly for the Plugin loading system to load this assembly
-[assembly: DataModelAssembly(DataModelAssemblyType.Optional)]
+#if DEBUG
+[assembly: DataModelAssembly(DataModelAssemblyType.Core)]
+#else
+[assembly: DataModelAssembly(DataModelAssemblyType.UserspaceCore)]
+#endif
diff --git a/ResoniteModLoader/ResoniteMod.cs b/ResoniteModLoader/ResoniteMod.cs
index fb1d6ee..b3e21b5 100644
--- a/ResoniteModLoader/ResoniteMod.cs
+++ b/ResoniteModLoader/ResoniteMod.cs
@@ -1,3 +1,5 @@
+using FrooxEngine;
+
namespace ResoniteModLoader;
///
@@ -11,7 +13,28 @@ public abstract class ResoniteMod : ResoniteModBase {
public static bool IsDebugEnabled() => Logger.IsDebugEnabled();
///
- /// Logs an object as a line in the log based on the value produced by the given function if debug logging is enabled..
+ /// Logs an object as a line in the log with a stack trace based on the value produced by the given function if debug logging is enabled.
+ ///
+ /// This is more efficient than passing an or a directly,
+ /// as it won't be generated if debug logging is disabled.
+ ///
+ /// The function generating the object to log.
+ public static void TraceFunc(Func messageProducer) => Logger.TraceFuncExternal(messageProducer);
+
+ ///
+ /// Logs the given object as a line in the log if debug logging is enabled.
+ ///
+ /// The object to log.
+ public static void Trace(object message) => Logger.TraceExternal(message);
+
+ ///
+ /// Logs the given objects as lines in the log with a stack trace if debug logging is enabled.
+ ///
+ /// The objects to log.
+ public static void Trace(params object[] messages) => Logger.TraceListExternal(messages);
+
+ ///
+ /// Logs an object as a line in the log with a stack trace based on the value produced by the given function if debug logging is enabled.
///
/// This is more efficient than passing an or a directly,
/// as it won't be generated if debug logging is disabled.
@@ -99,4 +122,10 @@ public virtual void DefineConfiguration(ModConfigurationDefinitionBuilder builde
public virtual IncompatibleConfigurationHandlingOption HandleIncompatibleConfigurationVersions(Version serializedVersion, Version definedVersion) {
return IncompatibleConfigurationHandlingOption.ERROR;
}
+
+ ///
+ protected internal override IEnumerable BuildConfigurationFeed(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false) {
+ foreach (DataFeedItem item in this.GenerateModConfigurationFeed(path, groupKeys, searchPhrase, viewData, includeInternal, true))
+ yield return item;
+ }
}
diff --git a/ResoniteModLoader/ResoniteModBase.cs b/ResoniteModLoader/ResoniteModBase.cs
index a3f0302..3b2da9b 100644
--- a/ResoniteModLoader/ResoniteModBase.cs
+++ b/ResoniteModLoader/ResoniteModBase.cs
@@ -1,3 +1,7 @@
+using System.Diagnostics;
+using System.Runtime.Remoting.Messaging;
+using FrooxEngine;
+
namespace ResoniteModLoader;
///
@@ -24,6 +28,8 @@ public abstract class ResoniteModBase {
///
public virtual string? Link { get; }
+ public TimeSpan InitializationTime { get; internal set; }
+
///
/// A reference to the AssemblyFile that this mod was loaded from.
/// The reference is set once the mod is successfully loaded, and is null before that.
@@ -46,5 +52,34 @@ public abstract class ResoniteModBase {
return ModConfiguration;
}
+ ///
+ /// Returns whether or not this mod has a configuration, and set an out param if it does.
+ ///
+ /// The variable that is set to this mods configuration if it has one
+ /// true if the out param was set, false if the mod has no configuration.
+ public bool TryGetConfiguration(out ModConfiguration configuration) {
+ configuration = ModConfiguration!;
+ return configuration is not null;
+ }
+
+ ///
+ /// Checks if this mod has defined a configuration.
+ ///
+ /// true if there is a config, false if there is not.
+ public bool HasConfiguration() => ModConfiguration is not null;
+
+ ///
+ /// Define a custom configuration DataFeed for this mod.
+ ///
+ /// Starts empty at the root of the configuration category, allows sub-categories to be used.
+ /// Passed-through from 's Enumerate call.
+ /// A phrase by which configuration items should be filtered. Passed-through from 's Enumerate call
+ /// Passed-through from 's Enumerate call.
+ /// Indicates whether the user has requested that internal configuration keys are included in the returned feed.
+ /// DataFeedItem's to be directly returned by the calling .
+ protected internal abstract IEnumerable BuildConfigurationFeed(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false);
+
+ // Why would anyone need an async config? They depend on Microsoft.Bcl.AsyncInterfaces too
+
internal bool FinishedLoading { get; set; }
}
diff --git a/ResoniteModLoader/ResoniteModLoader.csproj b/ResoniteModLoader/ResoniteModLoader.csproj
index ac00e16..56bcfcf 100644
--- a/ResoniteModLoader/ResoniteModLoader.csproj
+++ b/ResoniteModLoader/ResoniteModLoader.csproj
@@ -5,7 +5,7 @@
false
net472
512
- 10.0
+ 12
enable
true
True
@@ -26,14 +26,31 @@
+
$(ResonitePath)Resonite_Data\Managed\Elements.Core.dll
False
+
+ $(ResonitePath)Resonite_Data\Managed\Elements.Quantity.dll
+ False
+
$(ResonitePath)Resonite_Data\Managed\FrooxEngine.dll
False
+
+ $(ResonitePath)Resonite_Data\Managed\FrooxEngine.Store.dll
+ False
+
+
+ $(ResonitePath)Resonite_Data\Managed\SkyFrost.Base.dll
+ False
+
+
+ $(ResonitePath)Resonite_Data\Managed\SkyFrost.Base.Models.dll
+ False
+
$(ResonitePath)Resonite_Data\Managed\Newtonsoft.Json.dll
False
@@ -43,7 +60,7 @@
False
- R:\SteamLibrary\steamapps\common\Resonite\Resonite_Data\Managed\UnityEngine.CoreModule.dll
+ $(ResonitePath)Resonite_Data\Managed\UnityEngine.CoreModule.dll
False
@@ -52,6 +69,10 @@
+
+
+
+
diff --git a/ResoniteModLoader/Resources/DashScreenTemplate.bson b/ResoniteModLoader/Resources/DashScreenTemplate.bson
new file mode 100644
index 0000000..96f2813
Binary files /dev/null and b/ResoniteModLoader/Resources/DashScreenTemplate.bson differ
diff --git a/ResoniteModLoader/Util.cs b/ResoniteModLoader/Util.cs
index e03b3e1..6a7af85 100644
--- a/ResoniteModLoader/Util.cs
+++ b/ResoniteModLoader/Util.cs
@@ -81,7 +81,8 @@ internal static bool CanBeNull(Type t) {
internal static IEnumerable GetLoadableTypes(this Assembly assembly, Predicate predicate) {
try {
return assembly.GetTypes().Where(type => CheckType(type, predicate));
- } catch (ReflectionTypeLoadException e) {
+ }
+ catch (ReflectionTypeLoadException e) {
return e.Types.Where(type => CheckType(type, predicate));
}
}
@@ -95,14 +96,16 @@ private static bool CheckType(Type type, Predicate predicate) {
try {
string _name = type.Name;
- } catch (Exception e) {
+ }
+ catch (Exception e) {
Logger.DebugFuncInternal(() => $"Could not read the name for a type: {e}");
return false;
}
try {
return predicate(type);
- } catch (Exception e) {
+ }
+ catch (Exception e) {
Logger.DebugFuncInternal(() => $"Could not load type \"{type}\": {e}");
return false;
}
diff --git a/ResoniteModLoader/Utility/FeedBuilder.cs b/ResoniteModLoader/Utility/FeedBuilder.cs
new file mode 100644
index 0000000..8e10f2f
--- /dev/null
+++ b/ResoniteModLoader/Utility/FeedBuilder.cs
@@ -0,0 +1,559 @@
+using Elements.Core;
+using Elements.Quantity;
+using FrooxEngine;
+using HarmonyLib;
+
+namespace ResoniteModLoader;
+
+///
+/// Utility class to easily generate DataFeedItem's.
+///
+public static class FeedBuilder {
+#pragma warning disable CS8625, CS1591, CA1715
+ public static T Item(string itemKey, LocaleString label, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where T : DataFeedItem
+ => Activator.CreateInstance().ChainInitBase(itemKey, path, groupingParameters, label, icon, setupVisible, setupEnabled, subitems, customEntity);
+
+ // CONFLICT AB
+ public static DataFeedCategory Category(string itemKey, LocaleString label, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null)
+ => Item(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity);
+
+ // CONFLICT BA
+ public static DataFeedCategory Category(string itemKey, LocaleString label, string[] subpath, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null)
+ => Category(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainSetOverrideSubpath(subpath);
+
+ public static DataFeedGroup Group(string itemKey, LocaleString label, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null)
+ => Item(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity);
+
+ public static DataFeedGroup Group(string itemKey, LocaleString label, IReadOnlyList subitems, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, object customEntity = null)
+ => Group(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity);
+
+ public static DataFeedGrid Grid(string itemKey, LocaleString label, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null)
+ => Item(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity);
+
+ public static DataFeedGrid Grid(string itemKey, LocaleString label, IReadOnlyList subitems, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, object customEntity = null)
+ => Grid(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity);
+
+ public static DataFeedEntity Entity(string itemKey, LocaleString label, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null)
+ => Item>(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity);
+
+ public static DataFeedEntity Entity(string itemKey, LocaleString label, E entity, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null)
+ => Entity(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitEntity(entity);
+
+ public static DataFeedAction Action(string itemKey, LocaleString label, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null)
+ => Item(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity);
+
+ public static DataFeedAction Action(string itemKey, LocaleString label, Action> setupAction, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null)
+ => Action(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitAction(setupAction);
+
+ public static DataFeedAction Action(string itemKey, LocaleString label, Action> setupAction, Action> setupHighlight, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null)
+ => Action(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitAction(setupAction).ChainInitHighlight(setupHighlight);
+
+ public static DataFeedValueAction ValueAction(string itemKey, LocaleString label, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null)
+ => Item>(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity);
+
+ public static DataFeedValueAction ValueAction(string itemKey, LocaleString label, Action>> setupAction, Action> setupValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null)
+ => ValueAction(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitAction(setupAction, setupValue);
+
+ public static DataFeedValueAction ValueAction(string itemKey, LocaleString label, Action>> setupAction, T value, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null)
+ => ValueAction(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitAction(setupAction, value);
+
+ public static DataFeedValueAction ValueAction(string itemKey, LocaleString label, Action>> setupAction, Action> setupValue, Action> setupHighlight, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null)
+ => ValueAction(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitAction(setupAction, setupValue).ChainInitHighlight(setupHighlight);
+
+ public static DataFeedValueAction ValueAction(string itemKey, LocaleString label, Action>> setupAction, T value, Action> setupHighlight, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action