/* * Copyright 2022 Peter Han * Permission is hereby granted, free of charge, to any person obtaining a copy of this software * and associated documentation files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all copies or * substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ using HarmonyLib; using Newtonsoft.Json; using PeterHan.PLib.Core; using PeterHan.PLib.UI; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Reflection; using UnityEngine; namespace PeterHan.PLib.Options { /// /// Adds an "Options" screen to a mod in the Mods menu. /// public sealed class POptions : PForwardedComponent { /// /// The configuration file name used by default for classes that do not specify /// otherwise. This file name is case sensitive. /// public const string CONFIG_FILE_NAME = "config.json"; /// /// The maximum nested class depth which will be serialized in mod options to avoid /// infinite loops. /// public const int MAX_SERIALIZATION_DEPTH = 8; /// /// The margins around the Options button. /// internal static readonly RectOffset OPTION_BUTTON_MARGIN = new RectOffset(11, 11, 5, 5); /// /// The shared mod configuration folder, which works between archived versions and /// local/dev/Steam. /// public const string SHARED_CONFIG_FOLDER = "config"; /// /// The version of this component. Uses the running PLib version. /// internal static readonly Version VERSION = new Version(PVersion.VERSION); /// /// The instantiated copy of this class. /// internal static POptions Instance { get; private set; } /// /// Applied to ModsScreen if mod options are registered, after BuildDisplay runs. /// private static void BuildDisplay_Postfix(GameObject ___entryPrefab, System.Collections.IEnumerable ___displayedMods) { if (Instance != null) { int index = 0; // Harmony does not check the type at all on accessing private fields with ___ foreach (var displayedMod in ___displayedMods) Instance.AddModOptions(displayedMod, index++, ___entryPrefab); } } /// /// Retrieves the configuration file path used by PLib Options for a specified type. /// /// The options type stored in the config file. /// The path to the configuration file that will be used by PLib for that /// mod's config. public static string GetConfigFilePath(Type optionsType) { return GetConfigPath(optionsType.GetCustomAttribute(), optionsType.Assembly); } /// /// Attempts to find the mod which owns the specified type. /// /// The type to look up. /// The Mod that owns it, or null if no owning mod could be found, such as for /// types in System or Assembly-CSharp. internal static KMod.Mod GetModFromType(Type optionsType) { if (optionsType == null) throw new ArgumentNullException(nameof(optionsType)); var sd = PRegistry.Instance.GetSharedData(typeof(POptions).FullName); // Look up mod in the shared data if (!(sd is IDictionary lookup) || !lookup.TryGetValue( optionsType.Assembly, out KMod.Mod result)) result = null; return result; } /// /// Retrieves the configuration file path used by PLib Options for a specified type. /// /// The config file attribute for that type. /// The assembly to use for determining the path. /// The path to the configuration file that will be used by PLib for that /// mod's config. private static string GetConfigPath(ConfigFileAttribute attr, Assembly modAssembly) { string path, name = modAssembly.GetNameSafe(); path = (name != null && (attr?.UseSharedConfigLocation == true)) ? Path.Combine(KMod.Manager.GetDirectory(), SHARED_CONFIG_FOLDER, name) : PUtil.GetModPath(modAssembly); return Path.Combine(path, attr?.ConfigFileName ?? CONFIG_FILE_NAME); } /// /// Reads a mod's settings from its configuration file. The assembly defining T is used /// to resolve the proper settings folder. /// /// The type of the settings object. /// The settings read, or null if they could not be read (e.g. newly installed). public static T ReadSettings() where T : class { var type = typeof(T); return ReadSettings(GetConfigPath(type.GetCustomAttribute(), type.Assembly), type) as T; } /// /// Reads a mod's settings from its configuration file. /// /// The path to the settings file. /// The options type. /// The settings read, or null if they could not be read (e.g. newly installed) internal static object ReadSettings(string path, Type optionsType) { object options = null; try { using (var jr = new JsonTextReader(File.OpenText(path))) { var serializer = new JsonSerializer { MaxDepth = MAX_SERIALIZATION_DEPTH }; // Deserialize from stream avoids reading file text into memory options = serializer.Deserialize(jr, optionsType); } } catch (FileNotFoundException) { #if DEBUG PUtil.LogDebug("{0} was not found; using default settings".F(Path.GetFileName( path))); #endif } catch (UnauthorizedAccessException e) { // Options will be set to defaults PUtil.LogExcWarn(e); } catch (IOException e) { // Again set defaults PUtil.LogExcWarn(e); } catch (JsonException e) { // Again set defaults PUtil.LogExcWarn(e); } return options; } /// /// Shows a mod options dialog now, as if Options was used inside the Mods menu. /// /// The type of the options to show. The mod to configure, /// configuration directory, and so forth will be retrieved from the provided type. /// This type must be the same type configured in RegisterOptions for the mod. /// The method to call when the dialog is closed. public static void ShowDialog(Type optionsType, Action onClose = null) { var args = new OpenDialogArgs(optionsType, onClose); var allOptions = PRegistry.Instance.GetAllComponents(typeof(POptions).FullName); if (allOptions != null) foreach (var mod in allOptions) mod?.Process(0, args); } /// /// Writes a mod's settings to its configuration file. The assembly defining T is used /// to resolve the proper settings folder. /// /// The type of the settings object. /// The settings to write. public static void WriteSettings(T settings) where T : class { var attr = typeof(T).GetCustomAttribute(); WriteSettings(settings, GetConfigPath(attr, typeof(T).Assembly), attr?. IndentOutput ?? false); } /// /// Writes a mod's settings to its configuration file. /// /// The settings to write. /// The path to the settings file. /// true to indent the output, or false to leave it in one line. internal static void WriteSettings(object settings, string path, bool indent = false) { if (settings != null) try { // SharedConfigLocation Directory.CreateDirectory(Path.GetDirectoryName(path)); using (var jw = new JsonTextWriter(File.CreateText(path))) { var serializer = new JsonSerializer { MaxDepth = MAX_SERIALIZATION_DEPTH }; serializer.Formatting = indent ? Formatting.Indented : Formatting.None; // Serialize from stream avoids creating file text in memory serializer.Serialize(jw, settings); } } catch (UnauthorizedAccessException e) { // Options cannot be set PUtil.LogExcWarn(e); } catch (IOException e) { // Options cannot be set PUtil.LogExcWarn(e); } catch (JsonException e) { // Options cannot be set PUtil.LogExcWarn(e); } } /// /// Maps mod static IDs to their options. /// private readonly IDictionary modOptions; /// /// Maps mod assemblies to handlers that can fire their options. Only populated in /// the instantiated copy of POptions. /// private readonly IDictionary registered; public override Version Version => VERSION; public POptions() { modOptions = new Dictionary(8); registered = new Dictionary(32); InstanceData = modOptions; } /// /// Adds the Options button to the Mods screen. /// /// The mod entry where the button should be added. /// The index to use if it cannot be determined from the entry. /// The parent where the entries were added, used only if the /// fallback index is required. private void AddModOptions(object modEntry, int fallbackIndex, GameObject parent) { var mods = Global.Instance.modManager?.mods; if (!PPatchTools.TryGetFieldValue(modEntry, "mod_index", out int index)) index = fallbackIndex; if (!PPatchTools.TryGetFieldValue(modEntry, "rect_transform", out Transform transform)) transform = parent.transform.GetChild(index); if (mods != null && index >= 0 && index < mods.Count && transform != null) { var modSpec = mods[index]; string label = modSpec.staticID; if (modSpec.IsEnabledForActiveDlc() && registered.TryGetValue(label, out ModOptionsHandler handler)) { #if DEBUG PUtil.LogDebug("Adding options for mod: {0}".F(modSpec.staticID)); #endif // Create delegate to open settings dialog new PButton("ModSettingsButton") { FlexSize = Vector2.up, OnClick = handler.ShowDialog, ToolTip = PLibStrings.DIALOG_TITLE.text.F(modSpec.title), Text = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(PLibStrings. BUTTON_OPTIONS.text.ToLower()), Margin = OPTION_BUTTON_MARGIN // Move before the subscription and enable button }.SetKleiPinkStyle().AddTo(transform.gameObject, 4); } } } /// /// Initializes and stores the options table for quicker lookups later. /// public override void Initialize(Harmony plibInstance) { Instance = this; registered.Clear(); SetSharedData(PUtil.CreateAssemblyToModTable()); foreach (var optionsProvider in PRegistry.Instance.GetAllComponents(ID)) { var options = optionsProvider.GetInstanceData>(); if (options != null) // Map the static ID to the mod's option type and fire the correct handler foreach (var pair in options) { string label = pair.Key; if (registered.ContainsKey(label)) PUtil.LogWarning("Mod {0} already has options registered - only one option type per mod". F(label ?? "?")); else registered.Add(label, new ModOptionsHandler(optionsProvider, pair.Value)); } } plibInstance.Patch(typeof(ModsScreen), "BuildDisplay", postfix: PatchMethod(nameof( BuildDisplay_Postfix))); } public override void Process(uint operation, object args) { // POptions is no longer forwarded, show the dialog from the assembly that has the // options type - ignore calls that are not for our mod if (operation == 0 && PPatchTools.TryGetPropertyValue(args, nameof(OpenDialogArgs. OptionsType), out Type forType)) { foreach (var pair in modOptions) // Linear search is not phenomenal, but there is usually only one options // type per instance if (pair.Value == forType) { var dialog = new OptionsDialog(forType); if (PPatchTools.TryGetPropertyValue(args, nameof(OpenDialogArgs. OnClose), out Action handler)) dialog.OnClose = handler; dialog.ShowDialog(); break; } } } /// /// Registers a class as a mod options class. The type is registered for the mod /// instance specified, which is easily available in OnLoad. /// /// The mod for which the type will be registered. /// The class which will represent the options for this mod. public void RegisterOptions(KMod.UserMod2 mod, Type optionsType) { var kmod = mod?.mod; if (optionsType == null) throw new ArgumentNullException(nameof(optionsType)); if (kmod == null) throw new ArgumentNullException(nameof(mod)); RegisterForForwarding(); string id = kmod.staticID; if (modOptions.TryGetValue(id, out Type curType)) PUtil.LogWarning("Mod {0} already has options type {1}".F(id, curType. FullName)); else { modOptions.Add(id, optionsType); PUtil.LogDebug("Registered mod options class {0} for {1}".F(optionsType. FullName, id)); } } /// /// Opens the mod options dialog for a specific mod assembly. /// private sealed class ModOptionsHandler { /// /// The type whose options will be shown. /// private readonly Type forType; /// /// The options instance that will handle the dialog. /// private readonly PForwardedComponent options; internal ModOptionsHandler(PForwardedComponent options, Type forType) { this.forType = forType ?? throw new ArgumentNullException(nameof(forType)); this.options = options ?? throw new ArgumentNullException(nameof(options)); } /// /// Shows the options dialog. /// internal void ShowDialog(GameObject _) { options.Process(0, new OpenDialogArgs(forType, null)); } public override string ToString() { return "ModOptionsHandler[Type={0}]".F(forType); } } /// /// The arguments to be passed with message SHOW_DIALOG_MOD. /// private sealed class OpenDialogArgs { /// /// The handler (if not null) to be called when the dialog is closed. /// public Action OnClose { get; } /// /// The mod options type to show. /// public Type OptionsType { get; } public OpenDialogArgs(Type optionsType, Action onClose) { OnClose = onClose; OptionsType = optionsType ?? throw new ArgumentNullException( nameof(optionsType)); } public override string ToString() { return "OpenDialogArgs[Type={0}]".F(OptionsType); } } } }