From 00289f4af8c0a6a3f867a546b82b3c8f3f5a3986 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 14 Jul 2024 21:19:21 -0500 Subject: [PATCH 01/25] Initial data feed and dash screen test --- ResoniteModLoader/DashScreenInjector.cs | 61 ++++++++++++++++ ResoniteModLoader/HarmonyWorker.cs | 1 + ResoniteModLoader/ModConfigurationDataFeed.cs | 69 +++++++++++++++++++ ResoniteModLoader/ModLoaderConfiguration.cs | 2 + ResoniteModLoader/Properties/AssemblyInfo.cs | 2 +- ResoniteModLoader/ResoniteModLoader.csproj | 13 +++- 6 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 ResoniteModLoader/DashScreenInjector.cs create mode 100644 ResoniteModLoader/ModConfigurationDataFeed.cs diff --git a/ResoniteModLoader/DashScreenInjector.cs b/ResoniteModLoader/DashScreenInjector.cs new file mode 100644 index 0000000..11aaa28 --- /dev/null +++ b/ResoniteModLoader/DashScreenInjector.cs @@ -0,0 +1,61 @@ +using FrooxEngine; +using HarmonyLib; + +namespace ResoniteModLoader; + +internal sealed class DashScreenInjector +{ + internal static RadiantDashScreen? InjectedScreen; + + internal static void PatchScreenManager(Harmony harmony) + { + MethodInfo SetupDefaultMethod = AccessTools.DeclaredMethod(typeof(UserspaceScreensManager), "SetupDefaults"); + MethodInfo TryInjectScreenMethod = AccessTools.DeclaredMethod(typeof(DashScreenInjector), nameof(TryInjectScreen)); + harmony.Patch(SetupDefaultMethod, postfix: new HarmonyMethod(TryInjectScreen)); + } + + internal static async void TryInjectScreen(UserspaceScreensManager __instance) + { + if (ModLoaderConfiguration.Get().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"); + } + + RadiantDash dash = __instance.Slot.GetComponentInParents(); + InjectedScreen = dash.AttachScreen("Mods", RadiantUI_Constants.Hero.RED, OfficialAssets.Graphics.Icons.General.BoxClosed); // Replace with RML icon later + + Slot screenSlot = InjectedScreen.Slot; + screenSlot.OrderOffset = 128; + screenSlot.PersistentSelf = false; + + SingleFeedView view = screenSlot.AttachComponent(); + ModConfigurationDataFeed feed = screenSlot.AttachComponent(); + + Slot templates = screenSlot.AddSlot("Template"); + templates.ActiveSelf = false; + + if (await templates.LoadObjectAsync(__instance.Cloud.Platform.GetSpawnObjectUri("SettingsItemMappers"), skipHolder: true)) + { + DataFeedItemMapper itemMapper = templates.FindChild("ItemsMapper").GetComponent(); + view.ItemsManager.TemplateMapper.Target = itemMapper; + view.ItemsManager.ContainerRoot.Target = InjectedScreen.ScreenRoot; + } + else + { + Logger.ErrorInternal("Failed to load SettingsItemMappers for dash screen, aborting."); + InjectedScreen.Slot.Destroy(); + } + + view.Feed.Target = feed; + } +} diff --git a/ResoniteModLoader/HarmonyWorker.cs b/ResoniteModLoader/HarmonyWorker.cs index c19cacb..989aed1 100644 --- a/ResoniteModLoader/HarmonyWorker.cs +++ b/ResoniteModLoader/HarmonyWorker.cs @@ -9,5 +9,6 @@ internal static void LoadModsAndHideModAssemblies(HashSet initialAssem ModLoader.LoadMods(); ModConfiguration.RegisterShutdownHook(harmony); AssemblyHider.PatchResonite(harmony, initialAssemblies); + DashScreenInjector.PatchScreenManager(harmony); } } diff --git a/ResoniteModLoader/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs new file mode 100644 index 0000000..06338bd --- /dev/null +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Elements.Core; +using FrooxEngine; +using SkyFrost.Base; + +namespace ResoniteModLoader; + +[GloballyRegistered] +[Category(new string[] { "Userspace" })] +public class ModConfigurationDataFeed : Component, IDataFeedComponent, IDataFeed, IWorldElement +{ + public override bool UserspaceOnly => true; + + public bool SupportsBackgroundQuerying => true; + + public async IAsyncEnumerable Enumerate(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData) { + { + DataFeedLabel modLoaderVersion = new DataFeedLabel(); + modLoaderVersion.InitBase("ResoniteModLoder.Version", null, null, $"ResoniteModLoader version {ModLoader.VERSION}"); + yield return modLoaderVersion; + + DataFeedLabel modLoaderLoadedModCount = new DataFeedLabel(); + modLoaderLoadedModCount.InitBase("ResoniteModLoder.LoadedModCount", null, null, $"{ModLoader.Mods().Count()} mods loaded"); + yield return modLoaderLoadedModCount; + } + + foreach (ResoniteModBase mod in ModLoader.Mods()) + { + DataFeedGroup modDataFeedGroup = new DataFeedGroup(); + modDataFeedGroup.InitBase(mod.Name + ".Group", null, null, mod.Name); + yield return modDataFeedGroup; + + DataFeedLabel authorDataFeedLabel = new DataFeedLabel(); + authorDataFeedLabel.InitBase(mod.Name + ".Author", null, null, $"Author: {mod.Author}"); + yield return authorDataFeedLabel; + + DataFeedLabel versionDataFeedLabel = new DataFeedLabel(); + versionDataFeedLabel.InitBase(mod.Name + ".Version", null, null, $"Version: {mod.Version}"); + yield return versionDataFeedLabel; + + DataFeedLabel isLoadedDataFeedLabel = new DataFeedLabel(); + isLoadedDataFeedLabel.InitBase(mod.Name + ".IsLoaded", null, null, $"IsLoaded: {mod.FinishedLoading}"); + yield return isLoadedDataFeedLabel; + } + } + + public void ListenToUpdates(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, DataFeedUpdateHandler handler, object viewData) { + Logger.DebugInternal($"ModConfigurationDataFeed.ListenToUpdates called, handler: {handler}"); + } + + public LocaleString PathSegmentName(string segment, int depth) { + return $"{segment} ({depth})"; + } + + public object RegisterViewData() { + Logger.DebugInternal("ModConfigurationDataFeed.RegisterViewData called"); + return null; + } + + public void UnregisterListener(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, DataFeedUpdateHandler handler) { + Logger.DebugInternal($"ModConfigurationDataFeed.UnregisterListener called, handler: {handler}"); + } + + public void UnregisterViewData(object data) { + Logger.DebugInternal($"ModConfigurationDataFeed.UnregisterViewData called, object: {data}"); + } +} diff --git a/ResoniteModLoader/ModLoaderConfiguration.cs b/ResoniteModLoader/ModLoaderConfiguration.cs index a1ba099..4919323 100644 --- a/ResoniteModLoader/ModLoaderConfiguration.cs +++ b/ResoniteModLoader/ModLoaderConfiguration.cs @@ -20,6 +20,7 @@ internal static ModLoaderConfiguration Get() { { "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. @@ -70,5 +71,6 @@ private static string GetAssemblyDirectory() { 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..5e5ac30 100644 --- a/ResoniteModLoader/Properties/AssemblyInfo.cs +++ b/ResoniteModLoader/Properties/AssemblyInfo.cs @@ -18,4 +18,4 @@ [module: Description("FROOXENGINE_WEAVED")] //Mark as DataModelAssembly for the Plugin loading system to load this assembly -[assembly: DataModelAssembly(DataModelAssemblyType.Optional)] +[assembly: DataModelAssembly(DataModelAssemblyType.UserspaceCore)] diff --git a/ResoniteModLoader/ResoniteModLoader.csproj b/ResoniteModLoader/ResoniteModLoader.csproj index ac00e16..9637108 100644 --- a/ResoniteModLoader/ResoniteModLoader.csproj +++ b/ResoniteModLoader/ResoniteModLoader.csproj @@ -34,6 +34,14 @@ $(ResonitePath)Resonite_Data\Managed\FrooxEngine.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,9 +51,12 @@ False - R:\SteamLibrary\steamapps\common\Resonite\Resonite_Data\Managed\UnityEngine.CoreModule.dll + $(ResonitePath)Resonite_Data\Managed\UnityEngine.CoreModule.dll False + + $(ResonitePath)Resonite_Data\Managed\Microsoft.Bcl.AsyncInterfaces.dll + From 746c49300dad715731a68abe0d7d14a4b0c102f2 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 14 Jul 2024 21:53:04 -0500 Subject: [PATCH 02/25] Fix compiler async issues --- ResoniteModLoader/DashScreenInjector.cs | 13 ++++++++++--- ResoniteModLoader/ModConfigurationDataFeed.cs | 2 +- ResoniteModLoader/ResoniteModLoader.csproj | 4 +--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/ResoniteModLoader/DashScreenInjector.cs b/ResoniteModLoader/DashScreenInjector.cs index 11aaa28..6935ec9 100644 --- a/ResoniteModLoader/DashScreenInjector.cs +++ b/ResoniteModLoader/DashScreenInjector.cs @@ -9,9 +9,12 @@ internal sealed class DashScreenInjector internal static void PatchScreenManager(Harmony harmony) { - MethodInfo SetupDefaultMethod = AccessTools.DeclaredMethod(typeof(UserspaceScreensManager), "SetupDefaults"); - MethodInfo TryInjectScreenMethod = AccessTools.DeclaredMethod(typeof(DashScreenInjector), nameof(TryInjectScreen)); - harmony.Patch(SetupDefaultMethod, postfix: new HarmonyMethod(TryInjectScreen)); + 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) @@ -31,6 +34,8 @@ internal static async void TryInjectScreen(UserspaceScreensManager __instance) Logger.WarnInternal("Dash screen will not be injected again because it already exists"); } + Logger.DebugInternal("Injecting dash screen"); + RadiantDash dash = __instance.Slot.GetComponentInParents(); InjectedScreen = dash.AttachScreen("Mods", RadiantUI_Constants.Hero.RED, OfficialAssets.Graphics.Icons.General.BoxClosed); // Replace with RML icon later @@ -57,5 +62,7 @@ internal static async void TryInjectScreen(UserspaceScreensManager __instance) } view.Feed.Target = feed; + + Logger.DebugInternal("Dash screen should be injected!"); } } diff --git a/ResoniteModLoader/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index 06338bd..5c12c5a 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Elements.Core; diff --git a/ResoniteModLoader/ResoniteModLoader.csproj b/ResoniteModLoader/ResoniteModLoader.csproj index 9637108..69d6372 100644 --- a/ResoniteModLoader/ResoniteModLoader.csproj +++ b/ResoniteModLoader/ResoniteModLoader.csproj @@ -26,6 +26,7 @@ + $(ResonitePath)Resonite_Data\Managed\Elements.Core.dll False @@ -54,9 +55,6 @@ $(ResonitePath)Resonite_Data\Managed\UnityEngine.CoreModule.dll False - - $(ResonitePath)Resonite_Data\Managed\Microsoft.Bcl.AsyncInterfaces.dll - From ec8e7d956f54d3ee217738f9b05c69f2c92ad06e Mon Sep 17 00:00:00 2001 From: David Date: Sun, 14 Jul 2024 21:54:23 -0500 Subject: [PATCH 03/25] Revert "remove Unsafe/HideModTypes/HideLateTypes modloader config options" This reverts commit 1b060a11ee10fa1e4c3459bb280db6fb2186d6dc. --- ResoniteModLoader/AssemblyHider.cs | 12 ++++++------ ResoniteModLoader/ModLoaderConfiguration.cs | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/ResoniteModLoader/AssemblyHider.cs b/ResoniteModLoader/AssemblyHider.cs index 6f4b33e..0df5595 100644 --- a/ResoniteModLoader/AssemblyHider.cs +++ b/ResoniteModLoader/AssemblyHider.cs @@ -48,7 +48,7 @@ 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) { + if (ModLoaderConfiguration.Get().HideModTypes) { // initialize the static assembly sets that our patches will need later resoniteAssemblies = GetResoniteAssemblies(initialAssemblies); modAssemblies = GetModAssemblies(resoniteAssemblies); @@ -68,7 +68,7 @@ internal static void PatchResonite(Harmony harmony, HashSet initialAss 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) { @@ -116,13 +116,13 @@ private static bool IsModAssembly(Assembly assembly, string typeOrAssembly, stri // 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) { + bool hideLate = 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; + return hideLate && !forceShowLate; } } } diff --git a/ResoniteModLoader/ModLoaderConfiguration.cs b/ResoniteModLoader/ModLoaderConfiguration.cs index 4919323..9e51e02 100644 --- a/ResoniteModLoader/ModLoaderConfiguration.cs +++ b/ResoniteModLoader/ModLoaderConfiguration.cs @@ -12,14 +12,14 @@ internal static ModLoaderConfiguration Get() { _configuration = new ModLoaderConfiguration(); Dictionary> keyActions = new() { - //{ "unsafe", (value) => _configuration.Unsafe = bool.Parse(value) }, + { "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) } + { "hidemodtypes", (value) => _configuration.HideModTypes = bool.Parse(value) }, + { "hidelatetypes", (value) => _configuration.HideLateTypes = bool.Parse(value) }, { "nodashscreen", (value) => _configuration.NoDashScreen = bool.Parse(value) }, }; @@ -63,14 +63,14 @@ private static string GetAssemblyDirectory() { } #pragma warning disable CA1805 - //public bool Unsafe { get; private set; } = false; + 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 HideModTypes { get; private set; } = true; + public bool HideLateTypes { get; private set; } = true; public bool NoDashScreen { get; private set; } = false; #pragma warning restore CA1805 } From 33ef2ab49252d3233a11f76d919ac61f1cb55f13 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 16 Jul 2024 03:11:08 -0500 Subject: [PATCH 04/25] It sort of works? --- ResoniteModLoader/DashScreenInjector.cs | 43 ++++-- ResoniteModLoader/ModConfigurationDataFeed.cs | 133 ++++++++++++++---- 2 files changed, 138 insertions(+), 38 deletions(-) diff --git a/ResoniteModLoader/DashScreenInjector.cs b/ResoniteModLoader/DashScreenInjector.cs index 6935ec9..8bed761 100644 --- a/ResoniteModLoader/DashScreenInjector.cs +++ b/ResoniteModLoader/DashScreenInjector.cs @@ -1,4 +1,5 @@ using FrooxEngine; +using FrooxEngine.UIX; using HarmonyLib; namespace ResoniteModLoader; @@ -19,7 +20,9 @@ internal static void PatchScreenManager(Harmony harmony) internal static async void TryInjectScreen(UserspaceScreensManager __instance) { - if (ModLoaderConfiguration.Get().NoDashScreen) + ModLoaderConfiguration config = ModLoaderConfiguration.Get(); + + if (config.NoDashScreen) { Logger.DebugInternal("Dash screen will not be injected due to configuration file"); return; @@ -37,28 +40,44 @@ internal static async void TryInjectScreen(UserspaceScreensManager __instance) Logger.DebugInternal("Injecting dash screen"); RadiantDash dash = __instance.Slot.GetComponentInParents(); - InjectedScreen = dash.AttachScreen("Mods", RadiantUI_Constants.Hero.RED, OfficialAssets.Graphics.Icons.General.BoxClosed); // Replace with RML icon later + InjectedScreen = dash.AttachScreen("Mods", RadiantUI_Constants.MidLight.ORANGE, OfficialAssets.Graphics.Icons.General.BoxClosed); // Replace with RML icon later - Slot screenSlot = InjectedScreen.Slot; - screenSlot.OrderOffset = 128; - screenSlot.PersistentSelf = false; + InjectedScreen.Slot.OrderOffset = 128; + InjectedScreen.Slot.PersistentSelf = false; - SingleFeedView view = screenSlot.AttachComponent(); - ModConfigurationDataFeed feed = screenSlot.AttachComponent(); + SingleFeedView view = InjectedScreen.ScreenRoot.AttachComponent(); + ModConfigurationDataFeed feed = InjectedScreen.ScreenRoot.AttachComponent(); - Slot templates = screenSlot.AddSlot("Template"); + Slot templates = InjectedScreen.ScreenRoot.AddSlot("Template"); templates.ActiveSelf = false; - if (await templates.LoadObjectAsync(__instance.Cloud.Platform.GetSpawnObjectUri("SettingsItemMappers"), skipHolder: true)) + 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(); + } + else if (config.Debug) { - DataFeedItemMapper itemMapper = templates.FindChild("ItemsMapper").GetComponent(); + 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.ScreenRoot; + 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."); + Logger.ErrorInternal("Failed to load SettingsItemMappers for dash screen, aborting and cleaning up."); InjectedScreen.Slot.Destroy(); + return; } view.Feed.Target = feed; diff --git a/ResoniteModLoader/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index 5c12c5a..90974fd 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Elements.Core; @@ -7,43 +7,46 @@ namespace ResoniteModLoader; -[GloballyRegistered] [Category(new string[] { "Userspace" })] public class ModConfigurationDataFeed : Component, IDataFeedComponent, IDataFeed, IWorldElement { + #pragma warning disable CS1591 public override bool UserspaceOnly => true; public bool SupportsBackgroundQuerying => true; public async IAsyncEnumerable Enumerate(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData) { + switch (path.Count) { - DataFeedLabel modLoaderVersion = new DataFeedLabel(); - modLoaderVersion.InitBase("ResoniteModLoder.Version", null, null, $"ResoniteModLoader version {ModLoader.VERSION}"); - yield return modLoaderVersion; + case 0: { + DataFeedLabel modLoaderVersion = new DataFeedLabel(); + modLoaderVersion.InitBase( + itemKey: "ResoniteModLoder.Version", + label: $"ResoniteModLoader version {ModLoader.VERSION}", + path: null, + groupingParameters: null + ); + yield return modLoaderVersion; - DataFeedLabel modLoaderLoadedModCount = new DataFeedLabel(); - modLoaderLoadedModCount.InitBase("ResoniteModLoder.LoadedModCount", null, null, $"{ModLoader.Mods().Count()} mods loaded"); - yield return modLoaderLoadedModCount; - } - - foreach (ResoniteModBase mod in ModLoader.Mods()) - { - DataFeedGroup modDataFeedGroup = new DataFeedGroup(); - modDataFeedGroup.InitBase(mod.Name + ".Group", null, null, mod.Name); - yield return modDataFeedGroup; + DataFeedIndicator modLoaderLoadedModCount = new DataFeedIndicator(); + modLoaderLoadedModCount.InitBase( + itemKey: "ResoniteModLoder.LoadedModCount", + label: "Loaded mods:", + path: null, + groupingParameters: null + ); + modLoaderLoadedModCount.InitSetupValue((count) => count.Value = ModLoader.Mods().Count()); + yield return modLoaderLoadedModCount; - DataFeedLabel authorDataFeedLabel = new DataFeedLabel(); - authorDataFeedLabel.InitBase(mod.Name + ".Author", null, null, $"Author: {mod.Author}"); - yield return authorDataFeedLabel; + foreach (ResoniteModBase mod in ModLoader.Mods()) yield return GenerateModFeedGroup(mod); + } + break; + case 2: { - DataFeedLabel versionDataFeedLabel = new DataFeedLabel(); - versionDataFeedLabel.InitBase(mod.Name + ".Version", null, null, $"Version: {mod.Version}"); - yield return versionDataFeedLabel; - - DataFeedLabel isLoadedDataFeedLabel = new DataFeedLabel(); - isLoadedDataFeedLabel.InitBase(mod.Name + ".IsLoaded", null, null, $"IsLoaded: {mod.FinishedLoading}"); - yield return isLoadedDataFeedLabel; + } + break; } + } public void ListenToUpdates(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, DataFeedUpdateHandler handler, object viewData) { @@ -56,7 +59,7 @@ public LocaleString PathSegmentName(string segment, int depth) { public object RegisterViewData() { Logger.DebugInternal("ModConfigurationDataFeed.RegisterViewData called"); - return null; + return null!; } public void UnregisterListener(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, DataFeedUpdateHandler handler) { @@ -66,4 +69,82 @@ public void UnregisterListener(IReadOnlyList path, IReadOnlyList public void UnregisterViewData(object data) { Logger.DebugInternal($"ModConfigurationDataFeed.UnregisterViewData called, object: {data}"); } + #pragma warning restore CS1591 + + private static DataFeedGroup GenerateModFeedGroup(ResoniteModBase mod) { + DataFeedGroup modFeedGroup = new DataFeedGroup(); + List groupChildren = new List(); // Could this be a pool list instead? + string key = mod.ModAssembly!.Sha256; + + DataFeedIndicator modAuthorIndicator = new DataFeedIndicator(); + modAuthorIndicator.InitBase( + itemKey: key + ".Author", + label: "Author", + path: null, + groupingParameters: null + ); + modAuthorIndicator.InitSetupValue((str) => str.Value = mod.Author); + groupChildren.Add(modAuthorIndicator); + + DataFeedIndicator modVersionIndicator = new DataFeedIndicator(); + modVersionIndicator.InitBase( + itemKey: key + ".Version", + label: "Version", + path: null, + groupingParameters: null + ); + modVersionIndicator.InitSetupValue((str) => str.Value = mod.Version); + groupChildren.Add(modVersionIndicator); + + DataFeedIndicator modAssemblyIndicator = new DataFeedIndicator(); + modAssemblyIndicator.InitBase( + itemKey: key + ".Assembly", + label: "File", + path: null, + groupingParameters: null + ); + modAssemblyIndicator.InitSetupValue((str) => str.Value = Path.GetFileName(mod.ModAssembly!.File)); + groupChildren.Add(modAssemblyIndicator); + + if (Uri.TryCreate(mod.Link, UriKind.Absolute, out var uri)) + { + DataFeedAction modOpenLinkAction = new DataFeedAction(); + modOpenLinkAction.InitBase( + itemKey: key + ".OpenLinkAction", + label: $"Open mod link ({uri.Host})", + path: null, + groupingParameters: null + ); + modOpenLinkAction.InitAction(delegate { + Userspace.UserspaceWorld.RunSynchronously(delegate { + Slot slot = Userspace.UserspaceWorld.AddSlot("Hyperlink"); + slot.PositionInFrontOfUser(float3.Backward); + slot.AttachComponent().Setup(uri, "Outgoing hyperlink"); + }); + }); + groupChildren.Add(modOpenLinkAction); + } + + if (mod.ModConfiguration is not null) + { + DataFeedCategory modConfigurationCategory = new DataFeedCategory(); + modConfigurationCategory.InitBase( + itemKey: key + ".ConfigurationCategory", + label: "Mod configuration", + path: new string[] {key, "Configuration"}, + groupingParameters: null + ); + groupChildren.Add(modConfigurationCategory); + } + + modFeedGroup.InitBase( + itemKey: key + ".Group", + label: mod.Name, + path: null, + groupingParameters: null, + subitems: groupChildren + ); + + return modFeedGroup; + } } From 37f6943544b19fe2f8779365ba098842d818a3fb Mon Sep 17 00:00:00 2001 From: David Date: Wed, 17 Jul 2024 16:09:30 -0500 Subject: [PATCH 05/25] It works better --- ResoniteModLoader/DashScreenInjector.cs | 31 ++- ResoniteModLoader/ModConfigurationDataFeed.cs | 208 ++++++++++++------ ResoniteModLoader/ResoniteModLoader.csproj | 2 +- 3 files changed, 160 insertions(+), 81 deletions(-) diff --git a/ResoniteModLoader/DashScreenInjector.cs b/ResoniteModLoader/DashScreenInjector.cs index 8bed761..d8bce3e 100644 --- a/ResoniteModLoader/DashScreenInjector.cs +++ b/ResoniteModLoader/DashScreenInjector.cs @@ -4,12 +4,10 @@ namespace ResoniteModLoader; -internal sealed class DashScreenInjector -{ +internal sealed class DashScreenInjector { internal static RadiantDashScreen? InjectedScreen; - internal static void PatchScreenManager(Harmony harmony) - { + 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)); @@ -18,23 +16,20 @@ internal static void PatchScreenManager(Harmony harmony) Logger.DebugInternal("UserspaceScreensManager patched"); } - internal static async void TryInjectScreen(UserspaceScreensManager __instance) - { + internal static async void TryInjectScreen(UserspaceScreensManager __instance) { ModLoaderConfiguration config = ModLoaderConfiguration.Get(); - if (config.NoDashScreen) - { + if (config.NoDashScreen) { Logger.DebugInternal("Dash screen will not be injected due to configuration file"); return; } - if (__instance.World != Userspace.UserspaceWorld) - { + 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) - { + if (InjectedScreen is not null && !InjectedScreen.IsRemoved) { Logger.WarnInternal("Dash screen will not be injected again because it already exists"); + return; } Logger.DebugInternal("Injecting dash screen"); @@ -51,8 +46,7 @@ internal static async void TryInjectScreen(UserspaceScreensManager __instance) Slot templates = InjectedScreen.ScreenRoot.AddSlot("Template"); templates.ActiveSelf = false; - if (await templates.LoadObjectAsync(__instance.Cloud.Platform.GetSpawnObjectUri("Settings"), skipHolder: true)) - { + 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; @@ -61,9 +55,9 @@ internal static async void TryInjectScreen(UserspaceScreensManager __instance) 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) - { + 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 @@ -73,14 +67,15 @@ internal static async void TryInjectScreen(UserspaceScreensManager __instance) view.ItemsManager.ContainerRoot.Target = InjectedScreen.ScreenCanvas.Slot; InjectedScreen.ScreenCanvas.Slot.AttachComponent(); // just for debugging } - else - { + else { Logger.ErrorInternal("Failed to load SettingsItemMappers for dash screen, aborting and cleaning up."); InjectedScreen.Slot.Destroy(); return; } + InjectedScreen.ScreenCanvas.Slot.AttachComponent().Tint.Value = UserspaceRadiantDash.DEFAULT_BACKGROUND; view.Feed.Target = feed; + view.SetCategoryPath(["ResoniteModLoader"]); Logger.DebugInternal("Dash screen should be injected!"); } diff --git a/ResoniteModLoader/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index 90974fd..aacf2da 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using Elements.Core; using FrooxEngine; @@ -7,50 +8,93 @@ namespace ResoniteModLoader; -[Category(new string[] { "Userspace" })] -public class ModConfigurationDataFeed : Component, IDataFeedComponent, IDataFeed, IWorldElement -{ - #pragma warning disable CS1591 +/// +/// A custom data feed that can be used to show information about loaded mods, and alter their configuration. Path must start with "ResoniteModLoder" +/// +[Category(["Userspace"])] +public class ModConfigurationDataFeed : Component, IDataFeedComponent, IDataFeed, IWorldElement { +#pragma warning disable CS1591 public override bool UserspaceOnly => true; public bool SupportsBackgroundQuerying => true; public async IAsyncEnumerable Enumerate(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData) { - switch (path.Count) - { + switch (path.Count) { case 0: { - DataFeedLabel modLoaderVersion = new DataFeedLabel(); - modLoaderVersion.InitBase( - itemKey: "ResoniteModLoder.Version", - label: $"ResoniteModLoader version {ModLoader.VERSION}", - path: null, - groupingParameters: null - ); - yield return modLoaderVersion; - - DataFeedIndicator modLoaderLoadedModCount = new DataFeedIndicator(); - modLoaderLoadedModCount.InitBase( - itemKey: "ResoniteModLoder.LoadedModCount", - label: "Loaded mods:", - path: null, - groupingParameters: null - ); - modLoaderLoadedModCount.InitSetupValue((count) => count.Value = ModLoader.Mods().Count()); - yield return modLoaderLoadedModCount; - - foreach (ResoniteModBase mod in ModLoader.Mods()) yield return GenerateModFeedGroup(mod); - } - break; - case 2: { + DataFeedCategory modLoaderCategory = new DataFeedCategory(); + modLoaderCategory.InitBase( + itemKey: "ResoniteModLoader", + label: $"Open ResoniteModLoader category", + path: null, + groupingParameters: null + ); + yield return modLoaderCategory; + } + yield break; + + case 1: { + if (path[0] != "ResoniteModLoader") yield break; + + DataFeedLabel modLoaderVersion = new DataFeedLabel(); + modLoaderVersion.InitBase( + itemKey: "ResoniteModLoder.Version", + label: $"ResoniteModLoader version {ModLoader.VERSION}", + path: null, + groupingParameters: null + ); + yield return modLoaderVersion; + + DataFeedIndicator modLoaderLoadedModCount = new DataFeedIndicator(); // Todo: Make DataFeedIndicator template + modLoaderLoadedModCount.InitBase( + itemKey: "ResoniteModLoder.LoadedModCount", + label: "Loaded mods:", + path: null, + groupingParameters: null + ); + modLoaderLoadedModCount.InitSetupValue((count) => count.Value = ModLoader.Mods().Count().ToString()); + yield return modLoaderLoadedModCount; + + foreach (ResoniteModBase mod in ModLoader.Mods()) + if (string.IsNullOrEmpty(searchPhrase) || mod.Name.ToLowerInvariant().Contains(searchPhrase.ToLowerInvariant())) + yield return GenerateModInfoGroup(mod); + } + yield break; - } - break; + case 2: { + if (path[0] != "ResoniteModLoader") yield break; + string key = path[1]; + ResoniteModBase mod = ModFromKey(key); + yield return GenerateModInfoGroup(mod, true); + // GenerateModLogFeed + // GenerateModExceptionFeed + } + yield break; + + case 3: { + if (path[0] != "ResoniteModLoader") yield break; + string key = path[1]; + ResoniteModBase mod = ModFromKey(key); + switch (path[2].ToLowerInvariant()) { + case "configuration": { + + } + yield break; + case "logs": { + + } + yield break; + case "exceptions": { + + } + yield break; + } + } + yield break; } - } public void ListenToUpdates(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, DataFeedUpdateHandler handler, object viewData) { - Logger.DebugInternal($"ModConfigurationDataFeed.ListenToUpdates called, handler: {handler}"); + Logger.DebugInternal($"ModConfigurationDataFeed.ListenToUpdates called, handler: {handler}\n{Environment.StackTrace}"); } public LocaleString PathSegmentName(string segment, int depth) { @@ -58,23 +102,48 @@ public LocaleString PathSegmentName(string segment, int depth) { } public object RegisterViewData() { - Logger.DebugInternal("ModConfigurationDataFeed.RegisterViewData called"); + Logger.DebugInternal($"ModConfigurationDataFeed.RegisterViewData called\n{Environment.StackTrace}"); return null!; } public void UnregisterListener(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, DataFeedUpdateHandler handler) { - Logger.DebugInternal($"ModConfigurationDataFeed.UnregisterListener called, handler: {handler}"); + Logger.DebugInternal($"ModConfigurationDataFeed.UnregisterListener called, handler: {handler}\n{Environment.StackTrace}"); } public void UnregisterViewData(object data) { - Logger.DebugInternal($"ModConfigurationDataFeed.UnregisterViewData called, object: {data}"); + Logger.DebugInternal($"ModConfigurationDataFeed.UnregisterViewData called, object: {data}\n{Environment.StackTrace}"); + } +#pragma warning restore CS1591 + + public static string KeyFromMod(ResoniteModBase mod) => mod.ModAssembly!.Sha256; + + public static ResoniteModBase? ModFromKey(string key) => ModLoader.Mods().First((mod) => KeyFromMod(mod) == key); + + [SyncMethod(typeof(Action), [])] + public static void OpenURI(Uri uri) { + Userspace.UserspaceWorld.RunSynchronously(delegate { + Slot slot = Userspace.UserspaceWorld.AddSlot("Hyperlink"); + slot.PositionInFrontOfUser(float3.Backward); + slot.AttachComponent().Setup(uri, "Outgoing hyperlink"); + }); } - #pragma warning restore CS1591 - private static DataFeedGroup GenerateModFeedGroup(ResoniteModBase mod) { + private static DataFeedGroup GenerateModInfoGroup(ResoniteModBase mod, bool standalone = false) { DataFeedGroup modFeedGroup = new DataFeedGroup(); List groupChildren = new List(); // Could this be a pool list instead? - string key = mod.ModAssembly!.Sha256; + string key = KeyFromMod(mod); + + if (standalone) { + DataFeedIndicator modNameIndicator = new DataFeedIndicator(); + modNameIndicator.InitBase( + itemKey: key + ".Name", + label: "Name", + path: null, + groupingParameters: null + ); + modNameIndicator.InitSetupValue((str) => str.Value = mod.Name); + groupChildren.Add(modNameIndicator); + } DataFeedIndicator modAuthorIndicator = new DataFeedIndicator(); modAuthorIndicator.InitBase( @@ -96,50 +165,57 @@ private static DataFeedGroup GenerateModFeedGroup(ResoniteModBase mod) { modVersionIndicator.InitSetupValue((str) => str.Value = mod.Version); groupChildren.Add(modVersionIndicator); - DataFeedIndicator modAssemblyIndicator = new DataFeedIndicator(); - modAssemblyIndicator.InitBase( - itemKey: key + ".Assembly", - label: "File", - path: null, - groupingParameters: null - ); - modAssemblyIndicator.InitSetupValue((str) => str.Value = Path.GetFileName(mod.ModAssembly!.File)); - groupChildren.Add(modAssemblyIndicator); + if (standalone) { + DataFeedIndicator modAssemblyFileIndicator = new DataFeedIndicator(); + modAssemblyFileIndicator.InitBase( + itemKey: key + ".AssemblyFile", + label: "Assembly file", + path: null, + groupingParameters: null + ); + modAssemblyFileIndicator.InitSetupValue((str) => str.Value = Path.GetFileName(mod.ModAssembly!.File)); + groupChildren.Add(modAssemblyFileIndicator); + + DataFeedIndicator modAssemblyHashIndicator = new DataFeedIndicator(); + modAssemblyHashIndicator.InitBase( + itemKey: key + ".AssemblyHash", + label: "Assembly hash", + path: null, + groupingParameters: null + ); + modAssemblyHashIndicator.InitSetupValue((str) => str.Value = mod.ModAssembly!.Sha256); + groupChildren.Add(modAssemblyHashIndicator); + + // TODO: Add initialization time recording + } - if (Uri.TryCreate(mod.Link, UriKind.Absolute, out var uri)) - { - DataFeedAction modOpenLinkAction = new DataFeedAction(); + if (Uri.TryCreate(mod.Link, UriKind.Absolute, out var uri)) { + DataFeedValueAction modOpenLinkAction = new DataFeedValueAction(); modOpenLinkAction.InitBase( itemKey: key + ".OpenLinkAction", label: $"Open mod link ({uri.Host})", path: null, groupingParameters: null ); - modOpenLinkAction.InitAction(delegate { - Userspace.UserspaceWorld.RunSynchronously(delegate { - Slot slot = Userspace.UserspaceWorld.AddSlot("Hyperlink"); - slot.PositionInFrontOfUser(float3.Backward); - slot.AttachComponent().Setup(uri, "Outgoing hyperlink"); - }); - }); + modOpenLinkAction.InitAction((action) => action.Target = OpenURI, uri); groupChildren.Add(modOpenLinkAction); } - if (mod.ModConfiguration is not null) - { + if (mod.ModConfiguration is not null) { DataFeedCategory modConfigurationCategory = new DataFeedCategory(); modConfigurationCategory.InitBase( itemKey: key + ".ConfigurationCategory", label: "Mod configuration", - path: new string[] {key, "Configuration"}, + path: [key, "Configuration"], groupingParameters: null ); + modConfigurationCategory.SetOverrideSubpath(standalone ? ["Configuration"] : [key, "Configuration"]); groupChildren.Add(modConfigurationCategory); } modFeedGroup.InitBase( itemKey: key + ".Group", - label: mod.Name, + label: standalone ? "Mod info" : mod.Name, path: null, groupingParameters: null, subitems: groupChildren @@ -147,4 +223,12 @@ private static DataFeedGroup GenerateModFeedGroup(ResoniteModBase mod) { return modFeedGroup; } + + // private static DataFeedGroup GenerateModLogFeed(ResoniteModBase mod, int last = -1) { + + // } + + // private static DataFeedGroup GenerateModExceptionFeed(ResoniteModBase mod, int last = -1) { + + // } } diff --git a/ResoniteModLoader/ResoniteModLoader.csproj b/ResoniteModLoader/ResoniteModLoader.csproj index 69d6372..a5c349b 100644 --- a/ResoniteModLoader/ResoniteModLoader.csproj +++ b/ResoniteModLoader/ResoniteModLoader.csproj @@ -5,7 +5,7 @@ false net472 512 - 10.0 + 12 enable true True From 87d185903327dd9ca9bc97c968979346e5cb8e49 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 19 Jul 2024 12:38:15 -0500 Subject: [PATCH 06/25] Prepare log querying and refactor with new FeedBuilder utility class --- ResoniteModLoader/Logger.cs | 26 +-- ResoniteModLoader/ModConfigurationDataFeed.cs | 200 +++++++---------- ResoniteModLoader/Properties/AssemblyInfo.cs | 2 +- ResoniteModLoader/ResoniteModLoader.csproj | 4 + ResoniteModLoader/Utility/FeedBuilder.cs | 203 ++++++++++++++++++ 5 files changed, 298 insertions(+), 137 deletions(-) create mode 100644 ResoniteModLoader/Utility/FeedBuilder.cs diff --git a/ResoniteModLoader/Logger.cs b/ResoniteModLoader/Logger.cs index 051c7c2..b7caef6 100644 --- a/ResoniteModLoader/Logger.cs +++ b/ResoniteModLoader/Logger.cs @@ -8,6 +8,10 @@ internal sealed class Logger { // logged for null objects internal const string NULL_STRING = "null"; + internal enum LogType { DEBUG, INFO, WARN, ERROR } + + internal static readonly List<(ResoniteModBase?, LogType, string, StackTrace)> LogBuffer = new(); + internal static bool IsDebugEnabled() { return ModLoaderConfiguration.Get().Debug; } @@ -52,21 +56,24 @@ internal static void DebugListExternal(object[] messages) { 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))); - private static void LogInternal(string logTypePrefix, object message, string? source = null) { + private static void LogInternal(LogType logType, object message, string? source = null) { message ??= NULL_STRING; + string logTypePrefix = LogTypeTag(logType); if (source == null) { UniLog.Log($"{logTypePrefix}[ResoniteModLoader] {message}"); - } else { + } + else { UniLog.Log($"{logTypePrefix}[ResoniteModLoader/{source}] {message}"); } } - private static void LogListInternal(string logTypePrefix, object[] messages, string? source) { + private static void LogListInternal(LogType logType, object[] messages, string? source) { if (messages == null) { - LogInternal(logTypePrefix, NULL_STRING, source); - } else { + LogInternal(logType, NULL_STRING, source); + } + else { foreach (object element in messages) { - LogInternal(logTypePrefix, element.ToString(), source); + LogInternal(logType, element.ToString(), source); } } } @@ -76,10 +83,5 @@ private static void LogListInternal(string logTypePrefix, object[] messages, str return Util.ExecutingMod(stackTrace)?.Name; } - 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 string LogTypeTag(LogType logType) => $"[{Enum.GetName(typeof(LogType), logType)}]"; } diff --git a/ResoniteModLoader/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index aacf2da..a55a623 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -17,64 +17,60 @@ public class ModConfigurationDataFeed : Component, IDataFeedComponent, IDataFeed public override bool UserspaceOnly => true; 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. Default: False. + /// + public readonly Sync IncludeInternalConfigItems; + + /// + /// Enable or disable the use of custom configuration feeds. Default: True. + /// + public readonly Sync UseModDefinedEnumerate; +#pragma warning restore CS8618, CA1051 +#pragma warning disable CS1591 + protected override void OnAttach() { + base.OnAttach(); + IncludeInternalConfigItems.Value = false; + UseModDefinedEnumerate.Value = true; + } public async IAsyncEnumerable Enumerate(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData) { switch (path.Count) { case 0: { - DataFeedCategory modLoaderCategory = new DataFeedCategory(); - modLoaderCategory.InitBase( - itemKey: "ResoniteModLoader", - label: $"Open ResoniteModLoader category", - path: null, - groupingParameters: null - ); - yield return modLoaderCategory; + yield return FeedBuilder.Category("ResoniteModLoader", "Open ResoniteModLoader category"); } yield break; case 1: { if (path[0] != "ResoniteModLoader") yield break; - DataFeedLabel modLoaderVersion = new DataFeedLabel(); - modLoaderVersion.InitBase( - itemKey: "ResoniteModLoder.Version", - label: $"ResoniteModLoader version {ModLoader.VERSION}", - path: null, - groupingParameters: null - ); - yield return modLoaderVersion; - - DataFeedIndicator modLoaderLoadedModCount = new DataFeedIndicator(); // Todo: Make DataFeedIndicator template - modLoaderLoadedModCount.InitBase( - itemKey: "ResoniteModLoder.LoadedModCount", - label: "Loaded mods:", - path: null, - groupingParameters: null - ); - modLoaderLoadedModCount.InitSetupValue((count) => count.Value = ModLoader.Mods().Count().ToString()); - yield return modLoaderLoadedModCount; + yield return FeedBuilder.Label("ResoniteModLoder.Version", $"ResoniteModLoader version {ModLoader.VERSION}"); + yield return FeedBuilder.StringIndicator("ResoniteModLoder.LoadedModCount", "Loaded mods:", ModLoader.Mods().Count()); + List groupChildren = Pool.BorrowList(); foreach (ResoniteModBase mod in ModLoader.Mods()) - if (string.IsNullOrEmpty(searchPhrase) || mod.Name.ToLowerInvariant().Contains(searchPhrase.ToLowerInvariant())) - yield return GenerateModInfoGroup(mod); + if (string.IsNullOrEmpty(searchPhrase) || mod.Name.IndexOf(searchPhrase, StringComparison.InvariantCultureIgnoreCase) >= 0) + yield return GenerateModInfoGroup(mod, false, groupChildren); + Pool.Return(ref groupChildren); } yield break; case 2: { - if (path[0] != "ResoniteModLoader") yield break; - string key = path[1]; - ResoniteModBase mod = ModFromKey(key); - yield return GenerateModInfoGroup(mod, true); + if (path[0] != "ResoniteModLoader" || !TryModFromKey(path[1], out var mod)) yield break; + + List groupChildren = Pool.BorrowList(); + yield return GenerateModInfoGroup(mod, true, groupChildren); + Pool.Return(ref groupChildren); // GenerateModLogFeed // GenerateModExceptionFeed } yield break; case 3: { - if (path[0] != "ResoniteModLoader") yield break; - string key = path[1]; - ResoniteModBase mod = ModFromKey(key); - switch (path[2].ToLowerInvariant()) { + if (path[0] != "ResoniteModLoader" || !TryModFromKey(path[1], out var mod)) yield break; + switch (path[2].ToLower()) { case "configuration": { } @@ -98,7 +94,10 @@ public void ListenToUpdates(IReadOnlyList path, IReadOnlyList gr } public LocaleString PathSegmentName(string segment, int depth) { - return $"{segment} ({depth})"; + return depth switch { + 2 => ModFromKey(segment)?.Name ?? "INVALID", + _ => segment + }; } public object RegisterViewData() { @@ -114,11 +113,38 @@ 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. + /// + /// public static string KeyFromMod(ResoniteModBase mod) => mod.ModAssembly!.Sha256; + /// + /// 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. + /// public 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. + public static bool TryModFromKey(string key, out ResoniteModBase mod) { + mod = ModFromKey(key)!; + return mod is not null; + } + + /// + /// Spawns the prompt for a user to open a hyperlink. + /// + /// The URI that the user will be prompted to open. [SyncMethod(typeof(Action), [])] public static void OpenURI(Uri uri) { Userspace.UserspaceWorld.RunSynchronously(delegate { @@ -128,100 +154,26 @@ public static void OpenURI(Uri uri) { }); } - private static DataFeedGroup GenerateModInfoGroup(ResoniteModBase mod, bool standalone = false) { - DataFeedGroup modFeedGroup = new DataFeedGroup(); - List groupChildren = new List(); // Could this be a pool list instead? + private static DataFeedGroup GenerateModInfoGroup(ResoniteModBase mod, bool standalone = false, List groupChildren = null!) { + DataFeedGroup modFeedGroup = new(); + groupChildren = groupChildren ?? new(); + groupChildren.Clear(); string key = KeyFromMod(mod); - if (standalone) { - DataFeedIndicator modNameIndicator = new DataFeedIndicator(); - modNameIndicator.InitBase( - itemKey: key + ".Name", - label: "Name", - path: null, - groupingParameters: null - ); - modNameIndicator.InitSetupValue((str) => str.Value = mod.Name); - groupChildren.Add(modNameIndicator); - } - - DataFeedIndicator modAuthorIndicator = new DataFeedIndicator(); - modAuthorIndicator.InitBase( - itemKey: key + ".Author", - label: "Author", - path: null, - groupingParameters: null - ); - modAuthorIndicator.InitSetupValue((str) => str.Value = mod.Author); - groupChildren.Add(modAuthorIndicator); - - DataFeedIndicator modVersionIndicator = new DataFeedIndicator(); - modVersionIndicator.InitBase( - itemKey: key + ".Version", - label: "Version", - path: null, - groupingParameters: null - ); - modVersionIndicator.InitSetupValue((str) => str.Value = mod.Version); - groupChildren.Add(modVersionIndicator); + 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) { - DataFeedIndicator modAssemblyFileIndicator = new DataFeedIndicator(); - modAssemblyFileIndicator.InitBase( - itemKey: key + ".AssemblyFile", - label: "Assembly file", - path: null, - groupingParameters: null - ); - modAssemblyFileIndicator.InitSetupValue((str) => str.Value = Path.GetFileName(mod.ModAssembly!.File)); - groupChildren.Add(modAssemblyFileIndicator); - - DataFeedIndicator modAssemblyHashIndicator = new DataFeedIndicator(); - modAssemblyHashIndicator.InitBase( - itemKey: key + ".AssemblyHash", - label: "Assembly hash", - path: null, - groupingParameters: null - ); - modAssemblyHashIndicator.InitSetupValue((str) => str.Value = mod.ModAssembly!.Sha256); - groupChildren.Add(modAssemblyHashIndicator); - + groupChildren.Add(FeedBuilder.Indicator(key + ".AssemblyFile", "Assembly file", Path.GetFileName(mod.ModAssembly!.File))); + groupChildren.Add(FeedBuilder.Indicator(key + ".AssemblyHash", "Assembly hash", mod.ModAssembly!.Sha256)); // TODO: Add initialization time recording } - if (Uri.TryCreate(mod.Link, UriKind.Absolute, out var uri)) { - DataFeedValueAction modOpenLinkAction = new DataFeedValueAction(); - modOpenLinkAction.InitBase( - itemKey: key + ".OpenLinkAction", - label: $"Open mod link ({uri.Host})", - path: null, - groupingParameters: null - ); - modOpenLinkAction.InitAction((action) => action.Target = OpenURI, uri); - groupChildren.Add(modOpenLinkAction); - } - - if (mod.ModConfiguration is not null) { - DataFeedCategory modConfigurationCategory = new DataFeedCategory(); - modConfigurationCategory.InitBase( - itemKey: key + ".ConfigurationCategory", - label: "Mod configuration", - path: [key, "Configuration"], - groupingParameters: null - ); - modConfigurationCategory.SetOverrideSubpath(standalone ? ["Configuration"] : [key, "Configuration"]); - groupChildren.Add(modConfigurationCategory); - } - - modFeedGroup.InitBase( - itemKey: key + ".Group", - label: standalone ? "Mod info" : mod.Name, - path: null, - groupingParameters: null, - subitems: groupChildren - ); + 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"])); - return modFeedGroup; + return FeedBuilder.Group(key + ".Group", standalone ? "Mod info" : mod.Name, groupChildren); } // private static DataFeedGroup GenerateModLogFeed(ResoniteModBase mod, int last = -1) { diff --git a/ResoniteModLoader/Properties/AssemblyInfo.cs b/ResoniteModLoader/Properties/AssemblyInfo.cs index 5e5ac30..a0976a7 100644 --- a/ResoniteModLoader/Properties/AssemblyInfo.cs +++ b/ResoniteModLoader/Properties/AssemblyInfo.cs @@ -15,7 +15,7 @@ // 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.UserspaceCore)] diff --git a/ResoniteModLoader/ResoniteModLoader.csproj b/ResoniteModLoader/ResoniteModLoader.csproj index a5c349b..fcf97a8 100644 --- a/ResoniteModLoader/ResoniteModLoader.csproj +++ b/ResoniteModLoader/ResoniteModLoader.csproj @@ -31,6 +31,10 @@ $(ResonitePath)Resonite_Data\Managed\Elements.Core.dll False + + $(ResonitePath)Resonite_Data\Managed\Elements.Quantity.dll + False + $(ResonitePath)Resonite_Data\Managed\FrooxEngine.dll False diff --git a/ResoniteModLoader/Utility/FeedBuilder.cs b/ResoniteModLoader/Utility/FeedBuilder.cs new file mode 100644 index 0000000..a66ee98 --- /dev/null +++ b/ResoniteModLoader/Utility/FeedBuilder.cs @@ -0,0 +1,203 @@ +using Elements.Core; +using Elements.Quantity; +using FrooxEngine; + +namespace ResoniteModLoader; + +public sealed class FeedBuilder { + 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 { + T item = Activator.CreateInstance(); + item.InitBase(itemKey, path, groupingParameters, label, icon, setupVisible, setupEnabled, subitems, customEntity); + return item; + } + + // CONFLICT AA + 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 AB + 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> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ValueAction(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitAction(setupAction, value).ChainInitHighlight(setupHighlight); + + public static DataFeedSelection Selection(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 DataFeedLabel Label(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 DataFeedLabel Label(string itemKey, LocaleString label, colorX color, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Label(itemKey, $"{label}", path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedLabel Label(string itemKey, LocaleString label, color color, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Label(itemKey, $"{label}", path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedIndicator Indicator(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 DataFeedIndicator Indicator(string itemKey, LocaleString label, Action> setup, string format = null, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Indicator(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup, format); + + public static DataFeedIndicator Indicator(string itemKey, LocaleString label, T value, string format = null, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Indicator(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue((field) => field.Value = value, format); + public static DataFeedIndicator StringIndicator(string itemKey, LocaleString label, object value, string format = null, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Indicator(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue((field) => field.Value = value.ToString(), format); + +} + +public static class DataFeedItemChaining { + public static DataFeedItem ChainInitBase(this DataFeedItem item, string itemKey, IReadOnlyList path, IReadOnlyList groupingParameters, LocaleString label, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) { + item.InitBase(itemKey, path, groupingParameters, label, icon, setupVisible, setupEnabled, subitems, customEntity); + return item; + } + + public static DataFeedItem ChainInitBase(this DataFeedItem item, string itemKey, IReadOnlyList path, IReadOnlyList groupingParameters, LocaleString label, LocaleString description, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) { + item.InitBase(itemKey, path, groupingParameters, label, description, icon, setupVisible, setupEnabled, subitems, customEntity); + return item; + } + + public static DataFeedItem ChainInitVisible(this DataFeedItem item, Action> setupVisible) { + item.InitVisible(setupVisible); + return item; + } + + public static DataFeedItem ChainInitEnabled(this DataFeedItem item, Action> setupEnabled) { + item.InitEnabled(setupEnabled); + return item; + } + + public static DataFeedItem ChainInitDescription(this DataFeedItem item, LocaleString description) { + item.InitDescription(description); + return item; + } + + public static DataFeedItem ChainInitSorting(this DataFeedItem item, long order) { + item.InitSorting(order); + return item; + } + + public static DataFeedItem ChainInitSorting(this DataFeedItem item, Func orderGetter) { + item.InitSorting(orderGetter); + return item; + } + + public static DataFeedCategory ChainSetOverrideSubpath(this DataFeedCategory item, params string[] subpath) { + item.SetOverrideSubpath(subpath); + return item; + } + + public static DataFeedEntity ChainInitEntity(this DataFeedEntity item, E entity) { + item.InitEntity(entity); + return item; + } + + public static DataFeedValueElement ChainInitSetupValue(this DataFeedValueElement item, Action> setup) { + item.InitSetupValue(setup); + return item; + } + + public static DataFeedValueElement ChainInitFormatting(this DataFeedValueElement item, Action> setupFormatting) { + item.InitFormatting(setupFormatting); + return item; + } + + public static DataFeedValueElement ChainInitFormatting(this DataFeedValueElement item, string formatting) { + item.InitFormatting(formatting); + return item; + } + + public static DataFeedOrderedItem ChainInitSetup(this DataFeedOrderedItem item, Action> orderValue, Action> setupIsFirst, Action> setupIsLast, Action> setupMoveUp, Action> setupMoveDown, Action> setupMakeFirst, Action> setupMakeLast, LocaleString moveUpLabel = default, LocaleString moveDownLabel = default, LocaleString makeFirstLabel = default, LocaleString makeLastLabel = default) where T : IComparable { + item.InitSetup(orderValue, setupIsFirst, setupIsLast, setupMoveUp, setupMoveDown, setupMakeFirst, setupMakeLast, moveUpLabel, moveDownLabel, makeFirstLabel, makeLastLabel); + return item; + } + + public static DataFeedClampedValueField ChainInitSetup(this DataFeedClampedValueField item, Action> value, Action> min, Action> max) { + item.InitSetup(value, min, max); + return item; + } + public static DataFeedClampedValueField ChainInitSetup(this DataFeedClampedValueField item, Action> value, T min, T max) { + item.InitSetup(value, min, max); + return item; + } + + public static DataFeedQuantityField ChainInitUnitConfiguration(this DataFeedQuantityField item, UnitConfiguration defaultConfig, UnitConfiguration imperialConfig = null) where Q : unmanaged, IQuantity { + item.InitUnitConfiguration(defaultConfig, imperialConfig); + return item; + } + + public static DataFeedSlider ChainInitSlider(this DataFeedSlider item, Action> setupReferenceValue) { + item.InitSlider(setupReferenceValue); + return item; + } + + public static DataFeedAction ChainInitAction(this DataFeedAction item, Action> setupAction) { + item.InitAction(setupAction); + return item; + } + + public static DataFeedAction ChainInitHighlight(this DataFeedAction item, Action> setupHighlight) { + item.InitHighlight(setupHighlight); + return item; + } + + public static DataFeedValueAction ChainInitAction(this DataFeedValueAction item, Action>> setupAction, Action> setupValue) { + item.InitAction(setupAction, setupValue); + return item; + } + + public static DataFeedValueAction ChainInitAction(this DataFeedValueAction item, Action>> setupAction, T value) { + item.InitAction(setupAction, value); + return item; + } + + public static DataFeedValueAction ChainInitHighlight(this DataFeedValueAction item, Action> setupHighlight) { + item.InitHighlight(setupHighlight); + return item; + } + + public static DataFeedIndicator ChainInitSetupValue(this DataFeedIndicator item, Action> setup, string format = null) { + item.InitSetupValue(setup, format); + return item; + } +} From f8a336b67877b602ef5572fcb38e19ebe2ab6f80 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 19 Jul 2024 15:00:08 -0500 Subject: [PATCH 07/25] Add value fields and descriptions to FeedBuilder --- ResoniteModLoader/Utility/FeedBuilder.cs | 346 +++++++++++++++++++++-- 1 file changed, 324 insertions(+), 22 deletions(-) diff --git a/ResoniteModLoader/Utility/FeedBuilder.cs b/ResoniteModLoader/Utility/FeedBuilder.cs index a66ee98..4884994 100644 --- a/ResoniteModLoader/Utility/FeedBuilder.cs +++ b/ResoniteModLoader/Utility/FeedBuilder.cs @@ -4,18 +4,15 @@ namespace ResoniteModLoader; -public sealed class FeedBuilder { - 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 { - T item = Activator.CreateInstance(); - item.InitBase(itemKey, path, groupingParameters, label, icon, setupVisible, setupEnabled, subitems, customEntity); - return item; - } +public static class FeedBuilder { + 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 AA + // 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 AB + // 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); @@ -68,10 +65,10 @@ public static DataFeedLabel Label(string itemKey, LocaleString label, IReadOnlyL => Item(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); public static DataFeedLabel Label(string itemKey, LocaleString label, colorX color, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) - => Label(itemKey, $"{label}", path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + => Label(itemKey, $"{label}", path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); public static DataFeedLabel Label(string itemKey, LocaleString label, color color, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) - => Label(itemKey, $"{label}", path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + => Label(itemKey, $"{label}", path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); public static DataFeedIndicator Indicator(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); @@ -84,40 +81,345 @@ public static DataFeedIndicator Indicator(string itemKey, LocaleString lab public static DataFeedIndicator StringIndicator(string itemKey, LocaleString label, object value, string format = null, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) => Indicator(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue((field) => field.Value = value.ToString(), format); + public static DataFeedToggle Toggle(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 DataFeedToggle Toggle(string itemKey, LocaleString label, Action> setup, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Toggle(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup); + + public static DataFeedOrderedItem OrderedItem(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 : IComparable + => Item>(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedOrderedItem OrderedItem(string itemKey, LocaleString label, Func orderGetter, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where T : IComparable + => OrderedItem(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSorting(orderGetter); + + public static DataFeedOrderedItem OrderedItem(string itemKey, LocaleString label, long order, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where T : IComparable + => OrderedItem(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSorting(order); + + public static DataFeedValueField ValueField(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 DataFeedValueField ValueField(string itemKey, LocaleString label, Action> setup, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ValueField(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup); + + public static DataFeedValueField ValueField(string itemKey, LocaleString label, Action> setup, Action> setupFormatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ValueField(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedValueField ValueField(string itemKey, LocaleString label, Action> setup, string formatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ValueField(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup).ChainInitFormatting, T>(formatting); + + public static DataFeedEnum Enum(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 E : Enum + => Item>(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedEnum Enum(string itemKey, LocaleString label, Action> setup, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where E : Enum + => Enum(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup); + + public static DataFeedEnum Enum(string itemKey, LocaleString label, Action> setup, Action> setupFormatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where E : Enum + => Enum(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup).ChainInitFormatting, E>(setupFormatting); + + public static DataFeedEnum Enum(string itemKey, LocaleString label, Action> setup, string formatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where E : Enum + => Enum(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup).ChainInitFormatting, E>(formatting); + + public static DataFeedClampedValueField ClampedValueField(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 DataFeedClampedValueField ClampedValueField(string itemKey, LocaleString label, Action> value, Action> min, Action> max, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ClampedValueField(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max); + + public static DataFeedClampedValueField ClampedValueField(string itemKey, LocaleString label, Action> value, Action> min, Action> max, Action> setupFormatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ClampedValueField(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedClampedValueField ClampedValueField(string itemKey, LocaleString label, Action> value, Action> min, Action> max, string formatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ClampedValueField(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(formatting); + + public static DataFeedClampedValueField ClampedValueField(string itemKey, LocaleString label, Action> value, T min, T max, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ClampedValueField(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max); + + public static DataFeedClampedValueField ClampedValueField(string itemKey, LocaleString label, Action> value, T min, T max, Action> setupFormatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ClampedValueField(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedClampedValueField ClampedValueField(string itemKey, LocaleString label, Action> value, T min, T max, string formatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ClampedValueField(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(formatting); + + public static DataFeedQuantityField QuantityField(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 Q : unmanaged, IQuantity + => Item>(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedQuantityField QuantityField(string itemKey, LocaleString label, Action> value, Action> min, Action> max, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where Q : unmanaged, IQuantity + => QuantityField(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max); + + public static DataFeedQuantityField QuantityField(string itemKey, LocaleString label, Action> value, T min, T max, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where Q : unmanaged, IQuantity + => QuantityField(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max); + + public static DataFeedQuantityField QuantityField(string itemKey, LocaleString label, Action> value, Action> min, Action> max, UnitConfiguration defaultConfig, UnitConfiguration imperialConfig = null, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where Q : unmanaged, IQuantity + => QuantityField(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitUnitConfiguration(defaultConfig, imperialConfig); + + public static DataFeedQuantityField QuantityField(string itemKey, LocaleString label, Action> value, T min, T max, UnitConfiguration defaultConfig, UnitConfiguration imperialConfig = null, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where Q : unmanaged, IQuantity + => QuantityField(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitUnitConfiguration(defaultConfig, imperialConfig); + + public static DataFeedSlider Slider(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 DataFeedSlider Slider(string itemKey, LocaleString label, Action> value, Action> min, Action> max, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, Action> value, Action> min, Action> max, Action> setupFormatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, Action> value, Action> min, Action> max, string formatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(formatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, Action> value, T min, T max, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, Action> value, T min, T max, Action> setupFormatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, Action> value, T min, T max, string formatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(formatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, Action> value, Action> min, Action> max, Action> setupReferenceValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitSlider(setupReferenceValue); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, Action> value, Action> min, Action> max, Action> setupFormatting, Action> setupReferenceValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitSlider(setupReferenceValue).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, Action> value, Action> min, Action> max, string formatting, Action> setupReferenceValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitSlider(setupReferenceValue).ChainInitFormatting, T>(formatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, Action> value, T min, T max, Action> setupReferenceValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitSlider(setupReferenceValue); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, Action> value, T min, T max, Action> setupFormatting, Action> setupReferenceValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitSlider(setupReferenceValue).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, Action> value, T min, T max, string formatting, Action> setupReferenceValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitSlider(setupReferenceValue).ChainInitFormatting, T>(formatting); + + // With description + + public static T Item(string itemKey, LocaleString label, LocaleString description, 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, description, icon, setupVisible, setupEnabled, subitems, customEntity); + + // CONFLICT AB + public static DataFeedCategory Category(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Item(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + // CONFLICT BA + public static DataFeedCategory Category(string itemKey, LocaleString label, LocaleString description, 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, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainSetOverrideSubpath(subpath); + + public static DataFeedGroup Group(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Item(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedGroup Group(string itemKey, LocaleString label, LocaleString description, IReadOnlyList subitems, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, object customEntity = null) + => Group(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedGrid Grid(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Item(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedGrid Grid(string itemKey, LocaleString label, LocaleString description, IReadOnlyList subitems, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, object customEntity = null) + => Grid(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedEntity Entity(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Item>(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedEntity Entity(string itemKey, LocaleString label, LocaleString description, 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, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitEntity(entity); + + public static DataFeedAction Action(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Item(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedAction Action(string itemKey, LocaleString label, LocaleString description, 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, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitAction(setupAction); + + public static DataFeedAction Action(string itemKey, LocaleString label, LocaleString description, 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, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitAction(setupAction).ChainInitHighlight(setupHighlight); + + public static DataFeedValueAction ValueAction(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Item>(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedValueAction ValueAction(string itemKey, LocaleString label, LocaleString description, 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, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitAction(setupAction, setupValue); + + public static DataFeedValueAction ValueAction(string itemKey, LocaleString label, LocaleString description, 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, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitAction(setupAction, value); + + public static DataFeedValueAction ValueAction(string itemKey, LocaleString label, LocaleString description, 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, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitAction(setupAction, setupValue).ChainInitHighlight(setupHighlight); + + public static DataFeedValueAction ValueAction(string itemKey, LocaleString label, LocaleString description, Action>> setupAction, T value, 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, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitAction(setupAction, value).ChainInitHighlight(setupHighlight); + + public static DataFeedSelection Selection(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Item(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedLabel Label(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Item(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedLabel Label(string itemKey, LocaleString label, LocaleString description, colorX color, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Label(itemKey, $"{label}", path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedLabel Label(string itemKey, LocaleString label, LocaleString description, color color, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Label(itemKey, $"{label}", path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedIndicator Indicator(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Item>(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedIndicator Indicator(string itemKey, LocaleString label, LocaleString description, Action> setup, string format = null, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Indicator(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup, format); + + public static DataFeedIndicator Indicator(string itemKey, LocaleString label, LocaleString description, T value, string format = null, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Indicator(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue((field) => field.Value = value, format); + public static DataFeedIndicator StringIndicator(string itemKey, LocaleString label, LocaleString description, object value, string format = null, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Indicator(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue((field) => field.Value = value.ToString(), format); + + public static DataFeedToggle Toggle(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Item(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedToggle Toggle(string itemKey, LocaleString label, LocaleString description, Action> setup, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Toggle(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup); + + public static DataFeedOrderedItem OrderedItem(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where T : IComparable + => Item>(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedOrderedItem OrderedItem(string itemKey, LocaleString label, LocaleString description, Func orderGetter, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where T : IComparable + => OrderedItem(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSorting(orderGetter); + + public static DataFeedOrderedItem OrderedItem(string itemKey, LocaleString label, LocaleString description, long order, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where T : IComparable + => OrderedItem(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSorting(order); + + public static DataFeedValueField ValueField(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Item>(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedValueField ValueField(string itemKey, LocaleString label, LocaleString description, Action> setup, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ValueField(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup); + + public static DataFeedValueField ValueField(string itemKey, LocaleString label, LocaleString description, Action> setup, Action> setupFormatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ValueField(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedValueField ValueField(string itemKey, LocaleString label, LocaleString description, Action> setup, string formatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ValueField(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup).ChainInitFormatting, T>(formatting); + + public static DataFeedEnum Enum(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where E : Enum + => Item>(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedEnum Enum(string itemKey, LocaleString label, LocaleString description, Action> setup, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where E : Enum + => Enum(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup); + + public static DataFeedEnum Enum(string itemKey, LocaleString label, LocaleString description, Action> setup, Action> setupFormatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where E : Enum + => Enum(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup).ChainInitFormatting, E>(setupFormatting); + + public static DataFeedEnum Enum(string itemKey, LocaleString label, LocaleString description, Action> setup, string formatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where E : Enum + => Enum(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetupValue(setup).ChainInitFormatting, E>(formatting); + + public static DataFeedClampedValueField ClampedValueField(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Item>(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedClampedValueField ClampedValueField(string itemKey, LocaleString label, LocaleString description, Action> value, Action> min, Action> max, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ClampedValueField(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max); + + public static DataFeedClampedValueField ClampedValueField(string itemKey, LocaleString label, LocaleString description, Action> value, Action> min, Action> max, Action> setupFormatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ClampedValueField(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedClampedValueField ClampedValueField(string itemKey, LocaleString label, LocaleString description, Action> value, Action> min, Action> max, string formatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ClampedValueField(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(formatting); + + public static DataFeedClampedValueField ClampedValueField(string itemKey, LocaleString label, LocaleString description, Action> value, T min, T max, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ClampedValueField(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max); + + public static DataFeedClampedValueField ClampedValueField(string itemKey, LocaleString label, LocaleString description, Action> value, T min, T max, Action> setupFormatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ClampedValueField(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedClampedValueField ClampedValueField(string itemKey, LocaleString label, LocaleString description, Action> value, T min, T max, string formatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => ClampedValueField(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(formatting); + + public static DataFeedQuantityField QuantityField(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where Q : unmanaged, IQuantity + => Item>(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedQuantityField QuantityField(string itemKey, LocaleString label, LocaleString description, Action> value, Action> min, Action> max, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where Q : unmanaged, IQuantity + => QuantityField(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max); + + public static DataFeedQuantityField QuantityField(string itemKey, LocaleString label, LocaleString description, Action> value, T min, T max, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where Q : unmanaged, IQuantity + => QuantityField(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max); + + public static DataFeedQuantityField QuantityField(string itemKey, LocaleString label, LocaleString description, Action> value, Action> min, Action> max, UnitConfiguration defaultConfig, UnitConfiguration imperialConfig = null, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where Q : unmanaged, IQuantity + => QuantityField(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitUnitConfiguration(defaultConfig, imperialConfig); + + public static DataFeedQuantityField QuantityField(string itemKey, LocaleString label, LocaleString description, Action> value, T min, T max, UnitConfiguration defaultConfig, UnitConfiguration imperialConfig = null, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where Q : unmanaged, IQuantity + => QuantityField(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitUnitConfiguration(defaultConfig, imperialConfig); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Item>(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, Action> value, Action> min, Action> max, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, Action> value, Action> min, Action> max, Action> setupFormatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, Action> value, Action> min, Action> max, string formatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(formatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, Action> value, T min, T max, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, Action> value, T min, T max, Action> setupFormatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, Action> value, T min, T max, string formatting, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitFormatting, T>(formatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, Action> value, Action> min, Action> max, Action> setupReferenceValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitSlider(setupReferenceValue); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, Action> value, Action> min, Action> max, Action> setupFormatting, Action> setupReferenceValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitSlider(setupReferenceValue).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, Action> value, Action> min, Action> max, string formatting, Action> setupReferenceValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitSlider(setupReferenceValue).ChainInitFormatting, T>(formatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, Action> value, T min, T max, Action> setupReferenceValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitSlider(setupReferenceValue); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, Action> value, T min, T max, Action> setupFormatting, Action> setupReferenceValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitSlider(setupReferenceValue).ChainInitFormatting, T>(setupFormatting); + + public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, Action> value, T min, T max, string formatting, Action> setupReferenceValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) + => Slider(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitSlider(setupReferenceValue).ChainInitFormatting, T>(formatting); } public static class DataFeedItemChaining { - public static DataFeedItem ChainInitBase(this DataFeedItem item, string itemKey, IReadOnlyList path, IReadOnlyList groupingParameters, LocaleString label, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) { + public static I ChainInitBase(this I item, string itemKey, IReadOnlyList path, IReadOnlyList groupingParameters, LocaleString label, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where I : DataFeedItem { item.InitBase(itemKey, path, groupingParameters, label, icon, setupVisible, setupEnabled, subitems, customEntity); return item; } - public static DataFeedItem ChainInitBase(this DataFeedItem item, string itemKey, IReadOnlyList path, IReadOnlyList groupingParameters, LocaleString label, LocaleString description, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) { + public static I ChainInitBase(this I item, string itemKey, IReadOnlyList path, IReadOnlyList groupingParameters, LocaleString label, LocaleString description, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where I : DataFeedItem { item.InitBase(itemKey, path, groupingParameters, label, description, icon, setupVisible, setupEnabled, subitems, customEntity); return item; } - public static DataFeedItem ChainInitVisible(this DataFeedItem item, Action> setupVisible) { + public static I ChainInitVisible(this I item, Action> setupVisible) where I : DataFeedItem { item.InitVisible(setupVisible); return item; } - public static DataFeedItem ChainInitEnabled(this DataFeedItem item, Action> setupEnabled) { + public static I ChainInitEnabled(this I item, Action> setupEnabled) where I : DataFeedItem { item.InitEnabled(setupEnabled); return item; } - public static DataFeedItem ChainInitDescription(this DataFeedItem item, LocaleString description) { + public static I ChainInitDescription(this I item, LocaleString description) where I : DataFeedItem { item.InitDescription(description); return item; } - public static DataFeedItem ChainInitSorting(this DataFeedItem item, long order) { + public static I ChainInitSorting(this I item, long order) where I : DataFeedItem { item.InitSorting(order); return item; } - public static DataFeedItem ChainInitSorting(this DataFeedItem item, Func orderGetter) { + public static I ChainInitSorting(this I item, Func orderGetter) where I : DataFeedItem { item.InitSorting(orderGetter); return item; } @@ -132,17 +434,17 @@ public static DataFeedEntity ChainInitEntity(this DataFeedEntity item, return item; } - public static DataFeedValueElement ChainInitSetupValue(this DataFeedValueElement item, Action> setup) { + public static I ChainInitSetupValue(this I item, Action> setup) where I : DataFeedValueElement { item.InitSetupValue(setup); return item; } - public static DataFeedValueElement ChainInitFormatting(this DataFeedValueElement item, Action> setupFormatting) { + public static I ChainInitFormatting(this I item, Action> setupFormatting) where I : DataFeedValueElement { item.InitFormatting(setupFormatting); return item; } - public static DataFeedValueElement ChainInitFormatting(this DataFeedValueElement item, string formatting) { + public static I ChainInitFormatting(this I item, string formatting) where I : DataFeedValueElement { item.InitFormatting(formatting); return item; } @@ -152,11 +454,11 @@ public static DataFeedOrderedItem ChainInitSetup(this DataFeedOrderedItem< return item; } - public static DataFeedClampedValueField ChainInitSetup(this DataFeedClampedValueField item, Action> value, Action> min, Action> max) { + public static I ChainInitSetup(this I item, Action> value, Action> min, Action> max) where I : DataFeedClampedValueField { item.InitSetup(value, min, max); return item; } - public static DataFeedClampedValueField ChainInitSetup(this DataFeedClampedValueField item, Action> value, T min, T max) { + public static I ChainInitSetup(this I item, Action> value, T min, T max) where I : DataFeedClampedValueField { item.InitSetup(value, min, max); return item; } From 2d250bc5a09d1e8855303a7e091d49058fd84012 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 21 Jul 2024 04:52:36 -0500 Subject: [PATCH 08/25] A bunch of progress --- ResoniteModLoader/ModConfigurationDataFeed.cs | 189 +++++++++++++----- .../ModConfigurationFeedBuilder.cs | 74 +++++++ .../ModConfigurationValueSync.cs | 44 ++++ ResoniteModLoader/ResoniteMod.cs | 8 + ResoniteModLoader/ResoniteModBase.cs | 18 ++ ResoniteModLoader/Utility/FeedBuilder.cs | 58 +++++- 6 files changed, 335 insertions(+), 56 deletions(-) create mode 100644 ResoniteModLoader/ModConfigurationFeedBuilder.cs create mode 100644 ResoniteModLoader/ModConfigurationValueSync.cs diff --git a/ResoniteModLoader/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index a55a623..c69466a 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -1,17 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using Elements.Core; +using Elements.Core; using FrooxEngine; -using SkyFrost.Base; +using System.Collections; namespace ResoniteModLoader; /// /// A custom data feed that can be used to show information about loaded mods, and alter their configuration. Path must start with "ResoniteModLoder" /// -[Category(["Userspace"])] +[Category(["ResoniteModLoder"])] public class ModConfigurationDataFeed : Component, IDataFeedComponent, IDataFeed, IWorldElement { #pragma warning disable CS1591 public override bool UserspaceOnly => true; @@ -20,23 +16,32 @@ public class ModConfigurationDataFeed : Component, IDataFeedComponent, IDataFeed #pragma warning restore CS1591 #pragma warning disable CS8618, CA1051 // FrooxEngine weaver will take care of these /// - /// Show mod configuration keys marked as internal. Default: False. + /// Show mod configuration keys marked as internal. /// public readonly Sync IncludeInternalConfigItems; /// - /// Enable or disable the use of custom configuration feeds. Default: True. + /// Enable or disable the use of custom configuration feeds. /// - public readonly Sync UseModDefinedEnumerate; + public readonly Sync IgnoreModDefinedEnumerate; + + /// + /// Set to true if this feed is being used in a RootCategoryView. + /// + public readonly Sync UsingRootCategoryView; #pragma warning restore CS8618, CA1051 #pragma warning disable CS1591 - protected override void OnAttach() { - base.OnAttach(); - IncludeInternalConfigItems.Value = false; - UseModDefinedEnumerate.Value = true; - } - public async IAsyncEnumerable Enumerate(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData) { + if (UsingRootCategoryView.Value) { + if (path.Count == 0) { + foreach (ResoniteModBase mod in ModLoader.Mods()) + yield return FeedBuilder.Category(KeyFromMod(mod), mod.Name); + yield break; + } + + path = path.Prepend("ResoniteModLoader").ToList().AsReadOnly(); + } + switch (path.Count) { case 0: { yield return FeedBuilder.Category("ResoniteModLoader", "Open ResoniteModLoader category"); @@ -46,25 +51,33 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path case 1: { if (path[0] != "ResoniteModLoader") yield break; - yield return FeedBuilder.Label("ResoniteModLoder.Version", $"ResoniteModLoader version {ModLoader.VERSION}"); - yield return FeedBuilder.StringIndicator("ResoniteModLoder.LoadedModCount", "Loaded mods:", ModLoader.Mods().Count()); + if (string.IsNullOrEmpty(searchPhrase)) { + yield return FeedBuilder.Group("ResoniteModLoder", "RML", [ + FeedBuilder.Label("ResoniteModLoder.Version", $"ResoniteModLoader version {ModLoader.VERSION}"), + FeedBuilder.StringIndicator("ResoniteModLoder.LoadedModCount", "Loaded mods:", ModLoader.Mods().Count()) + ]); + List modCategories = new(); + foreach (ResoniteModBase mod in ModLoader.Mods()) + modCategories.Add(FeedBuilder.Category(KeyFromMod(mod), mod.Name)); - List groupChildren = Pool.BorrowList(); - foreach (ResoniteModBase mod in ModLoader.Mods()) - if (string.IsNullOrEmpty(searchPhrase) || mod.Name.IndexOf(searchPhrase, StringComparison.InvariantCultureIgnoreCase) >= 0) - yield return GenerateModInfoGroup(mod, false, groupChildren); - Pool.Return(ref groupChildren); + yield return FeedBuilder.Grid("Mods", "Mods", modCategories); + } + else { + // yield return FeedBuilder.Label("SearchResults", "Search results"); + foreach (ResoniteModBase mod in ModLoader.Mods().Where((mod) => mod.Name.IndexOf(searchPhrase, StringComparison.InvariantCultureIgnoreCase) >= 0)) + yield return mod.GenerateModInfoGroup(); + } } yield break; case 2: { if (path[0] != "ResoniteModLoader" || !TryModFromKey(path[1], out var mod)) yield break; - - List groupChildren = Pool.BorrowList(); - yield return GenerateModInfoGroup(mod, true, groupChildren); - Pool.Return(ref groupChildren); - // GenerateModLogFeed - // GenerateModExceptionFeed + yield return mod.GenerateModInfoGroup(true); + string key = KeyFromMod(mod); + IReadOnlyList latestLogs = mod.GenerateModLogFeed(5).Append(FeedBuilder.Category("Logs", "View full log")).ToList().AsReadOnly(); + yield return FeedBuilder.Group(key + ".Logs", "Recent mod logs", latestLogs); + IReadOnlyList latestException = mod.GenerateModExceptionFeed(1).Append(FeedBuilder.Category("Exceptions", "View all exceptions")).ToList().AsReadOnly(); + yield return FeedBuilder.Group(key + ".Exceptions", "Latest mod exception", latestException); } yield break; @@ -72,17 +85,47 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path if (path[0] != "ResoniteModLoader" || !TryModFromKey(path[1], out var mod)) yield break; switch (path[2].ToLower()) { case "configuration": { - + if (IgnoreModDefinedEnumerate.Value) { + foreach (DataFeedItem item in mod.GenerateModConfigurationFeed(path.Skip(3).ToArray(), groupKeys, searchPhrase, viewData, IncludeInternalConfigItems.Value)) + yield return item; + } + else { + await foreach (DataFeedItem item in mod.BuildConfigurationFeed(path.Skip(3).ToArray(), groupKeys, searchPhrase, viewData, IncludeInternalConfigItems.Value)) + yield return item; + } } yield break; case "logs": { - + foreach (DataFeedLabel item in mod.GenerateModLogFeed()) + yield return item; } yield break; case "exceptions": { - + foreach (DataFeedLabel item in mod.GenerateModExceptionFeed()) + 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") { + if (IgnoreModDefinedEnumerate.Value) { + foreach (DataFeedItem item in mod.GenerateModConfigurationFeed(path.Skip(3).ToArray(), groupKeys, searchPhrase, viewData, IncludeInternalConfigItems.Value)) + yield return item; + } + else { + await foreach (DataFeedItem item in mod.BuildConfigurationFeed(path.Skip(3).ToArray(), groupKeys, searchPhrase, viewData, IncludeInternalConfigItems.Value)) + yield return item; + } + yield break; + } + else { + // Reserved for future use - mods defining their own subfeeds } } yield break; @@ -96,6 +139,7 @@ public void ListenToUpdates(IReadOnlyList path, IReadOnlyList gr public LocaleString PathSegmentName(string segment, int depth) { return depth switch { 2 => ModFromKey(segment)?.Name ?? "INVALID", + 3 => segment.Capitalize(), _ => segment }; } @@ -120,7 +164,7 @@ public void UnregisterViewData(object data) { /// A unique key representing the mod. /// /// - public static string KeyFromMod(ResoniteModBase mod) => mod.ModAssembly!.Sha256; + public static string KeyFromMod(ResoniteModBase mod) => Path.GetFileNameWithoutExtension(mod.ModAssembly!.File); /// /// Returns the mod that corresponds to a unique key. @@ -140,25 +184,19 @@ public static bool TryModFromKey(string key, out ResoniteModBase mod) { mod = ModFromKey(key)!; return mod is not null; } +} +public static class ModConfigurationDataFeedExtensions { /// - /// Spawns the prompt for a user to open a hyperlink. + /// Generates a DataFeedGroup that displays basic information about a mod. /// - /// The URI that the user will be prompted to open. - [SyncMethod(typeof(Action), [])] - public static void OpenURI(Uri uri) { - Userspace.UserspaceWorld.RunSynchronously(delegate { - Slot slot = Userspace.UserspaceWorld.AddSlot("Hyperlink"); - slot.PositionInFrontOfUser(float3.Backward); - slot.AttachComponent().Setup(uri, "Outgoing hyperlink"); - }); - } - - private static DataFeedGroup GenerateModInfoGroup(ResoniteModBase mod, bool standalone = false, List groupChildren = null!) { + /// The target mod + /// Set to true if this group will be displayed on its own page + /// + public static DataFeedGroup GenerateModInfoGroup(this ResoniteModBase mod, bool standalone = false) { DataFeedGroup modFeedGroup = new(); - groupChildren = groupChildren ?? new(); - groupChildren.Clear(); - string key = KeyFromMod(mod); + 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)); @@ -176,11 +214,58 @@ private static DataFeedGroup GenerateModInfoGroup(ResoniteModBase mod, bool stan return FeedBuilder.Group(key + ".Group", standalone ? "Mod info" : mod.Name, groupChildren); } - // private static DataFeedGroup GenerateModLogFeed(ResoniteModBase mod, int last = -1) { + public static IEnumerable GenerateModConfigurationFeed(this ResoniteModBase mod, IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false) { + 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; + } + + ModConfigurationFeedBuilder.CachedBuilders.TryGetValue(config, out ModConfigurationFeedBuilder builder); + builder = builder ?? new ModConfigurationFeedBuilder(config); + IEnumerable items; - // } + if (path.Any()) { + ModConfigurationKey key = config.ConfigurationItemDefinitions.First((config) => config.Name == path[0]); + if (!typeof(IEnumerable).IsAssignableFrom(key.ValueType())) yield break; + MethodInfo genericEnumerablePage = typeof(ModConfigurationFeedBuilder).GetMethod(nameof(ModConfigurationFeedBuilder.OrderedPage)).MakeGenericMethod(key.ValueType()); + items = (IEnumerable)genericEnumerablePage.Invoke(builder, [key]); + } + else { + items = builder.RootPage(searchPhrase, includeInternal); + } + foreach (DataFeedItem item in items) + yield return item; + } - // private static DataFeedGroup GenerateModExceptionFeed(ResoniteModBase mod, int last = -1) { + private static DataFeedItem AsFeedItem(this string text, int index, bool copyable = true) { + if (copyable) + return FeedBuilder.ValueAction(index.ToString(), text, (action) => action.Target = CopyText, text); + else + return FeedBuilder.Label(index.ToString(), text); + } + + public static IEnumerable GenerateModLogFeed(this ResoniteModBase mod, int last = -1, bool copyable = true) { + yield return "Not implemented".AsFeedItem(0, copyable); + } - // } + public static IEnumerable GenerateModExceptionFeed(this ResoniteModBase mod, int last = -1, bool copyable = true) { + yield return "Not implemented".AsFeedItem(0, copyable); + } + + /// + /// Spawns the prompt for a user to open a hyperlink. + /// + /// The URI that the user will be prompted to open. + [SyncMethod(typeof(Action), [])] + public static void OpenURI(Uri uri) { + Slot slot = Userspace.UserspaceWorld.AddSlot("Hyperlink"); + slot.PositionInFrontOfUser(float3.Backward); + slot.AttachComponent().Setup(uri, "Outgoing hyperlink"); + } + + [SyncMethod(typeof(Action), [])] + public 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..d43dcf4 --- /dev/null +++ b/ResoniteModLoader/ModConfigurationFeedBuilder.cs @@ -0,0 +1,74 @@ +using Elements.Core; +using FrooxEngine; +using HarmonyLib; +using System.Collections; + +namespace ResoniteModLoader; + +public class ModConfigurationFeedBuilder { + + private readonly ModConfiguration Config; + + private readonly Dictionary KeyFields = new(); + + public readonly static Dictionary CachedBuilders = new(); + + private static bool HasAutoRegisterAttribute(FieldInfo field) => field.GetCustomAttribute() is not null; + + private static bool HasRangeAttribute(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"); + } + + public ModConfigurationFeedBuilder(ModConfiguration config) { + Config = config; + IEnumerable autoConfigKeys = config.Owner.GetType().GetDeclaredFields().Where(HasAutoRegisterAttribute); + foreach (FieldInfo field in autoConfigKeys) { + ModConfigurationKey key = (ModConfigurationKey)field.GetValue(field.IsStatic ? null : config.Owner); + KeyFields[key] = field; + } + CachedBuilders[config] = this; + } + + public IEnumerable RootPage(string searchPhrase = "", bool includeInternal = false) { + 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); + } + } + + public IEnumerable> OrderedPage(ModConfigurationKey key) { + 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++); + } + + public DataFeedValueField GenerateDataFeedField(ModConfigurationKey key) { + AssertChildKey(key); + return FeedBuilder.ValueField(key.Name, key.Name, key.Description, (field) => field.SyncWithModConfiguration(Config, key)); + } + + public DataFeedItem GenerateDataFeedItem(ModConfigurationKey key) { + AssertChildKey(key); + Type valueType = key.ValueType(); + if (valueType == typeof(dummy)) + return FeedBuilder.Label(key.Name, key.Description ?? key.Name); + else if (valueType == typeof(bool)) + return FeedBuilder.Toggle(key.Name, key.Name, key.Description, (field) => field.SyncWithModConfiguration(Config, key)); + else if (valueType == typeof(float) && HasRangeAttribute(KeyFields[key], out RangeAttribute range)) + return FeedBuilder.Slider(key.Name, key.Name, key.Description, (field) => field.SyncWithModConfiguration(Config, key), range.Min, range.Max, range.TextFormat); + else if (valueType != typeof(string) && valueType != typeof(Uri) && typeof(IEnumerable).IsAssignableFrom(valueType)) + return FeedBuilder.Category(key.Name, key.Name, key.Description); + else + return (DataFeedItem)typeof(ModConfigurationFeedBuilder).GetMethod(nameof(GenerateDataFeedField)).MakeGenericMethod(key.ValueType()).Invoke(this, [key]); + } +} diff --git a/ResoniteModLoader/ModConfigurationValueSync.cs b/ResoniteModLoader/ModConfigurationValueSync.cs new file mode 100644 index 0000000..63f74c3 --- /dev/null +++ b/ResoniteModLoader/ModConfigurationValueSync.cs @@ -0,0 +1,44 @@ +using Elements.Core; +using FrooxEngine; + +namespace ResoniteModLoader; + +[Category(["ResoniteModLoder"])] +public class ModConfigurationValueSync : Component { +#pragma warning disable CS1591 + public override bool UserspaceOnly => true; +#pragma warning restore CS1591 +#pragma warning disable CS8618, CA1051 + public readonly Sync DefiningModAssembly; + + public readonly Sync ConfigurationKeyName; + + public readonly Sync DefinitionFound; + + public readonly FieldDrive TargetField; +#pragma warning restore CS8618, CA1051 + private ResoniteModBase _mappedMod; + + private ModConfiguration _mappedConfig; + + private ModConfigurationKey _mappedKey; + + public void LoadConfigKey(ModConfiguration config, ModConfigurationKey key) { + + _mappedMod = config.Owner; + _mappedConfig = config; + _mappedKey = key; + DefiningModAssembly.Value = Path.GetFileNameWithoutExtension(config.Owner.ModAssembly!.File); + ConfigurationKeyName.Value = key.Name; + } +} + +public static class ModConfigurationValueSyncExtensions { + public static ModConfigurationValueSync SyncWithModConfiguration(this IField field, ModConfiguration config, ModConfigurationKey key) { + ModConfigurationValueSync driver = field.FindNearestParent().AttachComponent>(); + driver.LoadConfigKey(config, key); + driver.TargetField.Target = field; + + return driver; + } +} diff --git a/ResoniteModLoader/ResoniteMod.cs b/ResoniteModLoader/ResoniteMod.cs index fb1d6ee..05f70ec 100644 --- a/ResoniteModLoader/ResoniteMod.cs +++ b/ResoniteModLoader/ResoniteMod.cs @@ -1,3 +1,5 @@ +using FrooxEngine; + namespace ResoniteModLoader; /// @@ -99,4 +101,10 @@ public virtual void DefineConfiguration(ModConfigurationDefinitionBuilder builde public virtual IncompatibleConfigurationHandlingOption HandleIncompatibleConfigurationVersions(Version serializedVersion, Version definedVersion) { return IncompatibleConfigurationHandlingOption.ERROR; } + + /// + public override async IAsyncEnumerable BuildConfigurationFeed(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false) { + foreach (DataFeedItem item in this.GenerateModConfigurationFeed(path, groupKeys, searchPhrase, viewData, includeInternal)) + yield return item; + } } diff --git a/ResoniteModLoader/ResoniteModBase.cs b/ResoniteModLoader/ResoniteModBase.cs index a3f0302..e08834a 100644 --- a/ResoniteModLoader/ResoniteModBase.cs +++ b/ResoniteModLoader/ResoniteModBase.cs @@ -1,3 +1,5 @@ +using FrooxEngine; + namespace ResoniteModLoader; /// @@ -46,5 +48,21 @@ public abstract class ResoniteModBase { return ModConfiguration; } + public bool TryGetConfiguration(out ModConfiguration configuration) { + configuration = ModConfiguration!; + return configuration 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 . + public abstract IAsyncEnumerable BuildConfigurationFeed(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false); + internal bool FinishedLoading { get; set; } } diff --git a/ResoniteModLoader/Utility/FeedBuilder.cs b/ResoniteModLoader/Utility/FeedBuilder.cs index 4884994..01a989a 100644 --- a/ResoniteModLoader/Utility/FeedBuilder.cs +++ b/ResoniteModLoader/Utility/FeedBuilder.cs @@ -1,10 +1,15 @@ 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); @@ -200,11 +205,11 @@ public static DataFeedSlider Slider(string itemKey, LocaleString label, Ac public static T Item(string itemKey, LocaleString label, LocaleString description, 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, description, icon, setupVisible, setupEnabled, subitems, customEntity); - // CONFLICT AB + // CONFLICT CD public static DataFeedCategory Category(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) => Item(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); - // CONFLICT BA + // CONFLICT DC public static DataFeedCategory Category(string itemKey, LocaleString label, LocaleString description, 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, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainSetOverrideSubpath(subpath); @@ -257,10 +262,10 @@ public static DataFeedLabel Label(string itemKey, LocaleString label, LocaleStri => Item(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); public static DataFeedLabel Label(string itemKey, LocaleString label, LocaleString description, colorX color, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) - => Label(itemKey, $"{label}", path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + => Label(itemKey, $"{label}", description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); public static DataFeedLabel Label(string itemKey, LocaleString label, LocaleString description, color color, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) - => Label(itemKey, $"{label}", path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); + => Label(itemKey, $"{label}", description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); public static DataFeedIndicator Indicator(string itemKey, LocaleString label, LocaleString description, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) => Item>(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity); @@ -386,120 +391,165 @@ public static DataFeedSlider Slider(string itemKey, LocaleString label, Lo public static DataFeedSlider Slider(string itemKey, LocaleString label, LocaleString description, Action> value, T min, T max, string formatting, Action> setupReferenceValue, IReadOnlyList path = null, IReadOnlyList groupingParameters = null, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) => Slider(itemKey, label, description, path, groupingParameters, icon, setupVisible, setupEnabled, subitems, customEntity).ChainInitSetup(value, min, max).ChainInitSlider(setupReferenceValue).ChainInitFormatting, T>(formatting); +#pragma warning restore CS8625, CS1591, CA1715 } +/// +/// Extends all DataFeedItem's "Init" methods so they can be called in a chain (methods return original item). +/// public static class DataFeedItemChaining { +#pragma warning disable CS8625, CA1715 + /// Mapped to InitBase public static I ChainInitBase(this I item, string itemKey, IReadOnlyList path, IReadOnlyList groupingParameters, LocaleString label, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where I : DataFeedItem { item.InitBase(itemKey, path, groupingParameters, label, icon, setupVisible, setupEnabled, subitems, customEntity); return item; } + /// Mapped to InitBase public static I ChainInitBase(this I item, string itemKey, IReadOnlyList path, IReadOnlyList groupingParameters, LocaleString label, LocaleString description, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where I : DataFeedItem { item.InitBase(itemKey, path, groupingParameters, label, description, icon, setupVisible, setupEnabled, subitems, customEntity); return item; } + /// Mapped to InitVisible public static I ChainInitVisible(this I item, Action> setupVisible) where I : DataFeedItem { item.InitVisible(setupVisible); return item; } + /// Mapped to InitEnabled public static I ChainInitEnabled(this I item, Action> setupEnabled) where I : DataFeedItem { item.InitEnabled(setupEnabled); return item; } + /// Mapped to InitDescription public static I ChainInitDescription(this I item, LocaleString description) where I : DataFeedItem { item.InitDescription(description); return item; } + /// Mapped to InitSorting public static I ChainInitSorting(this I item, long order) where I : DataFeedItem { item.InitSorting(order); return item; } + /// Mapped to InitSorting public static I ChainInitSorting(this I item, Func orderGetter) where I : DataFeedItem { item.InitSorting(orderGetter); return item; } + /// Mapped to SetOverrideSubpath public static DataFeedCategory ChainSetOverrideSubpath(this DataFeedCategory item, params string[] subpath) { item.SetOverrideSubpath(subpath); return item; } + /// Mapped to InitEntity public static DataFeedEntity ChainInitEntity(this DataFeedEntity item, E entity) { item.InitEntity(entity); return item; } + /// Mapped to InitSetupValue public static I ChainInitSetupValue(this I item, Action> setup) where I : DataFeedValueElement { item.InitSetupValue(setup); return item; } + /// Mapped to InitFormatting public static I ChainInitFormatting(this I item, Action> setupFormatting) where I : DataFeedValueElement { item.InitFormatting(setupFormatting); return item; } + /// Mapped to InitFormatting public static I ChainInitFormatting(this I item, string formatting) where I : DataFeedValueElement { item.InitFormatting(formatting); return item; } + /// Mapped to InitSetup public static DataFeedOrderedItem ChainInitSetup(this DataFeedOrderedItem item, Action> orderValue, Action> setupIsFirst, Action> setupIsLast, Action> setupMoveUp, Action> setupMoveDown, Action> setupMakeFirst, Action> setupMakeLast, LocaleString moveUpLabel = default, LocaleString moveDownLabel = default, LocaleString makeFirstLabel = default, LocaleString makeLastLabel = default) where T : IComparable { item.InitSetup(orderValue, setupIsFirst, setupIsLast, setupMoveUp, setupMoveDown, setupMakeFirst, setupMakeLast, moveUpLabel, moveDownLabel, makeFirstLabel, makeLastLabel); return item; } + /// Mapped to InitSetup public static I ChainInitSetup(this I item, Action> value, Action> min, Action> max) where I : DataFeedClampedValueField { item.InitSetup(value, min, max); return item; } + /// Mapped to InitSetup public static I ChainInitSetup(this I item, Action> value, T min, T max) where I : DataFeedClampedValueField { item.InitSetup(value, min, max); return item; } + /// Mapped to InitUnitConfiguration public static DataFeedQuantityField ChainInitUnitConfiguration(this DataFeedQuantityField item, UnitConfiguration defaultConfig, UnitConfiguration imperialConfig = null) where Q : unmanaged, IQuantity { item.InitUnitConfiguration(defaultConfig, imperialConfig); return item; } + /// Mapped to InitSlider public static DataFeedSlider ChainInitSlider(this DataFeedSlider item, Action> setupReferenceValue) { item.InitSlider(setupReferenceValue); return item; } + /// Mapped to InitAction public static DataFeedAction ChainInitAction(this DataFeedAction item, Action> setupAction) { item.InitAction(setupAction); return item; } + /// Mapped to InitHighlight public static DataFeedAction ChainInitHighlight(this DataFeedAction item, Action> setupHighlight) { item.InitHighlight(setupHighlight); return item; } + /// Mapped to InitAction public static DataFeedValueAction ChainInitAction(this DataFeedValueAction item, Action>> setupAction, Action> setupValue) { item.InitAction(setupAction, setupValue); return item; } + /// Mapped to InitAction public static DataFeedValueAction ChainInitAction(this DataFeedValueAction item, Action>> setupAction, T value) { item.InitAction(setupAction, value); return item; } + /// Mapped to InitHighlight public static DataFeedValueAction ChainInitHighlight(this DataFeedValueAction item, Action> setupHighlight) { item.InitHighlight(setupHighlight); return item; } + /// Mapped to InitSetupValue public static DataFeedIndicator ChainInitSetupValue(this DataFeedIndicator item, Action> setup, string format = null) { item.InitSetupValue(setup, format); return item; } + + private static MethodInfo SubItemsSetter = AccessTools.PropertySetter(typeof(DataFeedItem), nameof(DataFeedItem.SubItems)); + + public static I Subitem(this I item, params DataFeedItem[] subitem) where I : DataFeedItem { + if (item.SubItems is null) + SubItemsSetter.Invoke(item, [subitem]); + else + SubItemsSetter.Invoke(item, [item.SubItems.Concat(subitem).ToArray()]); + return item; + } + + public static I ClearSubitems(this I item, params DataFeedItem[] subitem) where I : DataFeedItem { + if (item.SubItems is not null && item.SubItems.Any()) + SubItemsSetter.Invoke(item, [null]); + return item; + } +#pragma warning restore CS8625, CA1715 } From edae026431c7dd2fd1291b697398473cecc36bc6 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 21 Jul 2024 06:18:29 -0500 Subject: [PATCH 09/25] Generate enum fields separately --- .../AutoRegisterConfigKeyAttribute.cs | 21 ++++++++++++++++++- ResoniteModLoader/ModConfigurationDataFeed.cs | 4 ++-- .../ModConfigurationFeedBuilder.cs | 15 +++++++++---- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs index bafd0eb..f4ae495 100644 --- a/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs +++ b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs @@ -4,4 +4,23 @@ 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 { + public readonly string GroupName; + + // public readonly IReadOnlyList Subpath; + + public AutoRegisterConfigKeyAttribute() { } + + public AutoRegisterConfigKeyAttribute(string groupName) { + GroupName = groupName; + } + + // public AutoRegisterConfigKeyAttribute(params string[] subpath) { + // Subpath = subpath; + // } + + // public AutoRegisterConfigKeyAttribute(string groupName, params string[] subpath) { + // GroupName = groupName; + // Subpath = subpath; + // } +} diff --git a/ResoniteModLoader/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index c69466a..da325c2 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -38,8 +38,8 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path yield return FeedBuilder.Category(KeyFromMod(mod), mod.Name); yield break; } - - path = path.Prepend("ResoniteModLoader").ToList().AsReadOnly(); + else if (path[0] != "ResoniteModLoader") + path = path.Prepend("ResoniteModLoader").ToList().AsReadOnly(); } switch (path.Count) { diff --git a/ResoniteModLoader/ModConfigurationFeedBuilder.cs b/ResoniteModLoader/ModConfigurationFeedBuilder.cs index d43dcf4..9bf0272 100644 --- a/ResoniteModLoader/ModConfigurationFeedBuilder.cs +++ b/ResoniteModLoader/ModConfigurationFeedBuilder.cs @@ -54,7 +54,12 @@ public IEnumerable> OrderedPage(ModConfigurationKey key public DataFeedValueField GenerateDataFeedField(ModConfigurationKey key) { AssertChildKey(key); - return FeedBuilder.ValueField(key.Name, key.Name, key.Description, (field) => field.SyncWithModConfiguration(Config, key)); + return FeedBuilder.ValueField(key.Name, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key)); + } + + public DataFeedEnum GenerateDataFeedEnum(ModConfigurationKey key) where T : Enum { + AssertChildKey(key); + return FeedBuilder.Enum(key.Name, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key)); } public DataFeedItem GenerateDataFeedItem(ModConfigurationKey key) { @@ -63,11 +68,13 @@ public DataFeedItem GenerateDataFeedItem(ModConfigurationKey key) { if (valueType == typeof(dummy)) return FeedBuilder.Label(key.Name, key.Description ?? key.Name); else if (valueType == typeof(bool)) - return FeedBuilder.Toggle(key.Name, key.Name, key.Description, (field) => field.SyncWithModConfiguration(Config, key)); + return FeedBuilder.Toggle(key.Name, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key)); else if (valueType == typeof(float) && HasRangeAttribute(KeyFields[key], out RangeAttribute range)) - return FeedBuilder.Slider(key.Name, key.Name, key.Description, (field) => field.SyncWithModConfiguration(Config, key), range.Min, range.Max, range.TextFormat); + return FeedBuilder.Slider(key.Name, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key), range.Min, range.Max, range.TextFormat); else if (valueType != typeof(string) && valueType != typeof(Uri) && typeof(IEnumerable).IsAssignableFrom(valueType)) - return FeedBuilder.Category(key.Name, key.Name, key.Description); + return FeedBuilder.Category(key.Name, key.Description ?? key.Name); + 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]); } From d5ad61a1ce9b1fcd5b91f3334c36d9464ea452c6 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 22 Jul 2024 00:32:19 -0500 Subject: [PATCH 10/25] Logger revamp and ConfigurationFeedBuilder grouping/subcategories --- .../AutoRegisterConfigKeyAttribute.cs | 20 +-- ResoniteModLoader/Logger.cs | 118 +++++++++++++---- ResoniteModLoader/ModConfigurationDataFeed.cs | 60 +++++---- .../ModConfigurationFeedBuilder.cs | 120 ++++++++++++++++-- ResoniteModLoader/ResoniteMod.cs | 25 +++- ResoniteModLoader/ResoniteModBase.cs | 2 +- ResoniteModLoader/Util.cs | 9 +- ResoniteModLoader/Utility/FeedBuilder.cs | 2 +- 8 files changed, 273 insertions(+), 83 deletions(-) diff --git a/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs index f4ae495..96ae100 100644 --- a/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs +++ b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs @@ -5,22 +5,22 @@ namespace ResoniteModLoader; /// [AttributeUsage(AttributeTargets.Field)] public sealed class AutoRegisterConfigKeyAttribute : Attribute { - public readonly string GroupName; + public readonly string Group; - // public readonly IReadOnlyList Subpath; + public readonly IReadOnlyList Path; public AutoRegisterConfigKeyAttribute() { } public AutoRegisterConfigKeyAttribute(string groupName) { - GroupName = groupName; + Group = groupName; } - // public AutoRegisterConfigKeyAttribute(params string[] subpath) { - // Subpath = subpath; - // } + public AutoRegisterConfigKeyAttribute(string[] subpath) { + Path = subpath; + } - // public AutoRegisterConfigKeyAttribute(string groupName, params string[] subpath) { - // GroupName = groupName; - // Subpath = subpath; - // } + public AutoRegisterConfigKeyAttribute(string[] subpath, string groupName) { + Group = groupName; + Path = subpath; + } } diff --git a/ResoniteModLoader/Logger.cs b/ResoniteModLoader/Logger.cs index b7caef6..0924c52 100644 --- a/ResoniteModLoader/Logger.cs +++ b/ResoniteModLoader/Logger.cs @@ -4,18 +4,86 @@ namespace ResoniteModLoader; -internal sealed class Logger { +public sealed class Logger { // logged for null objects internal const string NULL_STRING = "null"; - internal enum LogType { DEBUG, INFO, WARN, ERROR } + public enum LogType { TRACE, DEBUG, INFO, WARN, ERROR } - internal static readonly List<(ResoniteModBase?, LogType, string, StackTrace)> LogBuffer = new(); + public readonly struct LogMessage { + public LogMessage(DateTime time, ResoniteModBase? mod, LogType level, string message, StackTrace trace) { + Time = time; + Mod = mod; + Level = level; + Message = message; + Trace = trace; + } + + public DateTime Time { get; } + public ResoniteModBase? Mod { get; } + public LogType Level { get; } + public string Message { get; } + public StackTrace Trace { get; } + + public override string ToString() => $"({Mod?.Name ?? "ResoniteModLoader"} @ {Time}) {LogTypeTag(Level)} {Message}"; + } + + public readonly struct LogException { + public LogException(DateTime time, Assembly? assembly, Exception exception) { + Time = time; + Assembly = assembly; + Exception = exception; + } + + public DateTime Time { get; } + public Assembly? Assembly { get; } + public Exception Exception { get; } + + public override string ToString() => $"({Time}) [{Assembly?.FullName} ?? Unknown assembly] {Exception.Message}\n{Exception.StackTrace}"; + } + + private static List _logBuffer = new(); + + public static IReadOnlyList Logs => _logBuffer.AsReadOnly(); + + private static List _exceptionBuffer = new(); + + public static IReadOnlyList Exceptions => _exceptionBuffer.AsReadOnly(); internal static bool IsDebugEnabled() { return ModLoaderConfiguration.Get().Debug; } + internal static void TraceFuncInternal(Func messageProducer) { + if (IsDebugEnabled()) { + LogInternal(LogType.TRACE, messageProducer(), null, true); + } + } + + internal static void TraceFuncExternal(Func messageProducer) { + if (IsDebugEnabled()) { + LogInternal(LogType.TRACE, messageProducer(), new(1), true); + } + } + + internal static void TraceInternal(string message) { + if (IsDebugEnabled()) { + LogInternal(LogType.TRACE, message, null, true); + } + } + + internal static void TraceExternal(object message) { + if (IsDebugEnabled()) { + LogInternal(LogType.TRACE, message, new(1), true); + } + } + + internal static void TraceListExternal(object[] messages) { + if (IsDebugEnabled()) { + LogListInternal(LogType.TRACE, messages, new(1), true); + } + } + internal static void DebugFuncInternal(Func messageProducer) { if (IsDebugEnabled()) { LogInternal(LogType.DEBUG, messageProducer()); @@ -24,7 +92,7 @@ internal static void DebugFuncInternal(Func messageProducer) { internal static void DebugFuncExternal(Func messageProducer) { if (IsDebugEnabled()) { - LogInternal(LogType.DEBUG, messageProducer(), SourceFromStackTrace(new(1))); + LogInternal(LogType.DEBUG, messageProducer(), new(1)); } } @@ -36,52 +104,56 @@ internal static void DebugInternal(string message) { internal static void DebugExternal(object message) { if (IsDebugEnabled()) { - LogInternal(LogType.DEBUG, message, SourceFromStackTrace(new(1))); + LogInternal(LogType.DEBUG, message, new(1)); } } internal static void DebugListExternal(object[] messages) { if (IsDebugEnabled()) { - LogListInternal(LogType.DEBUG, messages, SourceFromStackTrace(new(1))); + LogListInternal(LogType.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 MsgExternal(object message) => LogInternal(LogType.INFO, message, new(1)); + internal static void MsgListExternal(object[] messages) => LogListInternal(LogType.INFO, messages, 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 WarnExternal(object message) => LogInternal(LogType.WARN, message, new(1)); + internal static void WarnListExternal(object[] messages) => LogListInternal(LogType.WARN, messages, 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 ErrorExternal(object message) => LogInternal(LogType.ERROR, message, new(1)); + internal static void ErrorListExternal(object[] messages) => LogListInternal(LogType.ERROR, messages, new(1)); - private static void LogInternal(LogType logType, object message, string? source = null) { + private static void LogInternal(LogType logType, object message, StackTrace? stackTrace = null, bool includeTrace = false) { message ??= NULL_STRING; + stackTrace = stackTrace ?? new(1); + ResoniteMod? source = Util.ExecutingMod(stackTrace); string logTypePrefix = LogTypeTag(logType); + _logBuffer.Add(new LogMessage(DateTime.Now, source, logType, message.ToString(), stackTrace)); if (source == null) { - UniLog.Log($"{logTypePrefix}[ResoniteModLoader] {message}"); + UniLog.Log($"{logTypePrefix}[ResoniteModLoader] {message}", includeTrace); } else { - UniLog.Log($"{logTypePrefix}[ResoniteModLoader/{source}] {message}"); + UniLog.Log($"{logTypePrefix}[ResoniteModLoader/{source.Name}] {message}", includeTrace); } } - private static void LogListInternal(LogType logType, object[] messages, string? source) { + private static void LogListInternal(LogType logType, object[] messages, StackTrace? stackTrace, bool includeTrace = false) { if (messages == null) { - LogInternal(logType, NULL_STRING, source); + LogInternal(logType, NULL_STRING, stackTrace, includeTrace); } else { foreach (object element in messages) { - LogInternal(logType, 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; - } - private static string LogTypeTag(LogType logType) => $"[{Enum.GetName(typeof(LogType), logType)}]"; } + +public static class LoggerExtensions { + public static IEnumerable Logs(this ResoniteModBase mod) => Logger.Logs.Where((line) => line.Mod == mod); + + public static IEnumerable Exceptions(this ResoniteModBase mod) => Logger.Exceptions.Where((line) => line.Assembly == mod.ModAssembly!.Assembly); +} diff --git a/ResoniteModLoader/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index da325c2..4f731e1 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -24,24 +24,9 @@ public class ModConfigurationDataFeed : Component, IDataFeedComponent, IDataFeed /// Enable or disable the use of custom configuration feeds. /// public readonly Sync IgnoreModDefinedEnumerate; - - /// - /// Set to true if this feed is being used in a RootCategoryView. - /// - public readonly Sync UsingRootCategoryView; #pragma warning restore CS8618, CA1051 #pragma warning disable CS1591 public async IAsyncEnumerable Enumerate(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData) { - if (UsingRootCategoryView.Value) { - if (path.Count == 0) { - foreach (ResoniteModBase mod in ModLoader.Mods()) - yield return FeedBuilder.Category(KeyFromMod(mod), mod.Name); - yield break; - } - else if (path[0] != "ResoniteModLoader") - path = path.Prepend("ResoniteModLoader").ToList().AsReadOnly(); - } - switch (path.Count) { case 0: { yield return FeedBuilder.Category("ResoniteModLoader", "Open ResoniteModLoader category"); @@ -74,10 +59,14 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path if (path[0] != "ResoniteModLoader" || !TryModFromKey(path[1], out var mod)) yield break; yield return mod.GenerateModInfoGroup(true); string key = KeyFromMod(mod); - IReadOnlyList latestLogs = mod.GenerateModLogFeed(5).Append(FeedBuilder.Category("Logs", "View full log")).ToList().AsReadOnly(); - yield return FeedBuilder.Group(key + ".Logs", "Recent mod logs", latestLogs); - IReadOnlyList latestException = mod.GenerateModExceptionFeed(1).Append(FeedBuilder.Category("Exceptions", "View all exceptions")).ToList().AsReadOnly(); - yield return FeedBuilder.Group(key + ".Exceptions", "Latest mod exception", latestException); + if (mod.Logs().Any()) { + IReadOnlyList latestLogs = mod.GenerateModLogFeed(5, false).Append(FeedBuilder.Category("Logs", "View full log")).ToList().AsReadOnly(); + yield return FeedBuilder.Group(key + ".Logs", "Recent mod logs", latestLogs); + } + if (mod.Exceptions().Any()) { + IReadOnlyList latestException = mod.GenerateModExceptionFeed(1, false).Append(FeedBuilder.Category("Exceptions", "View all exceptions")).ToList().AsReadOnly(); + yield return FeedBuilder.Group(key + ".Exceptions", "Latest mod exception", latestException); + } } yield break; @@ -224,15 +213,16 @@ public static IEnumerable GenerateModConfigurationFeed(this Resoni builder = builder ?? new ModConfigurationFeedBuilder(config); IEnumerable items; - if (path.Any()) { - ModConfigurationKey key = config.ConfigurationItemDefinitions.First((config) => config.Name == path[0]); - if (!typeof(IEnumerable).IsAssignableFrom(key.ValueType())) yield break; - MethodInfo genericEnumerablePage = typeof(ModConfigurationFeedBuilder).GetMethod(nameof(ModConfigurationFeedBuilder.OrderedPage)).MakeGenericMethod(key.ValueType()); - items = (IEnumerable)genericEnumerablePage.Invoke(builder, [key]); - } - else { - items = builder.RootPage(searchPhrase, includeInternal); - } + // if (path.Any()) { + // ModConfigurationKey key = config.ConfigurationItemDefinitions.First((config) => config.Name == path[0]); + // if (!typeof(IEnumerable).IsAssignableFrom(key.ValueType())) yield break; + // MethodInfo genericEnumerablePage = typeof(ModConfigurationFeedBuilder).GetMethod(nameof(ModConfigurationFeedBuilder.OrderedPage)).MakeGenericMethod(key.ValueType()); + // items = (IEnumerable)genericEnumerablePage.Invoke(builder, [key]); + // } + // else { + // items = builder.Page(searchPhrase, includeInternal); + // } + items = builder.GeneratePage(path.ToArray(), searchPhrase, includeInternal); foreach (DataFeedItem item in items) yield return item; } @@ -245,11 +235,19 @@ private static DataFeedItem AsFeedItem(this string text, int index, bool copyabl } public static IEnumerable GenerateModLogFeed(this ResoniteModBase mod, int last = -1, bool copyable = true) { - yield return "Not implemented".AsFeedItem(0, copyable); + last = last < 0 ? int.MaxValue : last; + List modLogs = mod.Logs().ToList(); + modLogs.Reverse(); + foreach (Logger.LogMessage line in modLogs.GetRange(0, Math.Min(modLogs.Count, last))) + yield return line.ToString().AsFeedItem(line.Time.GetHashCode(), copyable); } public static IEnumerable GenerateModExceptionFeed(this ResoniteModBase mod, int last = -1, bool copyable = true) { - yield return "Not implemented".AsFeedItem(0, copyable); + last = last < 0 ? int.MaxValue : last; + List modExceptions = mod.Exceptions().ToList(); + modExceptions.Reverse(); + foreach (Logger.LogException line in modExceptions.GetRange(0, Math.Min(modExceptions.Count, last))) + yield return line.ToString().AsFeedItem(line.Time.GetHashCode(), copyable); } /// @@ -266,6 +264,6 @@ public static void OpenURI(Uri uri) { [SyncMethod(typeof(Action), [])] public static void CopyText(string text) { Userspace.UserspaceWorld.InputInterface.Clipboard.SetText(text); - NotificationMessage.SpawnTextMessage("Copied line.", colorX.White); + NotificationMessage.SpawnTextMessage("Copied line", colorX.White); } } diff --git a/ResoniteModLoader/ModConfigurationFeedBuilder.cs b/ResoniteModLoader/ModConfigurationFeedBuilder.cs index 9bf0272..642fd38 100644 --- a/ResoniteModLoader/ModConfigurationFeedBuilder.cs +++ b/ResoniteModLoader/ModConfigurationFeedBuilder.cs @@ -11,11 +11,22 @@ public class ModConfigurationFeedBuilder { private readonly Dictionary KeyFields = new(); + private readonly Dictionary> KeyGrouping = new(); + + private readonly Dictionary> KeyCategories = new(); + public readonly static Dictionary CachedBuilders = new(); private static bool HasAutoRegisterAttribute(FieldInfo field) => field.GetCustomAttribute() is not null; - private static bool HasRangeAttribute(FieldInfo field, out RangeAttribute attribute) { + 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; } @@ -25,25 +36,74 @@ private void AssertChildKey(ModConfigurationKey key) { throw new InvalidOperationException($"Mod key ({key}) is not owned by {Config.Owner.Name}'s config"); } + private static bool AStartsWithB(T[] A, T[] B) => string.Join("\t", A).StartsWith(string.Join("\t", B), StringComparison.InvariantCultureIgnoreCase); + public ModConfigurationFeedBuilder(ModConfiguration config) { Config = config; IEnumerable autoConfigKeys = config.Owner.GetType().GetDeclaredFields().Where(HasAutoRegisterAttribute); + HashSet groupedKeys = new(); + HashSet categorizedKeys = 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 (attribute.Path is string[] categoryPath) { + if (!KeyCategories.ContainsKey(categoryPath)) + KeyCategories[categoryPath] = new(); + KeyCategories[categoryPath].Add(key); + categorizedKeys.Add(key); + } + } + foreach (ModConfigurationKey key in config.ConfigurationItemDefinitions) { + if (groupedKeys.Any() && !groupedKeys.Contains(key)) { + if (!KeyGrouping.ContainsKey("Uncategorized")) + KeyGrouping["Uncategorized"] = new(); + KeyGrouping["Uncategorized"].Add(key); + } + if (!categorizedKeys.Contains(key)) { + if (!KeyCategories.ContainsKey([])) + KeyCategories[[]] = new(); + KeyCategories[[]].Add(key); + } } CachedBuilders[config] = this; } - public IEnumerable RootPage(string searchPhrase = "", bool includeInternal = false) { - 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); + public IEnumerable GeneratePage(string[] path, string searchPhrase = "", bool includeInternal = false) { + path = path ?? []; + DataFeedGrid? subcategories = GenerateSubcategoryButtons(path); + if (subcategories is not null) yield return subcategories; + Logger.DebugInternal($"KeyCategories[{string.Join(", ", path)}].Contains"); + IEnumerable filteredItems = string.IsNullOrEmpty(searchPhrase) ? Config.ConfigurationItemDefinitions.Where(KeyCategories[path].Contains) : Config.ConfigurationItemDefinitions; + if (KeyGrouping.Any()) { + foreach (string group in KeyGrouping.Keys) { + DataFeedGroup container = FeedBuilder.Group(group, group); + foreach (ModConfigurationKey key in filteredItems.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.Subitem(GenerateDataFeedItem(key)); + } + if (container.SubItems is not null && container.SubItems.Any()) yield return container; + } + } + else { + foreach (ModConfigurationKey key in filteredItems) { + 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(); } - public IEnumerable> OrderedPage(ModConfigurationKey key) { + public IEnumerable> OrderedItem(ModConfigurationKey key) { AssertChildKey(key); if (!typeof(IEnumerable).IsAssignableFrom(key.ValueType())) yield break; var value = (IEnumerable)Config.GetValue(key); @@ -54,7 +114,12 @@ public IEnumerable> OrderedPage(ModConfigurationKey key public DataFeedValueField GenerateDataFeedField(ModConfigurationKey key) { AssertChildKey(key); - return FeedBuilder.ValueField(key.Name, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key)); + if (typeof(T) == typeof(bool)) + return (DataFeedValueField)(object)FeedBuilder.Toggle(key.Name, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key)); + else 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, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key), min, max, range.TextFormat); + else + return FeedBuilder.ValueField(key.Name, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key)); } public DataFeedEnum GenerateDataFeedEnum(ModConfigurationKey key) where T : Enum { @@ -67,10 +132,6 @@ public DataFeedItem GenerateDataFeedItem(ModConfigurationKey key) { Type valueType = key.ValueType(); if (valueType == typeof(dummy)) return FeedBuilder.Label(key.Name, key.Description ?? key.Name); - else if (valueType == typeof(bool)) - return FeedBuilder.Toggle(key.Name, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key)); - else if (valueType == typeof(float) && HasRangeAttribute(KeyFields[key], out RangeAttribute range)) - return FeedBuilder.Slider(key.Name, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key), range.Min, range.Max, range.TextFormat); else if (valueType != typeof(string) && valueType != typeof(Uri) && typeof(IEnumerable).IsAssignableFrom(valueType)) return FeedBuilder.Category(key.Name, key.Description ?? key.Name); else if (valueType.InheritsFrom(typeof(Enum))) @@ -78,4 +139,39 @@ public DataFeedItem GenerateDataFeedItem(ModConfigurationKey key) { else return (DataFeedItem)typeof(ModConfigurationFeedBuilder).GetMethod(nameof(GenerateDataFeedField)).MakeGenericMethod(key.ValueType()).Invoke(this, [key]); } + + public DataFeedGrid? GenerateSubcategoryButtons(string[] currentPath) { + if (!KeyCategories.Any()) return null; + IEnumerable subCategories = KeyCategories.Keys.Where((subPath) => subPath.Length == currentPath.Length + 1 && AStartsWithB(subPath, currentPath)); + if (subCategories is null || !subCategories.Any()) return null; + DataFeedGrid container = FeedBuilder.Grid("SubcategoryButtonsGrid", ""); + foreach (string[] subCategory in subCategories) + container.Subitem(FeedBuilder.Category(subCategory.Last(), subCategory.Last() + " >")); + return container; + } + + 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), [])] + public static void SaveConfig(string configName) { + + } + + [SyncMethod(typeof(Action), [])] + public static void DiscardConfig(string configName) { + + } + + [SyncMethod(typeof(Action), [])] + public static void ResetConfig(string configName) { + + } } diff --git a/ResoniteModLoader/ResoniteMod.cs b/ResoniteModLoader/ResoniteMod.cs index 05f70ec..b55e3c7 100644 --- a/ResoniteModLoader/ResoniteMod.cs +++ b/ResoniteModLoader/ResoniteMod.cs @@ -13,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. @@ -103,7 +124,7 @@ public virtual IncompatibleConfigurationHandlingOption HandleIncompatibleConfigu } /// - public override async IAsyncEnumerable BuildConfigurationFeed(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false) { + internal override async IAsyncEnumerable BuildConfigurationFeed(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false) { foreach (DataFeedItem item in this.GenerateModConfigurationFeed(path, groupKeys, searchPhrase, viewData, includeInternal)) yield return item; } diff --git a/ResoniteModLoader/ResoniteModBase.cs b/ResoniteModLoader/ResoniteModBase.cs index e08834a..8d30757 100644 --- a/ResoniteModLoader/ResoniteModBase.cs +++ b/ResoniteModLoader/ResoniteModBase.cs @@ -62,7 +62,7 @@ public bool TryGetConfiguration(out ModConfiguration configuration) { /// 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 . - public abstract IAsyncEnumerable BuildConfigurationFeed(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false); + internal abstract IAsyncEnumerable BuildConfigurationFeed(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false); internal bool FinishedLoading { get; set; } } 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 index 01a989a..d400c71 100644 --- a/ResoniteModLoader/Utility/FeedBuilder.cs +++ b/ResoniteModLoader/Utility/FeedBuilder.cs @@ -397,7 +397,7 @@ public static DataFeedSlider Slider(string itemKey, LocaleString label, Lo /// /// Extends all DataFeedItem's "Init" methods so they can be called in a chain (methods return original item). /// -public static class DataFeedItemChaining { +public static class FeedBuilderExtensions { #pragma warning disable CS8625, CA1715 /// Mapped to InitBase public static I ChainInitBase(this I item, string itemKey, IReadOnlyList path, IReadOnlyList groupingParameters, LocaleString label, Uri icon = null, Action> setupVisible = null, Action> setupEnabled = null, IReadOnlyList subitems = null, object customEntity = null) where I : DataFeedItem { From e1bc47828d548ec8ed54cbf920d6dcaf4e4d34c5 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 23 Jul 2024 01:18:27 -0500 Subject: [PATCH 11/25] Pro tip: don't try to use an array as a dictionary key! --- .editorconfig | 23 ++-- .../AutoRegisterConfigKeyAttribute.cs | 2 +- ResoniteModLoader/Logger.cs | 6 +- ResoniteModLoader/ModConfigurationDataFeed.cs | 116 +++++++++++------- .../ModConfigurationFeedBuilder.cs | 68 +++++++--- ResoniteModLoader/ResoniteMod.cs | 4 +- ResoniteModLoader/ResoniteModBase.cs | 4 +- ResoniteModLoader/Utility/FeedBuilder.cs | 18 +-- 8 files changed, 155 insertions(+), 86 deletions(-) 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/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs index 96ae100..72b0653 100644 --- a/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs +++ b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs @@ -7,7 +7,7 @@ namespace ResoniteModLoader; public sealed class AutoRegisterConfigKeyAttribute : Attribute { public readonly string Group; - public readonly IReadOnlyList Path; + public readonly string[] Path; public AutoRegisterConfigKeyAttribute() { } diff --git a/ResoniteModLoader/Logger.cs b/ResoniteModLoader/Logger.cs index 0924c52..9bc8113 100644 --- a/ResoniteModLoader/Logger.cs +++ b/ResoniteModLoader/Logger.cs @@ -129,7 +129,7 @@ private static void LogInternal(LogType logType, object message, StackTrace? sta stackTrace = stackTrace ?? new(1); ResoniteMod? source = Util.ExecutingMod(stackTrace); string logTypePrefix = LogTypeTag(logType); - _logBuffer.Add(new LogMessage(DateTime.Now, source, logType, message.ToString(), stackTrace)); + _logBuffer.Add(new(DateTime.Now, source, logType, message.ToString(), stackTrace)); if (source == null) { UniLog.Log($"{logTypePrefix}[ResoniteModLoader] {message}", includeTrace); } @@ -149,6 +149,10 @@ private static void LogListInternal(LogType logType, object[] messages, StackTra } } + internal static void ProcessException(Exception exception, Assembly? assembly = null) { + _exceptionBuffer.Add(new(DateTime.Now, assembly, exception)); + } + private static string LogTypeTag(LogType logType) => $"[{Enum.GetName(typeof(LogType), logType)}]"; } diff --git a/ResoniteModLoader/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index 4f731e1..04a299b 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -41,6 +41,7 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path FeedBuilder.Label("ResoniteModLoder.Version", $"ResoniteModLoader version {ModLoader.VERSION}"), FeedBuilder.StringIndicator("ResoniteModLoder.LoadedModCount", "Loaded mods:", ModLoader.Mods().Count()) ]); + List modCategories = new(); foreach (ResoniteModBase mod in ModLoader.Mods()) modCategories.Add(FeedBuilder.Category(KeyFromMod(mod), mod.Name)); @@ -48,23 +49,30 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path yield return FeedBuilder.Grid("Mods", "Mods", modCategories); } else { - // yield return FeedBuilder.Label("SearchResults", "Search results"); - foreach (ResoniteModBase mod in ModLoader.Mods().Where((mod) => mod.Name.IndexOf(searchPhrase, StringComparison.InvariantCultureIgnoreCase) >= 0)) - yield return mod.GenerateModInfoGroup(); + IEnumerable filteredMods = ModLoader.Mods().Where((mod) => mod.Name.IndexOf(searchPhrase, StringComparison.InvariantCultureIgnoreCase) >= 0); + yield return FeedBuilder.Label("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; - yield return mod.GenerateModInfoGroup(true); + string key = KeyFromMod(mod); - if (mod.Logs().Any()) { - IReadOnlyList latestLogs = mod.GenerateModLogFeed(5, false).Append(FeedBuilder.Category("Logs", "View full log")).ToList().AsReadOnly(); + yield return mod.GenerateModInfoGroup(true); + + IEnumerable modLogs = mod.Logs(); + 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); } - if (mod.Exceptions().Any()) { - IReadOnlyList latestException = mod.GenerateModExceptionFeed(1, false).Append(FeedBuilder.Category("Exceptions", "View all exceptions")).ToList().AsReadOnly(); + + IEnumerable modExceptions = mod.Exceptions(); + 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); } } @@ -72,28 +80,26 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path case 3: { if (path[0] != "ResoniteModLoader" || !TryModFromKey(path[1], out var mod)) yield break; + switch (path[2].ToLower()) { case "configuration": { - if (IgnoreModDefinedEnumerate.Value) { - foreach (DataFeedItem item in mod.GenerateModConfigurationFeed(path.Skip(3).ToArray(), groupKeys, searchPhrase, viewData, IncludeInternalConfigItems.Value)) - yield return item; - } - else { - await foreach (DataFeedItem item in mod.BuildConfigurationFeed(path.Skip(3).ToArray(), groupKeys, searchPhrase, viewData, IncludeInternalConfigItems.Value)) - yield return item; - } + 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 (DataFeedLabel item in mod.GenerateModLogFeed()) + foreach (DataFeedItem item in mod.GenerateModLogFeed(-1, true, searchPhrase)) yield return item; } yield break; + case "exceptions": { - foreach (DataFeedLabel item in mod.GenerateModExceptionFeed()) + foreach (DataFeedItem item in mod.GenerateModExceptionFeed(-1, true, searchPhrase)) yield return item; } yield break; + default: { // Reserved for future use - mods defining their own subfeeds } @@ -102,15 +108,11 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path } case > 3: { if (path[0] != "ResoniteModLoader" || !TryModFromKey(path[1], out var mod)) yield break; + if (path[2].ToLower() == "configuration") { - if (IgnoreModDefinedEnumerate.Value) { - foreach (DataFeedItem item in mod.GenerateModConfigurationFeed(path.Skip(3).ToArray(), groupKeys, searchPhrase, viewData, IncludeInternalConfigItems.Value)) - yield return item; - } - else { - await foreach (DataFeedItem item in mod.BuildConfigurationFeed(path.Skip(3).ToArray(), groupKeys, searchPhrase, viewData, IncludeInternalConfigItems.Value)) - yield return item; - } + foreach (DataFeedItem item in mod.GenerateModConfigurationFeed(path.Skip(3).ToArray(), groupKeys, searchPhrase, viewData, IncludeInternalConfigItems.Value, IgnoreModDefinedEnumerate.Value)) + yield return item; + yield break; } else { @@ -175,14 +177,14 @@ public static bool TryModFromKey(string key, out ResoniteModBase mod) { } } -public static class ModConfigurationDataFeedExtensions { +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 /// - public static DataFeedGroup GenerateModInfoGroup(this ResoniteModBase mod, bool standalone = false) { + public static DataFeedGroup GenerateModInfoGroup(this ResoniteModBase mod, bool standalone) { DataFeedGroup modFeedGroup = new(); List groupChildren = new(); string key = ModConfigurationDataFeed.KeyFromMod(mod); @@ -200,10 +202,20 @@ public static DataFeedGroup GenerateModInfoGroup(this ResoniteModBase mod, bool 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) { + IEnumerable modLogs = mod.Logs(); + IEnumerable modExceptions = mod.Exceptions(); + 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); } - public static IEnumerable GenerateModConfigurationFeed(this ResoniteModBase mod, IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false) { + public static IEnumerable GenerateModConfigurationFeed(this ResoniteModBase mod, IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false, bool forceDefaultBuilder = false) { + if (path.FirstOrDefault() == "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; @@ -213,16 +225,21 @@ public static IEnumerable GenerateModConfigurationFeed(this Resoni builder = builder ?? new ModConfigurationFeedBuilder(config); IEnumerable items; - // if (path.Any()) { - // ModConfigurationKey key = config.ConfigurationItemDefinitions.First((config) => config.Name == path[0]); - // if (!typeof(IEnumerable).IsAssignableFrom(key.ValueType())) yield break; - // MethodInfo genericEnumerablePage = typeof(ModConfigurationFeedBuilder).GetMethod(nameof(ModConfigurationFeedBuilder.OrderedPage)).MakeGenericMethod(key.ValueType()); - // items = (IEnumerable)genericEnumerablePage.Invoke(builder, [key]); - // } - // else { - // items = builder.Page(searchPhrase, includeInternal); - // } - items = builder.GeneratePage(path.ToArray(), searchPhrase, includeInternal); + if (!forceDefaultBuilder) { + try { + items = mod.BuildConfigurationFeed(path.Skip(3).ToArray(), groupKeys, searchPhrase, viewData, includeInternal); + } + catch (Exception ex) { + Logger.ProcessException(ex, mod.ModAssembly!.Assembly); + Logger.ErrorInternal($"Exception was thrown while running {mod.Name}'s BuildConfigurationFeed method"); + items = builder.GeneratePage(path.Skip(3).ToArray(), searchPhrase, includeInternal); + items = items.Prepend(FeedBuilder.Label("BuildConfigurationFeedException", "Encountered error while building custom configuration feed!", color.Red)); + } + } + else { + items = builder.GeneratePage(path.Skip(3).ToArray(), searchPhrase, includeInternal); + } + foreach (DataFeedItem item in items) yield return item; } @@ -234,19 +251,24 @@ private static DataFeedItem AsFeedItem(this string text, int index, bool copyabl return FeedBuilder.Label(index.ToString(), text); } - public static IEnumerable GenerateModLogFeed(this ResoniteModBase mod, int last = -1, bool copyable = true) { - last = last < 0 ? int.MaxValue : last; + public static IEnumerable GenerateModLogFeed(this ResoniteModBase mod, int last = -1, bool copyable = true, string? filter = null) { List modLogs = mod.Logs().ToList(); - modLogs.Reverse(); - foreach (Logger.LogMessage line in modLogs.GetRange(0, Math.Min(modLogs.Count, last))) + last = last < 0 ? int.MaxValue : last; + last = Math.Min(modLogs.Count, last); + modLogs = modLogs.GetRange(modLogs.Count - last, last); + if (!string.IsNullOrEmpty(filter)) + modLogs = modLogs.Where((line) => line.Message.IndexOf(filter, StringComparison.InvariantCultureIgnoreCase) >= 0).ToList(); + foreach (Logger.LogMessage line in modLogs) yield return line.ToString().AsFeedItem(line.Time.GetHashCode(), copyable); } - public static IEnumerable GenerateModExceptionFeed(this ResoniteModBase mod, int last = -1, bool copyable = true) { - last = last < 0 ? int.MaxValue : last; + public static IEnumerable GenerateModExceptionFeed(this ResoniteModBase mod, int last = -1, bool copyable = true, string? filter = null) { List modExceptions = mod.Exceptions().ToList(); - modExceptions.Reverse(); - foreach (Logger.LogException line in modExceptions.GetRange(0, Math.Min(modExceptions.Count, last))) + last = last < 0 ? int.MaxValue : last; + last = Math.Min(modExceptions.Count, last); + if (!string.IsNullOrEmpty(filter)) + modExceptions = modExceptions.Where((line) => line.Exception.ToString().IndexOf(filter, StringComparison.InvariantCultureIgnoreCase) >= 0).ToList(); + foreach (Logger.LogException line in modExceptions) yield return line.ToString().AsFeedItem(line.Time.GetHashCode(), copyable); } diff --git a/ResoniteModLoader/ModConfigurationFeedBuilder.cs b/ResoniteModLoader/ModConfigurationFeedBuilder.cs index 642fd38..cdaa583 100644 --- a/ResoniteModLoader/ModConfigurationFeedBuilder.cs +++ b/ResoniteModLoader/ModConfigurationFeedBuilder.cs @@ -13,7 +13,7 @@ public class ModConfigurationFeedBuilder { private readonly Dictionary> KeyGrouping = new(); - private readonly Dictionary> KeyCategories = new(); + private readonly Dictionary> KeyCategories = new(new StringArrayEqualityComparer()); public readonly static Dictionary CachedBuilders = new(); @@ -36,7 +36,14 @@ private void AssertChildKey(ModConfigurationKey key) { throw new InvalidOperationException($"Mod key ({key}) is not owned by {Config.Owner.Name}'s config"); } - private static bool AStartsWithB(T[] A, T[] B) => string.Join("\t", A).StartsWith(string.Join("\t", B), StringComparison.InvariantCultureIgnoreCase); + private static bool IsFirstChild(string[] x, string[] y) { + Logger.DebugInternal($"Is [({x.Length})[{string.Join(", ", x)}]] a first child to [({y.Length})[{string.Join(", ", y)}]]?"); + if (x.Length != y.Length + 1) return false; + for (int i = 0; i < y.Length; i++) + if (x[i] != y[i]) return false; + Logger.DebugInternal("You are the father!"); + return true; + } public ModConfigurationFeedBuilder(ModConfiguration config) { Config = config; @@ -74,13 +81,23 @@ public ModConfigurationFeedBuilder(ModConfiguration config) { } } 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}, Categorized: {categorizedKeys.Count}"); + Logger.DebugInternal($"Key groups ({KeyGrouping.Keys.Count}): [{string.Join(", ", KeyGrouping.Keys)}]"); + List categories = new(); + KeyCategories.Keys.Do((key) => categories.Add($"[{string.Join(", ", key)}]")); + Logger.DebugInternal($"Key categories ({KeyCategories.Keys.Count}): {string.Join(", ", categories)}"); + } } public IEnumerable GeneratePage(string[] path, string searchPhrase = "", bool includeInternal = false) { + Logger.DebugInternal($"KeyCategories[({path.Length})[{string.Join(", ", path)}]].Contains"); path = path ?? []; DataFeedGrid? subcategories = GenerateSubcategoryButtons(path); if (subcategories is not null) yield return subcategories; - Logger.DebugInternal($"KeyCategories[{string.Join(", ", path)}].Contains"); IEnumerable filteredItems = string.IsNullOrEmpty(searchPhrase) ? Config.ConfigurationItemDefinitions.Where(KeyCategories[path].Contains) : Config.ConfigurationItemDefinitions; if (KeyGrouping.Any()) { foreach (string group in KeyGrouping.Keys) { @@ -88,9 +105,9 @@ public IEnumerable GeneratePage(string[] path, string searchPhrase foreach (ModConfigurationKey key in filteredItems.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.Subitem(GenerateDataFeedItem(key)); + container.AddSubitem(GenerateDataFeedItem(key)); } - if (container.SubItems is not null && container.SubItems.Any()) yield return container; + if (container.SubItems?.Any() ?? false) yield return container; } } else { @@ -114,26 +131,29 @@ public IEnumerable> OrderedItem(ModConfigurationKey key public DataFeedValueField GenerateDataFeedField(ModConfigurationKey key) { AssertChildKey(key); - if (typeof(T) == typeof(bool)) - return (DataFeedValueField)(object)FeedBuilder.Toggle(key.Name, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key)); - else 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, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key), min, max, range.TextFormat); + string label = (key.InternalAccessOnly ? "[INTERNAL] " : "") + key.Description ?? key.Name; + 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, (field) => field.SyncWithModConfiguration(Config, key), min, max, range.TextFormat); else - return FeedBuilder.ValueField(key.Name, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key)); + return FeedBuilder.ValueField(key.Name, label, (field) => field.SyncWithModConfiguration(Config, key)); } public DataFeedEnum GenerateDataFeedEnum(ModConfigurationKey key) where T : Enum { AssertChildKey(key); - return FeedBuilder.Enum(key.Name, key.Description ?? key.Name, (field) => field.SyncWithModConfiguration(Config, key)); + string label = (key.InternalAccessOnly ? "[INTERNAL] " : "") + key.Description ?? key.Name; + return FeedBuilder.Enum(key.Name, label, (field) => field.SyncWithModConfiguration(Config, key)); } public DataFeedItem GenerateDataFeedItem(ModConfigurationKey key) { AssertChildKey(key); + string label = (key.InternalAccessOnly ? "[INTERNAL] " : "") + key.Description ?? key.Name; Type valueType = key.ValueType(); if (valueType == typeof(dummy)) - return FeedBuilder.Label(key.Name, key.Description ?? key.Name); + return FeedBuilder.Label(key.Name, label); + else if (valueType == typeof(bool)) + return FeedBuilder.Toggle(key.Name, label, (field) => field.SyncWithModConfiguration(Config, key)); else if (valueType != typeof(string) && valueType != typeof(Uri) && typeof(IEnumerable).IsAssignableFrom(valueType)) - return FeedBuilder.Category(key.Name, key.Description ?? key.Name); + return FeedBuilder.Category(key.Name, label); else if (valueType.InheritsFrom(typeof(Enum))) return (DataFeedItem)typeof(ModConfigurationFeedBuilder).GetMethod(nameof(GenerateDataFeedEnum)).MakeGenericMethod(key.ValueType()).Invoke(this, [key]); else @@ -142,11 +162,11 @@ public DataFeedItem GenerateDataFeedItem(ModConfigurationKey key) { public DataFeedGrid? GenerateSubcategoryButtons(string[] currentPath) { if (!KeyCategories.Any()) return null; - IEnumerable subCategories = KeyCategories.Keys.Where((subPath) => subPath.Length == currentPath.Length + 1 && AStartsWithB(subPath, currentPath)); + IEnumerable subCategories = KeyCategories.Keys.Where((subPath) => subPath.Length == currentPath.Length + 1 && IsFirstChild(subPath, currentPath)); if (subCategories is null || !subCategories.Any()) return null; DataFeedGrid container = FeedBuilder.Grid("SubcategoryButtonsGrid", ""); foreach (string[] subCategory in subCategories) - container.Subitem(FeedBuilder.Category(subCategory.Last(), subCategory.Last() + " >")); + container.AddSubitem(FeedBuilder.Category(subCategory.Last(), subCategory.Last() + " >")); return container; } @@ -175,3 +195,21 @@ public static void ResetConfig(string configName) { } } + +internal class StringArrayEqualityComparer : EqualityComparer { + public override bool Equals(string[] x, string[] y) { + Logger.DebugInternal($"Comparing [({x.Length})[{string.Join(", ", x)}]] to [({y.Length})[{string.Join(", ", y)}]]"); + if (x.Length != y.Length) return false; + for (int i = 0; i < x.Length; i++) + if (x[i] != y[i]) return false; + Logger.DebugInternal("Values equal"); + return true; + } + + public override int GetHashCode(string[] obj) { + int hashCode = 699494; + foreach (string item in obj) + hashCode += item.GetHashCode() - (item.Length + 621) ^ 8; + return hashCode; + } +} diff --git a/ResoniteModLoader/ResoniteMod.cs b/ResoniteModLoader/ResoniteMod.cs index b55e3c7..70354f4 100644 --- a/ResoniteModLoader/ResoniteMod.cs +++ b/ResoniteModLoader/ResoniteMod.cs @@ -124,8 +124,8 @@ public virtual IncompatibleConfigurationHandlingOption HandleIncompatibleConfigu } /// - internal override async IAsyncEnumerable BuildConfigurationFeed(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false) { - foreach (DataFeedItem item in this.GenerateModConfigurationFeed(path, groupKeys, searchPhrase, viewData, includeInternal)) + 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 8d30757..81f8b12 100644 --- a/ResoniteModLoader/ResoniteModBase.cs +++ b/ResoniteModLoader/ResoniteModBase.cs @@ -62,7 +62,9 @@ public bool TryGetConfiguration(out ModConfiguration configuration) { /// 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 . - internal abstract IAsyncEnumerable BuildConfigurationFeed(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false); + 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/Utility/FeedBuilder.cs b/ResoniteModLoader/Utility/FeedBuilder.cs index d400c71..a0c8b1f 100644 --- a/ResoniteModLoader/Utility/FeedBuilder.cs +++ b/ResoniteModLoader/Utility/FeedBuilder.cs @@ -536,19 +536,23 @@ public static DataFeedIndicator ChainInitSetupValue(this DataFeedIndicator return item; } - private static MethodInfo SubItemsSetter = AccessTools.PropertySetter(typeof(DataFeedItem), nameof(DataFeedItem.SubItems)); + private static PropertyInfo SubItemsSetter = typeof(DataFeedItem).GetProperty(nameof(DataFeedItem.SubItems)); - public static I Subitem(this I item, params DataFeedItem[] subitem) where I : DataFeedItem { + public static I AddSubitem(this I item, params DataFeedItem[] subitem) where I : DataFeedItem { if (item.SubItems is null) - SubItemsSetter.Invoke(item, [subitem]); + SubItemsSetter.SetValue(item, subitem.ToList().AsReadOnly(), null); else - SubItemsSetter.Invoke(item, [item.SubItems.Concat(subitem).ToArray()]); + SubItemsSetter.SetValue(item, item.SubItems.Concat(subitem).ToList().AsReadOnly(), null); return item; } - public static I ClearSubitems(this I item, params DataFeedItem[] subitem) where I : DataFeedItem { - if (item.SubItems is not null && item.SubItems.Any()) - SubItemsSetter.Invoke(item, [null]); + public static I ReplaceSubitems(this I item, params DataFeedItem[] subitem) where I : DataFeedItem { + SubItemsSetter.SetValue(item, subitem.ToList().AsReadOnly(), null); + return item; + } + + public static I ClearSubitems(this I item) where I : DataFeedItem { + SubItemsSetter.SetValue(item, null, null); return item; } #pragma warning restore CS8625, CA1715 From 82a88874e544d93c08b46e0ecfbe50adaac88b77 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 23 Jul 2024 02:51:32 -0500 Subject: [PATCH 12/25] Remove subcategories from ModConfigurationFeedBuilder --- .../AutoRegisterConfigKeyAttribute.cs | 17 +--- .../ModConfigurationFeedBuilder.cs | 85 ++++--------------- 2 files changed, 19 insertions(+), 83 deletions(-) diff --git a/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs index 72b0653..e508268 100644 --- a/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs +++ b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs @@ -5,22 +5,11 @@ namespace ResoniteModLoader; /// [AttributeUsage(AttributeTargets.Field)] public sealed class AutoRegisterConfigKeyAttribute : Attribute { - public readonly string Group; - - public readonly string[] Path; + public readonly string? Group; public AutoRegisterConfigKeyAttribute() { } - public AutoRegisterConfigKeyAttribute(string groupName) { - Group = groupName; - } - - public AutoRegisterConfigKeyAttribute(string[] subpath) { - Path = subpath; - } - - public AutoRegisterConfigKeyAttribute(string[] subpath, string groupName) { - Group = groupName; - Path = subpath; + public AutoRegisterConfigKeyAttribute(string group) { + Group = group; } } diff --git a/ResoniteModLoader/ModConfigurationFeedBuilder.cs b/ResoniteModLoader/ModConfigurationFeedBuilder.cs index cdaa583..a368abd 100644 --- a/ResoniteModLoader/ModConfigurationFeedBuilder.cs +++ b/ResoniteModLoader/ModConfigurationFeedBuilder.cs @@ -6,6 +6,7 @@ namespace ResoniteModLoader; public class ModConfigurationFeedBuilder { + public readonly static Dictionary CachedBuilders = new(); private readonly ModConfiguration Config; @@ -13,10 +14,6 @@ public class ModConfigurationFeedBuilder { private readonly Dictionary> KeyGrouping = new(); - private readonly Dictionary> KeyCategories = new(new StringArrayEqualityComparer()); - - public readonly static Dictionary CachedBuilders = new(); - private static bool HasAutoRegisterAttribute(FieldInfo field) => field.GetCustomAttribute() is not null; private static bool TryGetAutoRegisterAttribute(FieldInfo field, out AutoRegisterConfigKeyAttribute attribute) { @@ -36,24 +33,16 @@ private void AssertChildKey(ModConfigurationKey key) { throw new InvalidOperationException($"Mod key ({key}) is not owned by {Config.Owner.Name}'s config"); } - private static bool IsFirstChild(string[] x, string[] y) { - Logger.DebugInternal($"Is [({x.Length})[{string.Join(", ", x)}]] a first child to [({y.Length})[{string.Join(", ", y)}]]?"); - if (x.Length != y.Length + 1) return false; - for (int i = 0; i < y.Length; i++) - if (x[i] != y[i]) return false; - Logger.DebugInternal("You are the father!"); - return true; - } - public ModConfigurationFeedBuilder(ModConfiguration config) { Config = config; IEnumerable autoConfigKeys = config.Owner.GetType().GetDeclaredFields().Where(HasAutoRegisterAttribute); HashSet groupedKeys = new(); - HashSet categorizedKeys = 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)) @@ -61,48 +50,33 @@ public ModConfigurationFeedBuilder(ModConfiguration config) { KeyGrouping[groupName].Add(key); groupedKeys.Add(key); } - if (attribute.Path is string[] categoryPath) { - if (!KeyCategories.ContainsKey(categoryPath)) - KeyCategories[categoryPath] = new(); - KeyCategories[categoryPath].Add(key); - categorizedKeys.Add(key); - } } + foreach (ModConfigurationKey key in config.ConfigurationItemDefinitions) { if (groupedKeys.Any() && !groupedKeys.Contains(key)) { if (!KeyGrouping.ContainsKey("Uncategorized")) KeyGrouping["Uncategorized"] = new(); KeyGrouping["Uncategorized"].Add(key); } - if (!categorizedKeys.Contains(key)) { - if (!KeyCategories.ContainsKey([])) - KeyCategories[[]] = new(); - KeyCategories[[]].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}, Categorized: {categorizedKeys.Count}"); + Logger.DebugInternal($"AutoRegistered keys: {autoConfigKeys.Count()}, Grouped: {groupedKeys.Count}"); Logger.DebugInternal($"Key groups ({KeyGrouping.Keys.Count}): [{string.Join(", ", KeyGrouping.Keys)}]"); - List categories = new(); - KeyCategories.Keys.Do((key) => categories.Add($"[{string.Join(", ", key)}]")); - Logger.DebugInternal($"Key categories ({KeyCategories.Keys.Count}): {string.Join(", ", categories)}"); } } - public IEnumerable GeneratePage(string[] path, string searchPhrase = "", bool includeInternal = false) { - Logger.DebugInternal($"KeyCategories[({path.Length})[{string.Join(", ", path)}]].Contains"); - path = path ?? []; - DataFeedGrid? subcategories = GenerateSubcategoryButtons(path); - if (subcategories is not null) yield return subcategories; - IEnumerable filteredItems = string.IsNullOrEmpty(searchPhrase) ? Config.ConfigurationItemDefinitions.Where(KeyCategories[path].Contains) : Config.ConfigurationItemDefinitions; + 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 filteredItems.Where(KeyGrouping[group].Contains)) { + 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.AddSubitem(GenerateDataFeedItem(key)); @@ -111,16 +85,17 @@ public IEnumerable GeneratePage(string[] path, string searchPhrase } } else { - foreach (ModConfigurationKey key in filteredItems) { + 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(); } - public IEnumerable> OrderedItem(ModConfigurationKey key) { + public IEnumerable> ListPage(ModConfigurationKey key) { AssertChildKey(key); if (!typeof(IEnumerable).IsAssignableFrom(key.ValueType())) yield break; var value = (IEnumerable)Config.GetValue(key); @@ -160,16 +135,6 @@ public DataFeedItem GenerateDataFeedItem(ModConfigurationKey key) { return (DataFeedItem)typeof(ModConfigurationFeedBuilder).GetMethod(nameof(GenerateDataFeedField)).MakeGenericMethod(key.ValueType()).Invoke(this, [key]); } - public DataFeedGrid? GenerateSubcategoryButtons(string[] currentPath) { - if (!KeyCategories.Any()) return null; - IEnumerable subCategories = KeyCategories.Keys.Where((subPath) => subPath.Length == currentPath.Length + 1 && IsFirstChild(subPath, currentPath)); - if (subCategories is null || !subCategories.Any()) return null; - DataFeedGrid container = FeedBuilder.Grid("SubcategoryButtonsGrid", ""); - foreach (string[] subCategory in subCategories) - container.AddSubitem(FeedBuilder.Category(subCategory.Last(), subCategory.Last() + " >")); - return container; - } - public DataFeedGrid GenerateSaveControlButtons() { string configName = Path.GetFileNameWithoutExtension(Config.Owner.ModAssembly!.File); DataFeedGrid container = FeedBuilder.Grid("SaveControlButtonsGrid", "", [ @@ -181,35 +146,17 @@ public DataFeedGrid GenerateSaveControlButtons() { } [SyncMethod(typeof(Action), [])] - public static void SaveConfig(string configName) { + private static void SaveConfig(string configName) { } [SyncMethod(typeof(Action), [])] - public static void DiscardConfig(string configName) { + private static void DiscardConfig(string configName) { } [SyncMethod(typeof(Action), [])] - public static void ResetConfig(string configName) { - - } -} - -internal class StringArrayEqualityComparer : EqualityComparer { - public override bool Equals(string[] x, string[] y) { - Logger.DebugInternal($"Comparing [({x.Length})[{string.Join(", ", x)}]] to [({y.Length})[{string.Join(", ", y)}]]"); - if (x.Length != y.Length) return false; - for (int i = 0; i < x.Length; i++) - if (x[i] != y[i]) return false; - Logger.DebugInternal("Values equal"); - return true; - } + private static void ResetConfig(string configName) { - public override int GetHashCode(string[] obj) { - int hashCode = 699494; - foreach (string item in obj) - hashCode += item.GetHashCode() - (item.Length + 621) ^ 8; - return hashCode; } } From 36cd4d229629437a52df3e4353d44f84c0af53b3 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 4 Aug 2024 19:16:23 -0500 Subject: [PATCH 13/25] Refactor logger for exception handling --- ResoniteModLoader/Logger.cs | 120 ++++++++++++++++++++++++------------ 1 file changed, 79 insertions(+), 41 deletions(-) diff --git a/ResoniteModLoader/Logger.cs b/ResoniteModLoader/Logger.cs index 9bc8113..f4222d9 100644 --- a/ResoniteModLoader/Logger.cs +++ b/ResoniteModLoader/Logger.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Diagnostics; using Elements.Core; @@ -5,14 +6,11 @@ namespace ResoniteModLoader; public sealed class Logger { - // logged for null objects - internal const string NULL_STRING = "null"; - - public enum LogType { TRACE, DEBUG, INFO, WARN, ERROR } + public enum LogLevel { TRACE, DEBUG, INFO, WARN, ERROR } public readonly struct LogMessage { - public LogMessage(DateTime time, ResoniteModBase? mod, LogType level, string message, StackTrace trace) { - Time = time; + internal LogMessage(ResoniteModBase? mod, LogLevel level, string message, StackTrace? trace = null) { + Time = DateTime.Now; Mod = mod; Level = level; Message = message; @@ -21,27 +19,47 @@ public LogMessage(DateTime time, ResoniteModBase? mod, LogType level, string mes public DateTime Time { get; } public ResoniteModBase? Mod { get; } - public LogType Level { get; } + public LogLevel Level { get; } public string Message { get; } - public StackTrace Trace { get; } + public StackTrace? Trace { get; } public override string ToString() => $"({Mod?.Name ?? "ResoniteModLoader"} @ {Time}) {LogTypeTag(Level)} {Message}"; } public readonly struct LogException { - public LogException(DateTime time, Assembly? assembly, Exception exception) { - Time = time; - Assembly = assembly; + internal LogException(Exception exception) { + Time = DateTime.Now; + Exception = exception; + } + + internal LogException(Exception exception, Assembly? assembly) { + Time = DateTime.Now; Exception = exception; + Source = (assembly, null); + } + + internal LogException(Exception exception, ResoniteModBase? mod) { + Time = DateTime.Now; + Exception = exception; + Source = (mod?.ModAssembly?.Assembly, mod); + } + + internal LogException(Exception exception, Assembly? assembly, ResoniteModBase? mod) { + Time = DateTime.Now; + Exception = exception; + Source = (assembly, mod); } public DateTime Time { get; } - public Assembly? Assembly { get; } public Exception Exception { get; } + public (Assembly? Assembly, ResoniteModBase? Mod)? Source { get; } - public override string ToString() => $"({Time}) [{Assembly?.FullName} ?? Unknown assembly] {Exception.Message}\n{Exception.StackTrace}"; + 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(); public static IReadOnlyList Logs => _logBuffer.AsReadOnly(); @@ -56,80 +74,80 @@ internal static bool IsDebugEnabled() { internal static void TraceFuncInternal(Func messageProducer) { if (IsDebugEnabled()) { - LogInternal(LogType.TRACE, messageProducer(), null, true); + LogInternal(LogLevel.TRACE, messageProducer(), null, true); } } internal static void TraceFuncExternal(Func messageProducer) { if (IsDebugEnabled()) { - LogInternal(LogType.TRACE, messageProducer(), new(1), true); + LogInternal(LogLevel.TRACE, messageProducer(), new(1), true); } } internal static void TraceInternal(string message) { if (IsDebugEnabled()) { - LogInternal(LogType.TRACE, message, null, true); + LogInternal(LogLevel.TRACE, message, null, true); } } internal static void TraceExternal(object message) { if (IsDebugEnabled()) { - LogInternal(LogType.TRACE, message, new(1), true); + LogInternal(LogLevel.TRACE, message, new(1), true); } } internal static void TraceListExternal(object[] messages) { if (IsDebugEnabled()) { - LogListInternal(LogType.TRACE, messages, new(1), true); + 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(), 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, new(1)); + LogInternal(LogLevel.DEBUG, message, new(1)); } } internal static void DebugListExternal(object[] messages) { if (IsDebugEnabled()) { - LogListInternal(LogType.DEBUG, messages, 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, new(1)); - internal static void MsgListExternal(object[] messages) => LogListInternal(LogType.INFO, messages, new(1)); - internal static void WarnInternal(string message) => LogInternal(LogType.WARN, message); - internal static void WarnExternal(object message) => LogInternal(LogType.WARN, message, new(1)); - internal static void WarnListExternal(object[] messages) => LogListInternal(LogType.WARN, messages, new(1)); - internal static void ErrorInternal(string message) => LogInternal(LogType.ERROR, message); - internal static void ErrorExternal(object message) => LogInternal(LogType.ERROR, message, new(1)); - internal static void ErrorListExternal(object[] messages) => LogListInternal(LogType.ERROR, messages, 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(LogType logType, object message, StackTrace? stackTrace = null, bool includeTrace = false) { + private static void LogInternal(LogLevel logType, object message, StackTrace? stackTrace = null, bool includeTrace = false) { message ??= NULL_STRING; stackTrace = stackTrace ?? new(1); ResoniteMod? source = Util.ExecutingMod(stackTrace); string logTypePrefix = LogTypeTag(logType); - _logBuffer.Add(new(DateTime.Now, source, logType, message.ToString(), stackTrace)); + _logBuffer.Add(new(source, logType, message.ToString(), stackTrace)); if (source == null) { UniLog.Log($"{logTypePrefix}[ResoniteModLoader] {message}", includeTrace); } @@ -138,7 +156,7 @@ private static void LogInternal(LogType logType, object message, StackTrace? sta } } - private static void LogListInternal(LogType logType, object[] messages, StackTrace? stackTrace, bool includeTrace = false) { + private static void LogListInternal(LogLevel logType, object[] messages, StackTrace? stackTrace, bool includeTrace = false) { if (messages == null) { LogInternal(logType, NULL_STRING, stackTrace, includeTrace); } @@ -149,15 +167,35 @@ private static void LogListInternal(LogType logType, object[] messages, StackTra } } - internal static void ProcessException(Exception exception, Assembly? assembly = null) { - _exceptionBuffer.Add(new(DateTime.Now, assembly, exception)); + internal static void ProcessException(Exception exception, Assembly? assembly = null, ResoniteModBase? mod = null) => _exceptionBuffer.Add(new(exception, assembly)); + + private static string LogTypeTag(LogLevel logType) => $"[{Enum.GetName(typeof(LogLevel), logType)}]"; + + internal static void RegisterExceptionHook() { + AppDomain.CurrentDomain.UnhandledException += UnhandledExceptionProcessor; + DebugInternal("Unhandled exception hook registered"); + } + + internal static void UnregisterExceptionHook() { + AppDomain.CurrentDomain.UnhandledException -= UnhandledExceptionProcessor; + DebugInternal("Unhandled exception hook unregistered"); } - private static string LogTypeTag(LogType logType) => $"[{Enum.GetName(typeof(LogType), logType)}]"; + 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); + } + } } -public static class LoggerExtensions { - public static IEnumerable Logs(this ResoniteModBase mod) => Logger.Logs.Where((line) => line.Mod == mod); +internal static class LoggerExtensions { + internal static IEnumerable Logs(this ResoniteModBase mod) => Logger.Logs.Where((line) => line.Mod == mod); - public static IEnumerable Exceptions(this ResoniteModBase mod) => Logger.Exceptions.Where((line) => line.Assembly == mod.ModAssembly!.Assembly); + internal static IEnumerable Exceptions(this ResoniteModBase mod) => Logger.Exceptions.Where((line) => line.Source?.Mod == mod); } From b0f7149a4e872e3f116029ffb0c685b13b483335 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 4 Aug 2024 19:17:55 -0500 Subject: [PATCH 14/25] Implement initialization time recording, Display mod logs/exceptions --- ResoniteModLoader/DebugInfo.cs | 2 + ResoniteModLoader/ExecutionHook.cs | 7 +++ ResoniteModLoader/ModConfigurationDataFeed.cs | 47 +++++++++---------- ResoniteModLoader/ModLoader.cs | 8 ++++ ResoniteModLoader/ResoniteModBase.cs | 3 ++ 5 files changed, 43 insertions(+), 24 deletions(-) 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/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/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index 04a299b..1e841c8 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -39,7 +39,8 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path if (string.IsNullOrEmpty(searchPhrase)) { yield return FeedBuilder.Group("ResoniteModLoder", "RML", [ FeedBuilder.Label("ResoniteModLoder.Version", $"ResoniteModLoader version {ModLoader.VERSION}"), - FeedBuilder.StringIndicator("ResoniteModLoder.LoadedModCount", "Loaded mods:", ModLoader.Mods().Count()) + FeedBuilder.StringIndicator("ResoniteModLoder.LoadedModCount", "Loaded mods", ModLoader.Mods().Count()), + FeedBuilder.StringIndicator("ResoniteModLoder.InitializationTime", "Startup time", DebugInfo.InitializationTime.Milliseconds + "ms") ]); List modCategories = new(); @@ -155,7 +156,7 @@ public void UnregisterViewData(object data) { /// A unique key representing the mod. /// /// - public static string KeyFromMod(ResoniteModBase mod) => Path.GetFileNameWithoutExtension(mod.ModAssembly!.File); + internal static string KeyFromMod(ResoniteModBase mod) => Path.GetFileNameWithoutExtension(mod.ModAssembly!.File); /// /// Returns the mod that corresponds to a unique key. @@ -163,7 +164,7 @@ public void UnregisterViewData(object data) { /// A unique key from . /// The mod that corresponds with the unique key, or null if one couldn't be found. /// - public static ResoniteModBase? ModFromKey(string key) => ModLoader.Mods().First((mod) => KeyFromMod(mod) == key); + internal static ResoniteModBase? ModFromKey(string key) => ModLoader.Mods().First((mod) => KeyFromMod(mod) == key); /// /// Tries to get the mod that corresponds to a unique key. @@ -171,7 +172,7 @@ public void UnregisterViewData(object data) { /// A unique key from . /// Set if a matching mod is found. /// True if a matching mod is found, false otherwise. - public static bool TryModFromKey(string key, out ResoniteModBase mod) { + internal static bool TryModFromKey(string key, out ResoniteModBase mod) { mod = ModFromKey(key)!; return mod is not null; } @@ -184,7 +185,7 @@ internal static class ModConfigurationDataFeedExtensions { /// The target mod /// Set to true if this group will be displayed on its own page /// - public static DataFeedGroup GenerateModInfoGroup(this ResoniteModBase mod, bool standalone) { + internal static DataFeedGroup GenerateModInfoGroup(this ResoniteModBase mod, bool standalone) { DataFeedGroup modFeedGroup = new(); List groupChildren = new(); string key = ModConfigurationDataFeed.KeyFromMod(mod); @@ -194,9 +195,9 @@ public static DataFeedGroup GenerateModInfoGroup(this ResoniteModBase mod, bool 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))); groupChildren.Add(FeedBuilder.Indicator(key + ".AssemblyHash", "Assembly hash", mod.ModAssembly!.Sha256)); - // TODO: Add initialization time recording } 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)); @@ -212,7 +213,7 @@ public static DataFeedGroup GenerateModInfoGroup(this ResoniteModBase mod, bool return FeedBuilder.Group(key + ".Group", standalone ? "Mod info" : mod.Name, groupChildren); } - public static IEnumerable GenerateModConfigurationFeed(this ResoniteModBase mod, IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false, bool forceDefaultBuilder = false) { + internal static IEnumerable GenerateModConfigurationFeed(this ResoniteModBase mod, IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false, bool forceDefaultBuilder = false) { if (path.FirstOrDefault() == "ResoniteModLoader") Logger.WarnInternal("Call to GenerateModConfigurationFeed may include full DataFeed path, if so expect broken behavior."); @@ -221,27 +222,29 @@ public static IEnumerable GenerateModConfigurationFeed(this Resoni yield break; } - ModConfigurationFeedBuilder.CachedBuilders.TryGetValue(config, out ModConfigurationFeedBuilder builder); - builder = builder ?? new ModConfigurationFeedBuilder(config); - IEnumerable items; + List items = new(); if (!forceDefaultBuilder) { try { - items = mod.BuildConfigurationFeed(path.Skip(3).ToArray(), groupKeys, searchPhrase, viewData, includeInternal); + items = mod.BuildConfigurationFeed(path, groupKeys, searchPhrase, viewData, includeInternal).ToList(); } catch (Exception ex) { Logger.ProcessException(ex, mod.ModAssembly!.Assembly); Logger.ErrorInternal($"Exception was thrown while running {mod.Name}'s BuildConfigurationFeed method"); - items = builder.GeneratePage(path.Skip(3).ToArray(), searchPhrase, includeInternal); - items = items.Prepend(FeedBuilder.Label("BuildConfigurationFeedException", "Encountered error while building custom configuration feed!", color.Red)); } } - else { - items = builder.GeneratePage(path.Skip(3).ToArray(), searchPhrase, includeInternal); + + if (!items.Any()) { + ModConfigurationFeedBuilder.CachedBuilders.TryGetValue(config, out var builder); + builder = builder ?? new ModConfigurationFeedBuilder(config); + items = builder.RootPage(searchPhrase, includeInternal).ToList(); } - foreach (DataFeedItem item in items) + 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, int index, bool copyable = true) { @@ -251,7 +254,7 @@ private static DataFeedItem AsFeedItem(this string text, int index, bool copyabl return FeedBuilder.Label(index.ToString(), text); } - public static IEnumerable GenerateModLogFeed(this ResoniteModBase mod, int last = -1, bool copyable = true, string? filter = null) { + internal static IEnumerable GenerateModLogFeed(this ResoniteModBase mod, int last = -1, bool copyable = true, string? filter = null) { List modLogs = mod.Logs().ToList(); last = last < 0 ? int.MaxValue : last; last = Math.Min(modLogs.Count, last); @@ -262,7 +265,7 @@ public static IEnumerable GenerateModLogFeed(this ResoniteModBase yield return line.ToString().AsFeedItem(line.Time.GetHashCode(), copyable); } - public static IEnumerable GenerateModExceptionFeed(this ResoniteModBase mod, int last = -1, bool copyable = true, string? filter = null) { + internal static IEnumerable GenerateModExceptionFeed(this ResoniteModBase mod, int last = -1, bool copyable = true, string? filter = null) { List modExceptions = mod.Exceptions().ToList(); last = last < 0 ? int.MaxValue : last; last = Math.Min(modExceptions.Count, last); @@ -272,19 +275,15 @@ public static IEnumerable GenerateModExceptionFeed(this ResoniteMo yield return line.ToString().AsFeedItem(line.Time.GetHashCode(), copyable); } - /// - /// Spawns the prompt for a user to open a hyperlink. - /// - /// The URI that the user will be prompted to open. [SyncMethod(typeof(Action), [])] - public static void OpenURI(Uri uri) { + 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), [])] - public static void CopyText(string text) { + private static void CopyText(string text) { Userspace.UserspaceWorld.InputInterface.Clipboard.SetText(text); NotificationMessage.SpawnTextMessage("Copied line", colorX.White); } 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/ResoniteModBase.cs b/ResoniteModLoader/ResoniteModBase.cs index 81f8b12..ca2c1fb 100644 --- a/ResoniteModLoader/ResoniteModBase.cs +++ b/ResoniteModLoader/ResoniteModBase.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using FrooxEngine; namespace ResoniteModLoader; @@ -26,6 +27,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. From 390f682a0db153032496c250bffa40a3b019f5aa Mon Sep 17 00:00:00 2001 From: David Date: Sun, 4 Aug 2024 19:18:22 -0500 Subject: [PATCH 15/25] ModConfigurationValueSync finally works, praise the sun --- .../ModConfigurationFeedBuilder.cs | 20 +++-- .../ModConfigurationValueSync.cs | 79 +++++++++++++++++-- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/ResoniteModLoader/ModConfigurationFeedBuilder.cs b/ResoniteModLoader/ModConfigurationFeedBuilder.cs index a368abd..d82e9bc 100644 --- a/ResoniteModLoader/ModConfigurationFeedBuilder.cs +++ b/ResoniteModLoader/ModConfigurationFeedBuilder.cs @@ -52,12 +52,13 @@ public ModConfigurationFeedBuilder(ModConfiguration config) { } } - foreach (ModConfigurationKey key in config.ConfigurationItemDefinitions) { - if (groupedKeys.Any() && !groupedKeys.Contains(key)) { - if (!KeyGrouping.ContainsKey("Uncategorized")) - KeyGrouping["Uncategorized"] = new(); - KeyGrouping["Uncategorized"].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; @@ -160,3 +161,10 @@ private static void ResetConfig(string configName) { } } + +public static class ModConfigurationFeedBuilderExtensions { + 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 index 63f74c3..0840a6c 100644 --- a/ResoniteModLoader/ModConfigurationValueSync.cs +++ b/ResoniteModLoader/ModConfigurationValueSync.cs @@ -13,28 +13,97 @@ public class ModConfigurationValueSync : Component { public readonly Sync ConfigurationKeyName; - public readonly Sync DefinitionFound; + public readonly RawOutput DefinitionFound; public readonly FieldDrive TargetField; #pragma warning restore CS8618, CA1051 - private ResoniteModBase _mappedMod; + private ResoniteModBase? _mappedMod; - private ModConfiguration _mappedConfig; + private ModConfiguration? _mappedConfig; - private ModConfigurationKey _mappedKey; + private ModConfigurationKey? _mappedKey; - public void LoadConfigKey(ModConfiguration config, ModConfigurationKey key) { + private bool _definitionFound; + + 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(); + } + 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; + } + } + + private void Register() { + ConfigValueChanged(_mappedConfig.GetValue(_mappedKey)); + _mappedKey!.OnChanged += ConfigValueChanged; + _definitionFound = true; + } + + private void Unregister() { + _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; + } + + public void LoadConfigKey(ModConfiguration config, ModConfigurationKey key) { _mappedMod = config.Owner; _mappedConfig = config; _mappedKey = key; DefiningModAssembly.Value = Path.GetFileNameWithoutExtension(config.Owner.ModAssembly!.File); ConfigurationKeyName.Value = key.Name; + Register(); } } public static class ModConfigurationValueSyncExtensions { public static ModConfigurationValueSync SyncWithModConfiguration(this IField field, ModConfiguration config, ModConfigurationKey key) { + Logger.DebugInternal($"Syncing field with [{key}] from {config.Owner.Name}"); ModConfigurationValueSync driver = field.FindNearestParent().AttachComponent>(); driver.LoadConfigKey(config, key); driver.TargetField.Target = field; From bf6df6146a955062ca27ab78b8611cc609b04431 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 5 Aug 2024 21:30:34 -0500 Subject: [PATCH 16/25] Reapply "remove Unsafe/HideModTypes/HideLateTypes modloader config options" This reverts commit ec8e7d956f54d3ee217738f9b05c69f2c92ad06e. --- ResoniteModLoader/AssemblyHider.cs | 12 ++++++------ ResoniteModLoader/ModLoaderConfiguration.cs | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/ResoniteModLoader/AssemblyHider.cs b/ResoniteModLoader/AssemblyHider.cs index 0df5595..6f4b33e 100644 --- a/ResoniteModLoader/AssemblyHider.cs +++ b/ResoniteModLoader/AssemblyHider.cs @@ -48,7 +48,7 @@ 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) { + //if (ModLoaderConfiguration.Get().HideModTypes) { // initialize the static assembly sets that our patches will need later resoniteAssemblies = GetResoniteAssemblies(initialAssemblies); modAssemblies = GetModAssemblies(resoniteAssemblies); @@ -68,7 +68,7 @@ internal static void PatchResonite(Harmony harmony, HashSet initialAss 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) { @@ -116,13 +116,13 @@ private static bool IsModAssembly(Assembly assembly, string typeOrAssembly, stri // 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 = ModLoaderConfiguration.Get().HideLateTypes; - if (log) { + //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 hideLate && !forceShowLate; + return !forceShowLate; } } } diff --git a/ResoniteModLoader/ModLoaderConfiguration.cs b/ResoniteModLoader/ModLoaderConfiguration.cs index 9e51e02..4919323 100644 --- a/ResoniteModLoader/ModLoaderConfiguration.cs +++ b/ResoniteModLoader/ModLoaderConfiguration.cs @@ -12,14 +12,14 @@ internal static ModLoaderConfiguration Get() { _configuration = new ModLoaderConfiguration(); Dictionary> keyActions = new() { - { "unsafe", (value) => _configuration.Unsafe = bool.Parse(value) }, + //{ "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) }, + //{ "hidemodtypes", (value) => _configuration.HideModTypes = bool.Parse(value) }, + //{ "hidelatetypes", (value) => _configuration.HideLateTypes = bool.Parse(value) } { "nodashscreen", (value) => _configuration.NoDashScreen = bool.Parse(value) }, }; @@ -63,14 +63,14 @@ private static string GetAssemblyDirectory() { } #pragma warning disable CA1805 - public bool Unsafe { get; private set; } = false; + //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 HideModTypes { get; private set; } = true; + //public bool HideLateTypes { get; private set; } = true; public bool NoDashScreen { get; private set; } = false; #pragma warning restore CA1805 } From 5261a4b197c454499b871e428af455573e76dabc Mon Sep 17 00:00:00 2001 From: David Date: Mon, 5 Aug 2024 21:53:37 -0500 Subject: [PATCH 17/25] Properly exclude RML's assembly from getting hidden so data model items can be added --- ResoniteModLoader/AssemblyHider.cs | 77 ++++++++++----------- ResoniteModLoader/ModLoaderConfiguration.cs | 6 -- 2 files changed, 38 insertions(+), 45 deletions(-) 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/ModLoaderConfiguration.cs b/ResoniteModLoader/ModLoaderConfiguration.cs index 4919323..a906e0a 100644 --- a/ResoniteModLoader/ModLoaderConfiguration.cs +++ b/ResoniteModLoader/ModLoaderConfiguration.cs @@ -12,14 +12,11 @@ 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) }, }; @@ -63,14 +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 } From df7a3a9ccaf261cf8db864bfeb0570ce85f550f3 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 12 Aug 2024 21:32:54 -0500 Subject: [PATCH 18/25] Document all the things --- .../AutoRegisterConfigKeyAttribute.cs | 17 ++- ResoniteModLoader/DashScreenInjector.cs | 2 +- ResoniteModLoader/Logger.cs | 122 +++++++++++++++--- ResoniteModLoader/ModConfigurationDataFeed.cs | 36 +++--- .../ModConfigurationFeedBuilder.cs | 84 +++++++++++- .../ModConfigurationValueSync.cs | 52 +++++++- ResoniteModLoader/ResoniteMod.cs | 2 +- ResoniteModLoader/ResoniteModBase.cs | 14 +- ResoniteModLoader/Utility/FeedBuilder.cs | 10 +- 9 files changed, 280 insertions(+), 59 deletions(-) diff --git a/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs index e508268..fd0e5ad 100644 --- a/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs +++ b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs @@ -5,11 +5,24 @@ namespace ResoniteModLoader; /// [AttributeUsage(AttributeTargets.Field)] public sealed class AutoRegisterConfigKeyAttribute : Attribute { - public readonly string? Group; + /// + /// 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; + _group = group; } } diff --git a/ResoniteModLoader/DashScreenInjector.cs b/ResoniteModLoader/DashScreenInjector.cs index d8bce3e..abb19a2 100644 --- a/ResoniteModLoader/DashScreenInjector.cs +++ b/ResoniteModLoader/DashScreenInjector.cs @@ -35,7 +35,7 @@ internal static async void TryInjectScreen(UserspaceScreensManager __instance) { Logger.DebugInternal("Injecting dash screen"); RadiantDash dash = __instance.Slot.GetComponentInParents(); - InjectedScreen = dash.AttachScreen("Mods", RadiantUI_Constants.MidLight.ORANGE, OfficialAssets.Graphics.Icons.General.BoxClosed); // Replace with RML icon later + InjectedScreen = dash.AttachScreen("Mods", RadiantUI_Constants.Neutrals.LIGHT, OfficialAssets.Graphics.Icons.Dash.Tools); InjectedScreen.Slot.OrderOffset = 128; InjectedScreen.Slot.PersistentSelf = false; diff --git a/ResoniteModLoader/Logger.cs b/ResoniteModLoader/Logger.cs index f4222d9..d79ecd2 100644 --- a/ResoniteModLoader/Logger.cs +++ b/ResoniteModLoader/Logger.cs @@ -5,11 +5,23 @@ namespace ResoniteModLoader; +/// +/// 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 } - public readonly struct LogMessage { - internal LogMessage(ResoniteModBase? mod, LogLevel level, string message, StackTrace? trace = null) { + /// + /// Represents a single log entry. + /// + public readonly struct MessageItem { + internal MessageItem(ResoniteModBase? mod, LogLevel level, string message, StackTrace? trace = null) { Time = DateTime.Now; Mod = mod; Level = level; @@ -18,55 +30,92 @@ internal LogMessage(ResoniteModBase? mod, LogLevel level, string message, StackT } 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}"; } - public readonly struct LogException { - internal LogException(Exception exception) { + /// + /// Represents an exception that was caught or passed for logging. + /// + public readonly struct ExceptionItem { + internal ExceptionItem(System.Exception exception) { Time = DateTime.Now; Exception = exception; } - internal LogException(Exception exception, Assembly? assembly) { + internal ExceptionItem(System.Exception exception, Assembly? assembly) { Time = DateTime.Now; Exception = exception; Source = (assembly, null); } - internal LogException(Exception exception, ResoniteModBase? mod) { + internal ExceptionItem(System.Exception exception, ResoniteModBase? mod) { Time = DateTime.Now; Exception = exception; Source = (mod?.ModAssembly?.Assembly, mod); } - internal LogException(Exception exception, Assembly? assembly, ResoniteModBase? mod) { + internal ExceptionItem(System.Exception exception, Assembly? assembly, ResoniteModBase? mod) { Time = DateTime.Now; Exception = exception; Source = (assembly, mod); } public DateTime Time { get; } - public Exception Exception { get; } + public System.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(); + 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(); - public static IReadOnlyList Logs => _logBuffer.AsReadOnly(); + /// + /// Stores all exceptions caught by RML or passed by mods for logging. + /// + public static IReadOnlyList Exceptions => _exceptionBuffer.AsReadOnly(); - private static List _exceptionBuffer = new(); + public delegate void MessageHandler(MessageItem message); - public static IReadOnlyList Exceptions => _exceptionBuffer.AsReadOnly(); + /// + /// 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; @@ -144,10 +193,12 @@ internal static void DebugListExternal(object[] messages) { private static void LogInternal(LogLevel logType, object message, StackTrace? stackTrace = null, bool includeTrace = false) { message ??= NULL_STRING; - stackTrace = stackTrace ?? new(1); + stackTrace ??= new(1); ResoniteMod? source = Util.ExecutingMod(stackTrace); string logTypePrefix = LogTypeTag(logType); - _logBuffer.Add(new(source, logType, message.ToString(), stackTrace)); + MessageItem item = new(source, logType, message.ToString(), stackTrace); + _logBuffer.Add(item); + OnMessagePosted?.SafeInvoke(item); if (source == null) { UniLog.Log($"{logTypePrefix}[ResoniteModLoader] {message}", includeTrace); } @@ -167,7 +218,25 @@ private static void LogListInternal(LogLevel logType, object[] messages, StackTr } } - internal static void ProcessException(Exception exception, Assembly? assembly = null, ResoniteModBase? mod = null) => _exceptionBuffer.Add(new(exception, assembly)); + /// + /// 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(System.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)}]"; @@ -182,7 +251,7 @@ internal static void UnregisterExceptionHook() { } private static void UnhandledExceptionProcessor(object sender, UnhandledExceptionEventArgs args) { - Exception exception = (Exception)args.ExceptionObject; + System.Exception exception = (System.Exception)args.ExceptionObject; StackTrace trace = new StackTrace(exception); ResoniteModBase? mod = Util.ExecutingMod(trace); Assembly assembly = Assembly.GetAssembly(sender.GetType()); @@ -194,8 +263,21 @@ private static void UnhandledExceptionProcessor(object sender, UnhandledExceptio } } -internal static class LoggerExtensions { - internal static IEnumerable Logs(this ResoniteModBase mod) => Logger.Logs.Where((line) => line.Mod == mod); - - internal static IEnumerable Exceptions(this ResoniteModBase mod) => Logger.Exceptions.Where((line) => line.Source?.Mod == 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/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index 1e841c8..6ce0859 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -5,9 +5,9 @@ namespace ResoniteModLoader; /// -/// A custom data feed that can be used to show information about loaded mods, and alter their configuration. Path must start with "ResoniteModLoder" +/// A custom data feed that can be used to show information about loaded mods, and alter their configuration. Path must start with "ResoniteModLoader" /// -[Category(["ResoniteModLoder"])] +[Category(["ResoniteModLoader"])] public class ModConfigurationDataFeed : Component, IDataFeedComponent, IDataFeed, IWorldElement { #pragma warning disable CS1591 public override bool UserspaceOnly => true; @@ -37,10 +37,10 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path if (path[0] != "ResoniteModLoader") yield break; if (string.IsNullOrEmpty(searchPhrase)) { - yield return FeedBuilder.Group("ResoniteModLoder", "RML", [ - FeedBuilder.Label("ResoniteModLoder.Version", $"ResoniteModLoader version {ModLoader.VERSION}"), - FeedBuilder.StringIndicator("ResoniteModLoder.LoadedModCount", "Loaded mods", ModLoader.Mods().Count()), - FeedBuilder.StringIndicator("ResoniteModLoder.InitializationTime", "Startup time", DebugInfo.InitializationTime.Milliseconds + "ms") + yield return FeedBuilder.Group("ResoniteModLoader", "RML", [ + FeedBuilder.Label("ResoniteModLoader.Version", $"ResoniteModLoader version {ModLoader.VERSION}"), + FeedBuilder.StringIndicator("ResoniteModLoader.LoadedModCount", "Loaded mods", ModLoader.Mods().Count()), + FeedBuilder.StringIndicator("ResoniteModLoader.InitializationTime", "Startup time", DebugInfo.InitializationTime.Milliseconds + "ms") ]); List modCategories = new(); @@ -65,13 +65,13 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path string key = KeyFromMod(mod); yield return mod.GenerateModInfoGroup(true); - IEnumerable modLogs = mod.Logs(); + IEnumerable modLogs = mod.Logs(); 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); } - IEnumerable modExceptions = mod.Exceptions(); + IEnumerable modExceptions = mod.Exceptions(); 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); @@ -171,7 +171,7 @@ public void UnregisterViewData(object data) { /// /// A unique key from . /// Set if a matching mod is found. - /// True if a matching mod is found, false otherwise. + /// 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; @@ -183,8 +183,8 @@ 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 - /// + /// 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) { DataFeedGroup modFeedGroup = new(); List groupChildren = new(); @@ -204,8 +204,8 @@ internal static DataFeedGroup GenerateModInfoGroup(this ResoniteModBase mod, boo if (mod.GetConfiguration() is not null) groupChildren.Add(FeedBuilder.Category(key + ".ConfigurationCategory", "Mod configuration", standalone ? ["Configuration"] : [key, "Configuration"])); if (!standalone) { - IEnumerable modLogs = mod.Logs(); - IEnumerable modExceptions = mod.Exceptions(); + IEnumerable modLogs = mod.Logs(); + IEnumerable modExceptions = mod.Exceptions(); 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"])); } @@ -236,7 +236,7 @@ internal static IEnumerable GenerateModConfigurationFeed(this Reso if (!items.Any()) { ModConfigurationFeedBuilder.CachedBuilders.TryGetValue(config, out var builder); - builder = builder ?? new ModConfigurationFeedBuilder(config); + builder ??= new ModConfigurationFeedBuilder(config); items = builder.RootPage(searchPhrase, includeInternal).ToList(); } @@ -255,23 +255,23 @@ private static DataFeedItem AsFeedItem(this string text, int index, bool copyabl } internal static IEnumerable GenerateModLogFeed(this ResoniteModBase mod, int last = -1, bool copyable = true, string? filter = null) { - List modLogs = mod.Logs().ToList(); + List modLogs = mod.Logs().ToList(); last = last < 0 ? int.MaxValue : last; last = Math.Min(modLogs.Count, last); modLogs = modLogs.GetRange(modLogs.Count - last, last); if (!string.IsNullOrEmpty(filter)) modLogs = modLogs.Where((line) => line.Message.IndexOf(filter, StringComparison.InvariantCultureIgnoreCase) >= 0).ToList(); - foreach (Logger.LogMessage line in modLogs) + foreach (Logger.MessageItem line in modLogs) yield return line.ToString().AsFeedItem(line.Time.GetHashCode(), copyable); } internal static IEnumerable GenerateModExceptionFeed(this ResoniteModBase mod, int last = -1, bool copyable = true, string? filter = null) { - List modExceptions = mod.Exceptions().ToList(); + List modExceptions = mod.Exceptions().ToList(); last = last < 0 ? int.MaxValue : last; last = Math.Min(modExceptions.Count, last); if (!string.IsNullOrEmpty(filter)) modExceptions = modExceptions.Where((line) => line.Exception.ToString().IndexOf(filter, StringComparison.InvariantCultureIgnoreCase) >= 0).ToList(); - foreach (Logger.LogException line in modExceptions) + foreach (Logger.ExceptionItem line in modExceptions) yield return line.ToString().AsFeedItem(line.Time.GetHashCode(), copyable); } diff --git a/ResoniteModLoader/ModConfigurationFeedBuilder.cs b/ResoniteModLoader/ModConfigurationFeedBuilder.cs index d82e9bc..113a440 100644 --- a/ResoniteModLoader/ModConfigurationFeedBuilder.cs +++ b/ResoniteModLoader/ModConfigurationFeedBuilder.cs @@ -5,7 +5,20 @@ 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; @@ -33,6 +46,16 @@ private void AssertChildKey(ModConfigurationKey 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)}"); + } + + /// + /// 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); @@ -72,15 +95,20 @@ public ModConfigurationFeedBuilder(ModConfiguration config) { } } + /// + /// 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.AddSubitem(GenerateDataFeedItem(key)); + container.AddSubitems(GenerateDataFeedItem(key)); } if (container.SubItems?.Any() ?? false) yield return container; } @@ -96,30 +124,64 @@ public IEnumerable RootPage(string searchPhrase = "", bool include yield return GenerateSaveControlButtons(); } - public IEnumerable> ListPage(ModConfigurationKey key) { + /// + /// (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 = (key.InternalAccessOnly ? "[INTERNAL] " : "") + key.Description ?? key.Name; 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, (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, (field) => field.SyncWithModConfiguration(Config, key)); } - public DataFeedEnum GenerateDataFeedEnum(ModConfigurationKey key) where T : Enum { + /// + /// 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 = (key.InternalAccessOnly ? "[INTERNAL] " : "") + key.Description ?? key.Name; - return FeedBuilder.Enum(key.Name, label, (field) => field.SyncWithModConfiguration(Config, key)); + return FeedBuilder.Enum(key.Name, label, (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 = (key.InternalAccessOnly ? "[INTERNAL] " : "") + key.Description ?? key.Name; @@ -136,6 +198,10 @@ public DataFeedItem GenerateDataFeedItem(ModConfigurationKey key) { 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", "", [ @@ -162,7 +228,15 @@ private static void ResetConfig(string configName) { } } +/// +/// 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 index 0840a6c..bb642c4 100644 --- a/ResoniteModLoader/ModConfigurationValueSync.cs +++ b/ResoniteModLoader/ModConfigurationValueSync.cs @@ -3,7 +3,11 @@ namespace ResoniteModLoader; -[Category(["ResoniteModLoder"])] +/// +/// 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 public override bool UserspaceOnly => true; @@ -24,7 +28,7 @@ public class ModConfigurationValueSync : Component { private ModConfigurationKey? _mappedKey; private bool _definitionFound; - +#pragma warning disable CS1591 protected override void OnAwake() { base.OnAwake(); TargetField.SetupValueSetHook((IField field, T value) => { @@ -55,7 +59,11 @@ protected override void 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; @@ -72,14 +80,21 @@ private bool MapModConfigKey() { } } + /// + /// 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() { - _mappedKey!.OnChanged -= ConfigValueChanged; + if (_mappedKey is not null) + _mappedKey.OnChanged -= ConfigValueChanged; _mappedMod = null; _mappedConfig = null; _mappedKey = null; @@ -91,7 +106,15 @@ private void ConfigValueChanged(object? value) { TargetField.Target.Value = (T)value ?? default; } - public void LoadConfigKey(ModConfiguration config, ModConfigurationKey key) { + /// + /// 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; @@ -101,11 +124,28 @@ public void LoadConfigKey(ModConfiguration config, ModConfigurationKey key) { } } +/// +/// 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); + driver.LoadConfigKey(config, key as ModConfigurationKey); driver.TargetField.Target = field; return driver; diff --git a/ResoniteModLoader/ResoniteMod.cs b/ResoniteModLoader/ResoniteMod.cs index 70354f4..b3e21b5 100644 --- a/ResoniteModLoader/ResoniteMod.cs +++ b/ResoniteModLoader/ResoniteMod.cs @@ -124,7 +124,7 @@ public virtual IncompatibleConfigurationHandlingOption HandleIncompatibleConfigu } /// - internal override IEnumerable BuildConfigurationFeed(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false) { + 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 ca2c1fb..3b2da9b 100644 --- a/ResoniteModLoader/ResoniteModBase.cs +++ b/ResoniteModLoader/ResoniteModBase.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Runtime.Remoting.Messaging; using FrooxEngine; namespace ResoniteModLoader; @@ -51,11 +52,22 @@ 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. /// @@ -65,7 +77,7 @@ public bool TryGetConfiguration(out ModConfiguration configuration) { /// 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 . - internal abstract IEnumerable BuildConfigurationFeed(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData, bool includeInternal = false); + 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 diff --git a/ResoniteModLoader/Utility/FeedBuilder.cs b/ResoniteModLoader/Utility/FeedBuilder.cs index a0c8b1f..8e10f2f 100644 --- a/ResoniteModLoader/Utility/FeedBuilder.cs +++ b/ResoniteModLoader/Utility/FeedBuilder.cs @@ -538,16 +538,16 @@ public static DataFeedIndicator ChainInitSetupValue(this DataFeedIndicator private static PropertyInfo SubItemsSetter = typeof(DataFeedItem).GetProperty(nameof(DataFeedItem.SubItems)); - public static I AddSubitem(this I item, params DataFeedItem[] subitem) where I : DataFeedItem { + public static I AddSubitems(this I item, params DataFeedItem[] subitems) where I : DataFeedItem { if (item.SubItems is null) - SubItemsSetter.SetValue(item, subitem.ToList().AsReadOnly(), null); + SubItemsSetter.SetValue(item, subitems.ToList().AsReadOnly(), null); else - SubItemsSetter.SetValue(item, item.SubItems.Concat(subitem).ToList().AsReadOnly(), null); + SubItemsSetter.SetValue(item, item.SubItems.Concat(subitems).ToList().AsReadOnly(), null); return item; } - public static I ReplaceSubitems(this I item, params DataFeedItem[] subitem) where I : DataFeedItem { - SubItemsSetter.SetValue(item, subitem.ToList().AsReadOnly(), null); + public static I ReplaceSubitems(this I item, params DataFeedItem[] subitems) where I : DataFeedItem { + SubItemsSetter.SetValue(item, subitems.ToList().AsReadOnly(), null); return item; } From 36ef92e652481f3276c1843586ae07f47567a224 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 20 Aug 2024 02:06:43 -0500 Subject: [PATCH 19/25] ConfigurationFeedBuilder improvements --- ResoniteModLoader/ModConfiguration.cs | 5 ++ .../ModConfigurationFeedBuilder.cs | 75 ++++++++++++++++--- 2 files changed, 68 insertions(+), 12 deletions(-) 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/ModConfigurationFeedBuilder.cs b/ResoniteModLoader/ModConfigurationFeedBuilder.cs index 113a440..a45b2a2 100644 --- a/ResoniteModLoader/ModConfigurationFeedBuilder.cs +++ b/ResoniteModLoader/ModConfigurationFeedBuilder.cs @@ -51,6 +51,20 @@ private static void AssertMatchingType(ModConfigurationKey key) { 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! @@ -155,12 +169,13 @@ private IEnumerable> EnumerablePage(ModConfigurationKey public DataFeedValueField GenerateDataFeedField(ModConfigurationKey key) { AssertChildKey(key); AssertMatchingType(key); - string label = (key.InternalAccessOnly ? "[INTERNAL] " : "") + key.Description ?? key.Name; + 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, (field) => field.SyncWithModConfiguration(Config, key), min, max, range.TextFormat); + 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, (field) => field.SyncWithModConfiguration(Config, key)); + return FeedBuilder.ValueField(key.Name, label, description, (field) => field.SyncWithModConfiguration(Config, key)); } /// @@ -173,8 +188,9 @@ public DataFeedValueField GenerateDataFeedField(ModConfigurationKey key) { public DataFeedEnum GenerateDataFeedEnum(ModConfigurationKey key) where E : Enum { AssertChildKey(key); AssertMatchingType(key); - string label = (key.InternalAccessOnly ? "[INTERNAL] " : "") + key.Description ?? key.Name; - return FeedBuilder.Enum(key.Name, label, (field) => field.SyncWithModConfiguration(Config, key)); + string label = GetKeyLabel(key); + string description = GetKeyDescription(key); + return FeedBuilder.Enum(key.Name, label, description, (field) => field.SyncWithModConfiguration(Config, key)); } /// @@ -184,14 +200,15 @@ public DataFeedEnum GenerateDataFeedEnum(ModConfigurationKey key) where E /// Automatically picks the best item type for the config key type. public DataFeedItem GenerateDataFeedItem(ModConfigurationKey key) { AssertChildKey(key); - string label = (key.InternalAccessOnly ? "[INTERNAL] " : "") + key.Description ?? key.Name; + string label = GetKeyLabel(key); + string description = GetKeyDescription(key); Type valueType = key.ValueType(); if (valueType == typeof(dummy)) - return FeedBuilder.Label(key.Name, label); + return FeedBuilder.Label(key.Name, label, description); else if (valueType == typeof(bool)) - return FeedBuilder.Toggle(key.Name, label, (field) => field.SyncWithModConfiguration(Config, key)); + 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); + 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 @@ -214,17 +231,51 @@ public DataFeedGrid GenerateSaveControlButtons() { [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(); + } + ); } } From ee4b0ce0b9028b8bb2b8ac147818dcafb5c035b1 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 19 Nov 2024 18:22:16 -0600 Subject: [PATCH 20/25] I havent touched this repo in months but I left git dirty so here's what I forgot to commit --- ResoniteModLoader/Logger.cs | 4 ++-- ResoniteModLoader/ModConfigurationDataFeed.cs | 4 +++- ResoniteModLoader/ModConfigurationFeedBuilder.cs | 8 ++++++++ ResoniteModLoader/ResoniteModLoader.csproj | 4 ++++ .../Resources/ConfigurationItemMapper.brson | 0 doc/datafeed.md | 9 +++++++++ 6 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 ResoniteModLoader/Resources/ConfigurationItemMapper.brson create mode 100644 doc/datafeed.md diff --git a/ResoniteModLoader/Logger.cs b/ResoniteModLoader/Logger.cs index d79ecd2..c3d5ab0 100644 --- a/ResoniteModLoader/Logger.cs +++ b/ResoniteModLoader/Logger.cs @@ -20,7 +20,7 @@ public enum LogLevel { TRACE, DEBUG, INFO, WARN, ERROR } /// /// Represents a single log entry. /// - public readonly struct MessageItem { + public class MessageItem { internal MessageItem(ResoniteModBase? mod, LogLevel level, string message, StackTrace? trace = null) { Time = DateTime.Now; Mod = mod; @@ -50,7 +50,7 @@ internal MessageItem(ResoniteModBase? mod, LogLevel level, string message, Stack /// /// Represents an exception that was caught or passed for logging. /// - public readonly struct ExceptionItem { + public class ExceptionItem { internal ExceptionItem(System.Exception exception) { Time = DateTime.Now; Exception = exception; diff --git a/ResoniteModLoader/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index 6ce0859..40da8c0 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -223,18 +223,20 @@ internal static IEnumerable GenerateModConfigurationFeed(this Reso } 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 (!items.Any()) { + if (failed || !items.Any()) { ModConfigurationFeedBuilder.CachedBuilders.TryGetValue(config, out var builder); builder ??= new ModConfigurationFeedBuilder(config); items = builder.RootPage(searchPhrase, includeInternal).ToList(); diff --git a/ResoniteModLoader/ModConfigurationFeedBuilder.cs b/ResoniteModLoader/ModConfigurationFeedBuilder.cs index a45b2a2..6044855 100644 --- a/ResoniteModLoader/ModConfigurationFeedBuilder.cs +++ b/ResoniteModLoader/ModConfigurationFeedBuilder.cs @@ -109,6 +109,14 @@ public ModConfigurationFeedBuilder(ModConfiguration config) { } } + 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. /// diff --git a/ResoniteModLoader/ResoniteModLoader.csproj b/ResoniteModLoader/ResoniteModLoader.csproj index fcf97a8..eaf186a 100644 --- a/ResoniteModLoader/ResoniteModLoader.csproj +++ b/ResoniteModLoader/ResoniteModLoader.csproj @@ -65,6 +65,10 @@ + + + + diff --git a/ResoniteModLoader/Resources/ConfigurationItemMapper.brson b/ResoniteModLoader/Resources/ConfigurationItemMapper.brson new file mode 100644 index 0000000..e69de29 diff --git a/doc/datafeed.md b/doc/datafeed.md new file mode 100644 index 0000000..143f37d --- /dev/null +++ b/doc/datafeed.md @@ -0,0 +1,9 @@ +# Configuration feeds & you + +FeedBuilder +Logger +ModConfigurationDataFeed +ModConfigurationFeedBuilder +ModConfigurationValueSync +ResoniteMod.BuildConfigurationFeed +AutoRegisterConfigKeyAttribute.Group From a18751d754b58fc9ab7b830286c93ca0deb76804 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 15 Apr 2025 10:46:25 -0500 Subject: [PATCH 21/25] Override dash screen template with file and better exception hooking --- ResoniteModLoader/DashScreenInjector.cs | 13 +++++++++- ResoniteModLoader/ExceptionHook.cs | 24 +++++++++++++++++++ ResoniteModLoader/HarmonyWorker.cs | 1 + ResoniteModLoader/Logger.cs | 14 +++++------ ResoniteModLoader/ModConfigurationDataFeed.cs | 8 +++---- ResoniteModLoader/ModLoaderConfiguration.cs | 2 +- ResoniteModLoader/ResoniteModLoader.csproj | 6 ++++- 7 files changed, 54 insertions(+), 14 deletions(-) create mode 100644 ResoniteModLoader/ExceptionHook.cs diff --git a/ResoniteModLoader/DashScreenInjector.cs b/ResoniteModLoader/DashScreenInjector.cs index abb19a2..6b8e2a7 100644 --- a/ResoniteModLoader/DashScreenInjector.cs +++ b/ResoniteModLoader/DashScreenInjector.cs @@ -1,6 +1,8 @@ +using Elements.Core; using FrooxEngine; using FrooxEngine.UIX; using HarmonyLib; +using Stream = System.IO.Stream; namespace ResoniteModLoader; @@ -32,6 +34,10 @@ internal static async void TryInjectScreen(UserspaceScreensManager __instance) { return; } + string? overrideTemplatePath = Directory.EnumerateFiles(ModLoaderConfiguration.GetAssemblyDirectory(), "DashScreenTemplate.*").FirstOrDefault(); + Stream screenFileStream = File.Exists(overrideTemplatePath) ? File.OpenRead(overrideTemplatePath) : Assembly.GetExecutingAssembly().GetManifestResourceStream("Resources\\ConfigurationItemMapper.brson"); + DataTreeDictionary screenDict = DataTreeConverter.LoadAuto(screenFileStream); + Logger.DebugInternal("Injecting dash screen"); RadiantDash dash = __instance.Slot.GetComponentInParents(); @@ -40,6 +46,9 @@ internal static async void TryInjectScreen(UserspaceScreensManager __instance) { InjectedScreen.Slot.OrderOffset = 128; InjectedScreen.Slot.PersistentSelf = false; + InjectedScreen.ScreenRoot.LoadObject(screenDict, null!); + + /* SingleFeedView view = InjectedScreen.ScreenRoot.AttachComponent(); ModConfigurationDataFeed feed = InjectedScreen.ScreenRoot.AttachComponent(); @@ -73,9 +82,11 @@ internal static async void TryInjectScreen(UserspaceScreensManager __instance) { return; } - InjectedScreen.ScreenCanvas.Slot.AttachComponent().Tint.Value = UserspaceRadiantDash.DEFAULT_BACKGROUND; 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/ExceptionHook.cs b/ResoniteModLoader/ExceptionHook.cs new file mode 100644 index 0000000..7539017 --- /dev/null +++ b/ResoniteModLoader/ExceptionHook.cs @@ -0,0 +1,24 @@ +using FrooxEngine; +using HarmonyLib; + +namespace ResoniteModLoader; + +public 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/HarmonyWorker.cs b/ResoniteModLoader/HarmonyWorker.cs index 989aed1..9ac2e15 100644 --- a/ResoniteModLoader/HarmonyWorker.cs +++ b/ResoniteModLoader/HarmonyWorker.cs @@ -6,6 +6,7 @@ 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); diff --git a/ResoniteModLoader/Logger.cs b/ResoniteModLoader/Logger.cs index c3d5ab0..a0781bd 100644 --- a/ResoniteModLoader/Logger.cs +++ b/ResoniteModLoader/Logger.cs @@ -51,31 +51,31 @@ internal MessageItem(ResoniteModBase? mod, LogLevel level, string message, Stack /// Represents an exception that was caught or passed for logging. /// public class ExceptionItem { - internal ExceptionItem(System.Exception exception) { + internal ExceptionItem(Exception exception) { Time = DateTime.Now; Exception = exception; } - internal ExceptionItem(System.Exception exception, Assembly? assembly) { + internal ExceptionItem(Exception exception, Assembly? assembly) { Time = DateTime.Now; Exception = exception; Source = (assembly, null); } - internal ExceptionItem(System.Exception exception, ResoniteModBase? mod) { + internal ExceptionItem(Exception exception, ResoniteModBase? mod) { Time = DateTime.Now; Exception = exception; Source = (mod?.ModAssembly?.Assembly, mod); } - internal ExceptionItem(System.Exception exception, Assembly? assembly, ResoniteModBase? mod) { + internal ExceptionItem(Exception exception, Assembly? assembly, ResoniteModBase? mod) { Time = DateTime.Now; Exception = exception; Source = (assembly, mod); } public DateTime Time { get; } - public System.Exception Exception { get; } + public Exception Exception { get; } /// /// The (possible) source of the exception. Note the assembly and mod may be unrelated if both set! @@ -225,7 +225,7 @@ private static void LogListInternal(LogLevel logType, object[] messages, StackTr /// 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(System.Exception exception, Assembly? assembly = null, ResoniteModBase? mod = null) { + public static void ProcessException(Exception exception, Assembly? assembly = null, ResoniteModBase? mod = null) { ExceptionItem item = new(exception, assembly, mod); _exceptionBuffer.Add(item); OnExceptionPosted?.SafeInvoke(item); @@ -251,7 +251,7 @@ internal static void UnregisterExceptionHook() { } private static void UnhandledExceptionProcessor(object sender, UnhandledExceptionEventArgs args) { - System.Exception exception = (System.Exception)args.ExceptionObject; + Exception exception = (Exception)args.ExceptionObject; StackTrace trace = new StackTrace(exception); ResoniteModBase? mod = Util.ExecutingMod(trace); Assembly assembly = Assembly.GetAssembly(sender.GetType()); diff --git a/ResoniteModLoader/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index 40da8c0..0dffe72 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -65,15 +65,15 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path string key = KeyFromMod(mod); yield return mod.GenerateModInfoGroup(true); - IEnumerable modLogs = mod.Logs(); + 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(); + 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); } - IEnumerable modExceptions = mod.Exceptions(); + 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(); + 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); } } diff --git a/ResoniteModLoader/ModLoaderConfiguration.cs b/ResoniteModLoader/ModLoaderConfiguration.cs index a906e0a..9291361 100644 --- a/ResoniteModLoader/ModLoaderConfiguration.cs +++ b/ResoniteModLoader/ModLoaderConfiguration.cs @@ -52,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); diff --git a/ResoniteModLoader/ResoniteModLoader.csproj b/ResoniteModLoader/ResoniteModLoader.csproj index eaf186a..8b2b479 100644 --- a/ResoniteModLoader/ResoniteModLoader.csproj +++ b/ResoniteModLoader/ResoniteModLoader.csproj @@ -26,7 +26,7 @@ - + $(ResonitePath)Resonite_Data\Managed\Elements.Core.dll False @@ -39,6 +39,10 @@ $(ResonitePath)Resonite_Data\Managed\FrooxEngine.dll False + + $(ResonitePath)Resonite_Data\Managed\FrooxEngine.Store.dll + False + $(ResonitePath)Resonite_Data\Managed\SkyFrost.Base.dll False From 9e584d45327ee33b233c37e1f2bc7e8108503b89 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 28 May 2025 15:07:46 -0500 Subject: [PATCH 22/25] Switch exception logging to FirstChanceException event --- ResoniteModLoader/ExceptionHook.cs | 2 +- ResoniteModLoader/HarmonyWorker.cs | 2 +- ResoniteModLoader/Logger.cs | 19 ++++++++++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/ResoniteModLoader/ExceptionHook.cs b/ResoniteModLoader/ExceptionHook.cs index 7539017..094217c 100644 --- a/ResoniteModLoader/ExceptionHook.cs +++ b/ResoniteModLoader/ExceptionHook.cs @@ -3,7 +3,7 @@ namespace ResoniteModLoader; -public class ExceptionHook { +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); diff --git a/ResoniteModLoader/HarmonyWorker.cs b/ResoniteModLoader/HarmonyWorker.cs index 9ac2e15..43df723 100644 --- a/ResoniteModLoader/HarmonyWorker.cs +++ b/ResoniteModLoader/HarmonyWorker.cs @@ -6,7 +6,7 @@ namespace ResoniteModLoader; internal sealed class HarmonyWorker { internal static void LoadModsAndHideModAssemblies(HashSet initialAssemblies) { Harmony harmony = new("com.resonitemodloader.ResoniteModLoader"); - ExceptionHook.RegisterExceptionHook(harmony); + // ExceptionHook.RegisterExceptionHook(harmony); ModLoader.LoadMods(); ModConfiguration.RegisterShutdownHook(harmony); AssemblyHider.PatchResonite(harmony, initialAssemblies); diff --git a/ResoniteModLoader/Logger.cs b/ResoniteModLoader/Logger.cs index a0781bd..f541658 100644 --- a/ResoniteModLoader/Logger.cs +++ b/ResoniteModLoader/Logger.cs @@ -1,6 +1,6 @@ using System.Collections; using System.Diagnostics; - +using System.Runtime.ExceptionServices; using Elements.Core; namespace ResoniteModLoader; @@ -241,12 +241,14 @@ public static void ProcessException(Exception exception, Assembly? assembly = nu private static string LogTypeTag(LogLevel logType) => $"[{Enum.GetName(typeof(LogLevel), logType)}]"; internal static void RegisterExceptionHook() { - AppDomain.CurrentDomain.UnhandledException += UnhandledExceptionProcessor; + // AppDomain.CurrentDomain.UnhandledException += UnhandledExceptionProcessor; + AppDomain.CurrentDomain.FirstChanceException += FirstChanceExceptionProcessor; DebugInternal("Unhandled exception hook registered"); } internal static void UnregisterExceptionHook() { - AppDomain.CurrentDomain.UnhandledException -= UnhandledExceptionProcessor; + // AppDomain.CurrentDomain.UnhandledException -= UnhandledExceptionProcessor; + AppDomain.CurrentDomain.FirstChanceException -= FirstChanceExceptionProcessor; DebugInternal("Unhandled exception hook unregistered"); } @@ -260,6 +262,17 @@ private static void UnhandledExceptionProcessor(object sender, UnhandledExceptio 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); + } } } From c31502a1dac07ef02d7b44c408393bfe9e061545 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 28 May 2025 15:15:20 -0500 Subject: [PATCH 23/25] Debug build models can exist outside userspace --- .run/Attach to Resonite.run.xml | 10 ++++++++++ ResoniteModLoader/ModConfigurationDataFeed.cs | 8 ++++++-- ResoniteModLoader/ModConfigurationValueSync.cs | 2 ++ ResoniteModLoader/Properties/AssemblyInfo.cs | 4 ++++ 4 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 .run/Attach to Resonite.run.xml 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/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index 0dffe72..73bb2b5 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -10,7 +10,9 @@ namespace 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 @@ -29,7 +31,9 @@ public class ModConfigurationDataFeed : Component, IDataFeedComponent, IDataFeed public async IAsyncEnumerable Enumerate(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData) { switch (path.Count) { case 0: { - yield return FeedBuilder.Category("ResoniteModLoader", "Open ResoniteModLoader category"); + yield return FeedBuilder.Category("ResoniteModLoader", "Home page"); + foreach (ResoniteModBase mod in ModLoader.Mods()) + yield return FeedBuilder.Category(KeyFromMod(mod), mod.Name, ["ResoniteModLoader", KeyFromMod(mod)]); } yield break; @@ -138,7 +142,7 @@ public LocaleString PathSegmentName(string segment, int depth) { public object RegisterViewData() { Logger.DebugInternal($"ModConfigurationDataFeed.RegisterViewData called\n{Environment.StackTrace}"); - return null!; + return this; } public void UnregisterListener(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, DataFeedUpdateHandler handler) { diff --git a/ResoniteModLoader/ModConfigurationValueSync.cs b/ResoniteModLoader/ModConfigurationValueSync.cs index bb642c4..780176a 100644 --- a/ResoniteModLoader/ModConfigurationValueSync.cs +++ b/ResoniteModLoader/ModConfigurationValueSync.cs @@ -10,7 +10,9 @@ namespace ResoniteModLoader; [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; diff --git a/ResoniteModLoader/Properties/AssemblyInfo.cs b/ResoniteModLoader/Properties/AssemblyInfo.cs index a0976a7..58929dd 100644 --- a/ResoniteModLoader/Properties/AssemblyInfo.cs +++ b/ResoniteModLoader/Properties/AssemblyInfo.cs @@ -18,4 +18,8 @@ // [module: Description("FROOXENGINE_WEAVED")] //Mark as DataModelAssembly for the Plugin loading system to load this assembly +#if DEBUG +[assembly: DataModelAssembly(DataModelAssemblyType.Core)] +#else [assembly: DataModelAssembly(DataModelAssemblyType.UserspaceCore)] +#endif From ad131a1a97358c477628adbc74029b7a1b428047 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 14 Jun 2025 12:41:09 -0500 Subject: [PATCH 24/25] Dash template file loading and syntax corrections --- ResoniteModLoader/DashScreenInjector.cs | 84 ++++++++++--------- ResoniteModLoader/ModConfigurationDataFeed.cs | 81 +++++++++--------- 2 files changed, 88 insertions(+), 77 deletions(-) diff --git a/ResoniteModLoader/DashScreenInjector.cs b/ResoniteModLoader/DashScreenInjector.cs index 6b8e2a7..156bfd6 100644 --- a/ResoniteModLoader/DashScreenInjector.cs +++ b/ResoniteModLoader/DashScreenInjector.cs @@ -36,7 +36,6 @@ internal static async void TryInjectScreen(UserspaceScreensManager __instance) { string? overrideTemplatePath = Directory.EnumerateFiles(ModLoaderConfiguration.GetAssemblyDirectory(), "DashScreenTemplate.*").FirstOrDefault(); Stream screenFileStream = File.Exists(overrideTemplatePath) ? File.OpenRead(overrideTemplatePath) : Assembly.GetExecutingAssembly().GetManifestResourceStream("Resources\\ConfigurationItemMapper.brson"); - DataTreeDictionary screenDict = DataTreeConverter.LoadAuto(screenFileStream); Logger.DebugInternal("Injecting dash screen"); @@ -46,45 +45,54 @@ internal static async void TryInjectScreen(UserspaceScreensManager __instance) { InjectedScreen.Slot.OrderOffset = 128; InjectedScreen.Slot.PersistentSelf = false; - InjectedScreen.ScreenRoot.LoadObject(screenDict, null!); - - /* - 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; + 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; } - 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 + 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"]); } - 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; diff --git a/ResoniteModLoader/ModConfigurationDataFeed.cs b/ResoniteModLoader/ModConfigurationDataFeed.cs index 73bb2b5..a5f5af3 100644 --- a/ResoniteModLoader/ModConfigurationDataFeed.cs +++ b/ResoniteModLoader/ModConfigurationDataFeed.cs @@ -1,6 +1,5 @@ using Elements.Core; using FrooxEngine; -using System.Collections; namespace ResoniteModLoader; @@ -31,18 +30,21 @@ public class ModConfigurationDataFeed : Component, IDataFeedComponent, IDataFeed public async IAsyncEnumerable Enumerate(IReadOnlyList path, IReadOnlyList groupKeys, string searchPhrase, object viewData) { switch (path.Count) { case 0: { - yield return FeedBuilder.Category("ResoniteModLoader", "Home page"); + 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", "RML", [ + 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") ]); @@ -51,16 +53,16 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path foreach (ResoniteModBase mod in ModLoader.Mods()) modCategories.Add(FeedBuilder.Category(KeyFromMod(mod), mod.Name)); - yield return FeedBuilder.Grid("Mods", "Mods", modCategories); + yield return FeedBuilder.Grid("ResoniteModLoader.ModsGroup", "Mods", modCategories); } else { - IEnumerable filteredMods = ModLoader.Mods().Where((mod) => mod.Name.IndexOf(searchPhrase, StringComparison.InvariantCultureIgnoreCase) >= 0); - yield return FeedBuilder.Label("SearchResults", filteredMods.Any() ? $"Search results: {filteredMods.Count()} mods found." : "No results!"); + 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: { @@ -80,7 +82,7 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path 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: { @@ -90,19 +92,19 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path 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(-1, true, searchPhrase)) + foreach (DataFeedItem item in mod.GenerateModLogFeed(int.MaxValue, true, searchPhrase)) yield return item; - } + } yield break; case "exceptions": { - foreach (DataFeedItem item in mod.GenerateModExceptionFeed(-1, true, searchPhrase)) + foreach (DataFeedItem item in mod.GenerateModExceptionFeed(int.MaxValue, true, searchPhrase)) yield return item; - } + } yield break; default: { @@ -123,7 +125,7 @@ public async IAsyncEnumerable Enumerate(IReadOnlyList path else { // Reserved for future use - mods defining their own subfeeds } - } + } yield break; } } @@ -190,35 +192,40 @@ internal static class ModConfigurationDataFeedExtensions { /// 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) { - DataFeedGroup modFeedGroup = new(); List groupChildren = new(); string key = ModConfigurationDataFeed.KeyFromMod(mod); - if (standalone) groupChildren.Add(FeedBuilder.Indicator(key + ".Name", "Name", mod.Name)); + 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))); - groupChildren.Add(FeedBuilder.Indicator(key + ".AssemblyHash", "Assembly hash", mod.ModAssembly!.Sha256)); + 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 (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) { - IEnumerable modLogs = mod.Logs(); - IEnumerable modExceptions = mod.Exceptions(); - 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"])); + 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.FirstOrDefault() == "ResoniteModLoader") + 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()) { @@ -253,32 +260,28 @@ internal static IEnumerable GenerateModConfigurationFeed(this Reso } } - private static DataFeedItem AsFeedItem(this string text, int index, bool copyable = true) { + private static DataFeedItem AsFeedItem(this string text, bool copyable = true, string? key = null) { + key ??= Guid.NewGuid().ToString(); if (copyable) - return FeedBuilder.ValueAction(index.ToString(), text, (action) => action.Target = CopyText, text); + return FeedBuilder.ValueAction(key, text, (action) => action.Target = CopyText, text); else - return FeedBuilder.Label(index.ToString(), text); + return FeedBuilder.Label(key, text); } - internal static IEnumerable GenerateModLogFeed(this ResoniteModBase mod, int last = -1, bool copyable = true, string? filter = null) { - List modLogs = mod.Logs().ToList(); - last = last < 0 ? int.MaxValue : last; - last = Math.Min(modLogs.Count, last); - modLogs = modLogs.GetRange(modLogs.Count - last, last); + 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(line.Time.GetHashCode(), copyable); + yield return line.ToString().AsFeedItem(copyable); } - internal static IEnumerable GenerateModExceptionFeed(this ResoniteModBase mod, int last = -1, bool copyable = true, string? filter = null) { - List modExceptions = mod.Exceptions().ToList(); - last = last < 0 ? int.MaxValue : last; - last = Math.Min(modExceptions.Count, last); + 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(line.Time.GetHashCode(), copyable); + yield return line.ToString().AsFeedItem(copyable); } [SyncMethod(typeof(Action), [])] From 594504b96daa029c6c3ab002065084b79531c905 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 14 Jun 2025 12:48:13 -0500 Subject: [PATCH 25/25] Embed dash screen template resource --- ResoniteModLoader/DashScreenInjector.cs | 2 +- ResoniteModLoader/ResoniteModLoader.csproj | 2 +- .../Resources/ConfigurationItemMapper.brson | 0 .../Resources/DashScreenTemplate.bson | Bin 0 -> 120669 bytes 4 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 ResoniteModLoader/Resources/ConfigurationItemMapper.brson create mode 100644 ResoniteModLoader/Resources/DashScreenTemplate.bson diff --git a/ResoniteModLoader/DashScreenInjector.cs b/ResoniteModLoader/DashScreenInjector.cs index 156bfd6..22401e9 100644 --- a/ResoniteModLoader/DashScreenInjector.cs +++ b/ResoniteModLoader/DashScreenInjector.cs @@ -35,7 +35,7 @@ internal static async void TryInjectScreen(UserspaceScreensManager __instance) { } string? overrideTemplatePath = Directory.EnumerateFiles(ModLoaderConfiguration.GetAssemblyDirectory(), "DashScreenTemplate.*").FirstOrDefault(); - Stream screenFileStream = File.Exists(overrideTemplatePath) ? File.OpenRead(overrideTemplatePath) : Assembly.GetExecutingAssembly().GetManifestResourceStream("Resources\\ConfigurationItemMapper.brson"); + Stream screenFileStream = File.Exists(overrideTemplatePath) ? File.OpenRead(overrideTemplatePath) : Assembly.GetExecutingAssembly().GetManifestResourceStream("Resources\\DashScreenTemplate.bson"); Logger.DebugInternal("Injecting dash screen"); diff --git a/ResoniteModLoader/ResoniteModLoader.csproj b/ResoniteModLoader/ResoniteModLoader.csproj index 8b2b479..56bcfcf 100644 --- a/ResoniteModLoader/ResoniteModLoader.csproj +++ b/ResoniteModLoader/ResoniteModLoader.csproj @@ -70,7 +70,7 @@ - + diff --git a/ResoniteModLoader/Resources/ConfigurationItemMapper.brson b/ResoniteModLoader/Resources/ConfigurationItemMapper.brson deleted file mode 100644 index e69de29..0000000 diff --git a/ResoniteModLoader/Resources/DashScreenTemplate.bson b/ResoniteModLoader/Resources/DashScreenTemplate.bson new file mode 100644 index 0000000000000000000000000000000000000000..96f281346a85c1cd595df601fbd32f1602f96e58 GIT binary patch literal 120669 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%0A?4O_e?hJCy086ho)_E2VIg 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 jUF_XELo@u- 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 Wc4C=vLW?f%ayca(DGc_jA9@ImkTHG$ literal 0 HcmV?d00001