From 143d38668fc88852363840aeaeca66205ce2af8b Mon Sep 17 00:00:00 2001 From: fox <110327098+mugongzi520@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:16:20 +0800 Subject: [PATCH 1/2] Add files via upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit baselib转移桥接 --- ...BaseLibToRitsuGeneratedReflectionMirror.cs | 558 ++++++ RitsuModSettingsSubmenu.cs | 1506 +++++++++++++++++ 2 files changed, 2064 insertions(+) create mode 100644 ModSettingsBaseLibToRitsuGeneratedReflectionMirror.cs create mode 100644 RitsuModSettingsSubmenu.cs diff --git a/ModSettingsBaseLibToRitsuGeneratedReflectionMirror.cs b/ModSettingsBaseLibToRitsuGeneratedReflectionMirror.cs new file mode 100644 index 0000000..311b762 --- /dev/null +++ b/ModSettingsBaseLibToRitsuGeneratedReflectionMirror.cs @@ -0,0 +1,558 @@ +using System.Collections; +using System.Reflection; +using Godot; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using STS2RitsuLib.Compat; + +namespace STS2RitsuLib.Settings +{ + /// + /// Mirrors converter-generated BaseLib compatibility configs into RitsuLib's settings UI. + /// + public static class ModSettingsBaseLibToRitsuGeneratedReflectionMirror + { + private const string RegistryTypeName = "BaseLibToRitsu.Generated.ModConfigRegistry"; + private const string ModConfigTypeName = "BaseLibToRitsu.Generated.ModConfig"; + private const string SectionAttrName = "BaseLibToRitsu.Generated.ConfigSectionAttribute"; + private const string HideUiAttrName = "BaseLibToRitsu.Generated.ConfigHideInUI"; + private const string ButtonAttrName = "BaseLibToRitsu.Generated.ConfigButtonAttribute"; + private const string ColorPickerAttrName = "BaseLibToRitsu.Generated.ConfigColorPickerAttribute"; + private const string HoverTipAttrName = "BaseLibToRitsu.Generated.ConfigHoverTipAttribute"; + private const string HoverTipsByDefaultAttrName = + "BaseLibToRitsu.Generated.ConfigHoverTipsByDefaultAttribute"; + private const string LegacyHoverTipsByDefaultAttrName = + "BaseLibToRitsu.Generated.HoverTipsByDefaultAttribute"; + + private static readonly Lock Gate = new(); + + /// + /// Registers mirrored settings pages for converter-generated BaseLib compatibility configs discovered in + /// loaded mod assemblies. + /// + /// Stable page id under each mod. + /// Sidebar ordering for mirrored pages. + /// Optional page title. + public static int TryRegisterMirroredPages( + string pageId = "baselib", + int sortOrder = 10_000, + ModSettingsText? pageTitle = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pageId); + + lock (Gate) + { + pageTitle ??= ModSettingsText.I18N(ModSettingsLocalization.Instance, "baselib.mirroredPage.title", + "Mod config"); + var pageDescription = ModSettingsText.I18N(ModSettingsLocalization.Instance, + "baselib.mirroredPage.description", + "This page mirrors BaseLib-generated mod configuration entries."); + + var added = 0; + foreach (var ctx in EnumerateContexts()) + added += RegisterFromContext(ctx, pageId, sortOrder, pageTitle, pageDescription); + return added; + } + } + + private static int RegisterFromContext( + MirrorContext ctx, + string pageId, + int sortOrder, + ModSettingsText pageTitle, + ModSettingsText pageDescription) + { + var modIdProp = ctx.ModConfigType.GetProperty("ModId", BindingFlags.Instance | BindingFlags.Public); + var propsField = ctx.ModConfigType.GetField("_configProperties", BindingFlags.Instance | BindingFlags.NonPublic); + var changed = ctx.ModConfigType.GetMethod("Changed", BindingFlags.Instance | BindingFlags.Public); + var save = ctx.ModConfigType.GetMethod("Save", BindingFlags.Instance | BindingFlags.Public); + var restore = ctx.ModConfigType.GetMethod("RestoreDefaultsNoConfirm", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (modIdProp == null || propsField == null || changed == null || save == null || restore == null) + return 0; + + var added = 0; + foreach (var config in EnumerateConfigs(ctx)) + { + var modId = modIdProp.GetValue(config) as string; + if (string.IsNullOrWhiteSpace(modId) || ModSettingsRegistry.TryGetPage(modId, pageId, out _)) + continue; + + var configType = config.GetType(); + if (!ModSettingsMirrorInteropPolicy.ShouldMirror(ModSettingsMirrorSource.BaseLib, modId, configType)) + continue; + + var host = new Host(config, changed, save, restore); + var propNames = ReadPropertyNames(propsField, config); + if (!TryBuildPage(modId, pageId, sortOrder, pageTitle, pageDescription, host, propNames, ctx, configType)) + continue; + + added++; + RitsuLibFramework.Logger.Info( + $"[ModSettingsBaseLibToRitsuGeneratedReflectionMirror] Registered '{modId}::{pageId}' from '{ctx.Assembly.GetName().Name}'."); + } + + return added; + } + + private static bool TryBuildPage( + string modId, + string pageId, + int sortOrder, + ModSettingsText pageTitle, + ModSettingsText pageDescription, + Host host, + IReadOnlySet propNames, + MirrorContext ctx, + Type configType) + { + var members = configType.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance) + .Where(m => IsVisibleMember(m, propNames, ctx.HideUiAttrType, ctx.ButtonAttrType)) + .OrderBy(GetSourceOrder) + .ToList(); + if (members.Count == 0) + return false; + + var sections = BuildSections(members, ctx.SectionAttrType); + if (sections.Count == 0) + return false; + + try + { + ModSettingsRegistry.Register(modId, builder => + { + builder.WithTitle(pageTitle) + .WithDescription(pageDescription) + .WithSortOrder(sortOrder) + .WithModDisplayName(ModSettingsText.Dynamic(() => host.ResolveModDisplayName(modId))); + + for (var i = 0; i < sections.Count; i++) + { + var sec = sections[i]; + var isLast = i == sections.Count - 1; + builder.AddSection(sec.Id, section => + { + if (!string.IsNullOrWhiteSpace(sec.Title)) + section.WithTitle(ModSettingsText.Dynamic(() => host.ResolveLabel(sec.Title!))); + + foreach (var member in sec.Entries) + { + if (member is PropertyInfo prop) + AddProperty(section, modId, prop, host, ctx, configType); + else if (member is MethodInfo method) + AddButton(section, method, host, ctx, configType); + } + + if (!isLast) + return; + + var label = host.ResolveBaseLibLabel("RestoreDefaultsButton"); + section.AddButton("baselib_restore_defaults", ModSettingsText.Literal(label), + ModSettingsText.Literal(label), () => ConfirmAndRestoreDefaults(host), + ModSettingsButtonTone.Danger); + }); + } + }, pageId); + return true; + } + catch (Exception ex) + { + RitsuLibFramework.Logger.Warn( + $"[ModSettingsBaseLibToRitsuGeneratedReflectionMirror] Failed to register '{modId}::{pageId}': {ex.Message}"); + return false; + } + } + + private static List BuildSections(List members, Type? sectionAttrType) + { + var result = new List(); + PendingSection current = new("main", null, []); + string? currentTitle = null; + + foreach (var member in members) + { + if (sectionAttrType != null && member.GetCustomAttribute(sectionAttrType, false) is { } attr) + { + var title = sectionAttrType.GetProperty("Name")?.GetValue(attr) as string; + if (!string.IsNullOrWhiteSpace(title) && title != currentTitle) + { + if (current.Entries.Count > 0) + result.Add(current); + currentTitle = title; + current = new($"sec_{StringHelper.Slugify(title)}_{result.Count}", title, []); + } + } + current.Entries.Add(member); + } + + if (current.Entries.Count > 0) + result.Add(current); + return result; + } + + private static void AddProperty( + ModSettingsSectionBuilder section, + string modId, + PropertyInfo prop, + Host host, + MirrorContext ctx, + Type configType) + { + var id = $"bl_{StringHelper.Slugify(prop.Name)}"; + var label = ModSettingsText.Dynamic(() => host.ResolveLabel(prop.Name)); + var desc = TryHoverTip(prop, configType, host, ctx.HoverTipAttrType, ctx.HoverTipsByDefaultAttrType, + ctx.LegacyHoverTipsByDefaultAttrType); + var dataKey = $"baselib::{prop.Name}"; + var type = prop.PropertyType; + + if (type == typeof(bool)) + { + section.AddToggle(id, label, CallbackForStaticProperty(modId, dataKey, prop, host), desc); + return; + } + + if (type == typeof(Color)) + { + var colorBinding = ModSettingsBindings.Callback(modId, dataKey, + () => ModSettingsColorControl.FormatStoredColorString((Color)prop.GetValue(null)!), + value => + { + if (string.IsNullOrWhiteSpace(value) || + !ModSettingsColorControl.TryDeserializeColorForSettings(value, out var color)) + return; + prop.SetValue(null, color); + host.NotifyChanged(); + }, + host.Save); + section.AddColor(id, label, colorBinding, desc, true, false); + return; + } + + var asColor = ctx.ColorPickerAttrType != null && prop.GetCustomAttribute(ctx.ColorPickerAttrType, false) != null; + if (type == typeof(string) && asColor) + { + section.AddColor(id, label, CallbackForStaticProperty(modId, dataKey, prop, host), desc, true, false); + return; + } + + if (type == typeof(string)) + { + section.AddString(id, label, CallbackForStaticProperty(modId, dataKey, prop, host), description: desc); + return; + } + + if (type == typeof(int)) + { + var intBinding = ModSettingsBindings.Callback(modId, dataKey, + () => Convert.ToDouble((int)prop.GetValue(null)!), + value => { prop.SetValue(null, (int)Math.Round(value)); host.NotifyChanged(); }, + host.Save); + section.AddSlider(id, label, intBinding, 0d, 100d, 1d, value => ((int)Math.Round(value)).ToString(), desc); + return; + } + + if (type == typeof(float)) + { +#pragma warning disable CS0618 + section.AddSlider(id, label, CallbackForStaticProperty(modId, dataKey, prop, host), 0f, 100f, 1f, + value => value.ToString("0.##"), desc); +#pragma warning restore CS0618 + return; + } + + if (type == typeof(double)) + { + section.AddSlider(id, label, CallbackForStaticProperty(modId, dataKey, prop, host), 0d, 100d, 1d, + value => value.ToString("0.##"), desc); + return; + } + + if (!type.IsEnum) + return; + + var enumBinding = typeof(ModSettingsBaseLibToRitsuGeneratedReflectionMirror) + .GetMethod(nameof(CallbackForStaticProperty), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(type) + .Invoke(null, [modId, dataKey, prop, host]); + typeof(ModSettingsSectionBuilder).GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Single(m => m is { Name: nameof(ModSettingsSectionBuilder.AddEnumChoice), IsGenericMethodDefinition: true }) + .MakeGenericMethod(type) + .Invoke(section, [id, label, enumBinding, null, desc, ModSettingsChoicePresentation.Stepper]); + } + + private static void AddButton( + ModSettingsSectionBuilder section, + MethodInfo method, + Host host, + MirrorContext ctx, + Type configType) + { + if (ctx.ButtonAttrType == null || method.GetCustomAttribute(ctx.ButtonAttrType, false) is not { } attr) + return; + + var key = ctx.ButtonAttrType.GetProperty("ButtonLabelKey")?.GetValue(attr) as string ?? method.Name; + var id = $"bl_btn_{StringHelper.Slugify(method.Name)}"; + var label = ModSettingsText.Dynamic(() => host.ResolveLabel(method.Name)); + var button = ModSettingsText.Dynamic(() => host.ResolveLabel(key)); + var desc = TryHoverTip(method, configType, host, ctx.HoverTipAttrType, ctx.HoverTipsByDefaultAttrType, + ctx.LegacyHoverTipsByDefaultAttrType); + section.AddButton(id, label, button, () => InvokeConfigButton(method, host), ModSettingsButtonTone.Normal, desc); + } + + private static void InvokeConfigButton(MethodInfo method, Host host) + { + try + { + var args = method.GetParameters(); + var values = new object?[args.Length]; + for (var i = 0; i < args.Length; i++) + values[i] = args[i].ParameterType.IsInstanceOfType(host.Instance) + ? host.Instance + : (args[i].ParameterType.IsValueType ? Activator.CreateInstance(args[i].ParameterType) : null); + method.Invoke(method.IsStatic ? null : host.Instance, values); + host.NotifyChanged(); + } + catch (Exception ex) + { + RitsuLibFramework.Logger.Warn( + $"[ModSettingsBaseLibToRitsuGeneratedReflectionMirror] ConfigButton '{method.Name}' failed: {ex.Message}"); + } + } + + private static ModSettingsText? TryHoverTip( + MemberInfo member, + Type configType, + Host host, + Type? hoverTipAttrType, + Type? hoverTipsByDefaultAttrType, + Type? legacyHoverTipsByDefaultAttrType) + { + if (!ShouldShowHoverTip(member, configType, hoverTipAttrType, hoverTipsByDefaultAttrType, + legacyHoverTipsByDefaultAttrType)) + return null; + + var prefix = host.ModPrefix; + if (string.IsNullOrWhiteSpace(prefix)) + return null; + + var key = prefix + StringHelper.Slugify(member.Name) + ".hover.desc"; + if (!LocString.Exists("settings_ui", key)) + return null; + return ModSettingsText.Dynamic(() => LocString.GetIfExists("settings_ui", key)?.GetFormattedText() ?? ""); + } + + private static bool ShouldShowHoverTip( + MemberInfo member, + Type configType, + Type? hoverTipAttrType, + Type? hoverTipsByDefaultAttrType, + Type? legacyHoverTipsByDefaultAttrType) + { + bool? explicitFlag = null; + if (hoverTipAttrType != null && member.GetCustomAttribute(hoverTipAttrType, false) is { } attr && + hoverTipAttrType.GetProperty("Enabled")?.GetValue(attr) is bool enabled) + { + explicitFlag = enabled; + } + + var byDefault = + (hoverTipsByDefaultAttrType != null && configType.GetCustomAttribute(hoverTipsByDefaultAttrType, false) != null) || + (legacyHoverTipsByDefaultAttrType != null && + configType.GetCustomAttribute(legacyHoverTipsByDefaultAttrType, false) != null); + return explicitFlag ?? byDefault; + } + + private static ModSettingsCallbackValueBinding CallbackForStaticProperty( + string modId, + string dataKey, + PropertyInfo prop, + Host host) + { + return ModSettingsBindings.Callback(modId, dataKey, () => (T)prop.GetValue(null)!, + value => { prop.SetValue(null, value); host.NotifyChanged(); }, host.Save); + } + + private static bool IsVisibleMember(MemberInfo member, IReadOnlySet propNames, Type? hideUiAttrType, Type? buttonAttrType) + { + return member switch + { + PropertyInfo p => propNames.Contains(p.Name) && (hideUiAttrType == null || p.GetCustomAttribute(hideUiAttrType) == null), + MethodInfo m => buttonAttrType != null && m.GetCustomAttribute(buttonAttrType) != null, + _ => false, + }; + } + + private static int GetSourceOrder(MemberInfo member) + { + return member switch + { + MethodInfo m => m.MetadataToken, + PropertyInfo p => p.GetMethod?.MetadataToken ?? p.SetMethod?.MetadataToken ?? 0, + _ => 0, + }; + } + + private static IReadOnlySet ReadPropertyNames(FieldInfo propsField, object config) + { + var result = new HashSet(StringComparer.Ordinal); + if (propsField.GetValue(config) is not IEnumerable enumerable) + return result; + foreach (var item in enumerable) + if (item is PropertyInfo prop) + result.Add(prop.Name); + return result; + } + + private static IEnumerable EnumerateConfigs(MirrorContext ctx) + { + var getAll = ctx.RegistryType.GetMethod("GetAll", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, + null, Type.EmptyTypes, null); + if (getAll?.Invoke(null, null) is IEnumerable all) + { + foreach (var item in all) + if (item != null) + yield return item; + yield break; + } + + if (ctx.RegistryType.GetField("Configs", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public) + ?.GetValue(null) is not IDictionary map) + yield break; + foreach (DictionaryEntry entry in map) + if (entry.Value != null) + yield return entry.Value; + } + + private static void ConfirmAndRestoreDefaults(Host host) + { + if (Engine.GetMainLoop() is not SceneTree { Root: { } root }) + { + host.RestoreDefaultsNoConfirm(); + return; + } + + var body = LocString.GetIfExists("settings_ui", "BASELIB-RESTORE_MODCONFIG_CONFIRMATION.body")?.GetFormattedText() + ?? "Reset all options for this mod to their default values?"; + var header = LocString.GetIfExists("settings_ui", "BASELIB-RESTORE_MODCONFIG_CONFIRMATION.header")?.GetFormattedText() + ?? "Restore defaults"; + var submenu = FindSubmenu(root); + var attachParent = (Node?)submenu ?? root; + ModSettingsUiFactory.ShowStyledConfirm(attachParent, header, body, + ModSettingsLocalization.Get("baselib.restoreDefaults.cancel", "Cancel"), + ModSettingsLocalization.Get("baselib.restoreDefaults.confirm", "Restore defaults"), true, () => + { + host.RestoreDefaultsNoConfirm(); + host.NotifyChanged(); + host.Save(); + submenu?.RequestRefresh(); + }); + } + + private static RitsuModSettingsSubmenu? FindSubmenu(Node root) + { + var queue = new Queue(); + queue.Enqueue(root); + while (queue.Count > 0) + { + var node = queue.Dequeue(); + if (node is RitsuModSettingsSubmenu submenu) + return submenu; + foreach (var child in node.GetChildren()) + queue.Enqueue(child); + } + return null; + } + + private static IEnumerable EnumerateContexts() + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + Type? registryType; + Type? modConfigType; + try + { + registryType = asm.GetType(RegistryTypeName, false); + modConfigType = asm.GetType(ModConfigTypeName, false); + } + catch + { + continue; + } + + if (registryType == null || modConfigType == null) + continue; + + yield return new(asm, registryType, modConfigType, asm.GetType(SectionAttrName, false), + asm.GetType(HideUiAttrName, false), asm.GetType(ButtonAttrName, false), + asm.GetType(ColorPickerAttrName, false), asm.GetType(HoverTipAttrName, false), + asm.GetType(HoverTipsByDefaultAttrName, false), asm.GetType(LegacyHoverTipsByDefaultAttrName, false)); + } + } + + private sealed record MirrorContext( + Assembly Assembly, + Type RegistryType, + Type ModConfigType, + Type? SectionAttrType, + Type? HideUiAttrType, + Type? ButtonAttrType, + Type? ColorPickerAttrType, + Type? HoverTipAttrType, + Type? HoverTipsByDefaultAttrType, + Type? LegacyHoverTipsByDefaultAttrType); + + private sealed record PendingSection(string Id, string? Title, List Entries); + + private sealed class Host(object instance, MethodInfo changed, MethodInfo save, MethodInfo restore) + { + public object Instance { get; } = instance; + public string ModPrefix => ResolveRootNamespace() is { Length: > 0 } root ? root.ToUpperInvariant() + "-" : ""; + + public void NotifyChanged() => changed.Invoke(Instance, []); + public void Save() => save.Invoke(Instance, []); + public void RestoreDefaultsNoConfirm() => restore.Invoke(Instance, []); + + public string ResolveLabel(string name) + { + var key = ModPrefix + StringHelper.Slugify(name) + ".title"; + return LocString.GetIfExists("settings_ui", key)?.GetFormattedText() ?? name; + } + + public string ResolveBaseLibLabel(string name) + { + var key = "BASELIB-" + StringHelper.Slugify(name) + ".title"; + return LocString.GetIfExists("settings_ui", key)?.GetFormattedText() ?? name; + } + + public string ResolveModDisplayName(string fallback) + { + var root = ResolveRootNamespace(); + if (!string.IsNullOrWhiteSpace(root)) + { + var key = root.ToUpperInvariant() + ".mod_title"; + var localized = LocString.GetIfExists("settings_ui", key)?.GetFormattedText(); + if (!string.IsNullOrWhiteSpace(localized)) + return localized; + } + + return Sts2ModManagerCompat.EnumerateModsForManifestLookup().FirstOrDefault(mod => + string.Equals(mod.manifest?.id, fallback, StringComparison.OrdinalIgnoreCase))?.manifest?.name + ?? fallback; + } + + private string ResolveRootNamespace() + { + var type = Instance.GetType(); + if (!string.IsNullOrWhiteSpace(type.Namespace)) + { + var dot = type.Namespace.IndexOf('.'); + return dot < 0 ? type.Namespace : type.Namespace[..dot]; + } + + var asm = type.Assembly.GetName().Name ?? ""; + var asmDot = asm.IndexOf('.'); + return asmDot < 0 ? asm : asm[..asmDot]; + } + } + } +} diff --git a/RitsuModSettingsSubmenu.cs b/RitsuModSettingsSubmenu.cs new file mode 100644 index 0000000..0e5d533 --- /dev/null +++ b/RitsuModSettingsSubmenu.cs @@ -0,0 +1,1506 @@ +using Godot; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.Assets; +using MegaCrit.Sts2.Core.ControllerInput; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes.GodotExtensions; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using MegaCrit.Sts2.Core.Nodes.Screens.ScreenContext; +using Timer = Godot.Timer; + +namespace STS2RitsuLib.Settings +{ + /// + /// Full-screen mod settings browser: sidebar (mods, pages, sections) and content pane. + /// + public partial class RitsuModSettingsSubmenu : NSubmenu + { + private const float SidebarWidth = 324f; + private const double AutosaveDelaySeconds = 0.35; + private const int ScrollContentRightGutter = 12; + + private static readonly StringName PaneSidebarHotkey = MegaInput.viewDeckAndTabLeft; + private static readonly StringName PaneContentHotkey = MegaInput.viewExhaustPileAndTabRight; + + private readonly List _contentFocusChain = []; + + private readonly HashSet _dirtyBindings = []; + + private readonly List<(Control Control, Func Predicate)> _dynamicVisibilityTargets = []; + private readonly HashSet _expandedModIds = new(StringComparer.OrdinalIgnoreCase); + + private readonly Dictionary _modButtons = + new(StringComparer.OrdinalIgnoreCase); + + private readonly Dictionary _pageButtons = + new(StringComparer.OrdinalIgnoreCase); + + private readonly List _refreshActions = []; + + private readonly Dictionary _sectionButtons = + new(StringComparer.OrdinalIgnoreCase); + + private readonly List _sidebarFocusChain = []; + + private VBoxContainer _contentList = null!; + + private bool _contentOnlyRebuildNeedsContentFocus; + private Control _contentPanelRoot = null!; + private bool _focusNavigationRefreshScheduled; + private bool _focusSelectedPageButtonOnNextRefresh; + private bool _guiFocusSignalConnected; + private Action? _hotkeyPaneContent; + private Action? _hotkeyPaneSidebar; + private Control? _initialFocusedControl; + private TextureRect? _leftPaneHotkeyIcon; + private bool _localeSubscribed; + private VBoxContainer _modButtonList = null!; + private Callable _modSettingsGuiFocusCallable; + private HBoxContainer _pageTabRow = null!; + private HBoxContainer? _paneHotkeyHintRow; + private bool _paneHotkeySignalsConnected; + private bool _paneHotkeysPushed; + private AcceptDialog? _pasteErrorDialog; + private bool _pendingRefreshFlush; + private Timer? _refreshDebounceTimer; + private TextureRect? _rightPaneHotkeyIcon; + private double _saveTimer = -1; + private ScrollContainer _scrollContainer = null!; + private string? _selectedModId; + private string? _selectedPageId; + private string? _selectedSectionId; + private Control _sidebarPanelRoot = null!; + private ScrollContainer _sidebarScrollContainer = null!; + private MegaRichTextLabel _subtitleLabel; + private bool _suppressScrollSync; + private MegaRichTextLabel _titleLabel; + private Callable _updatePaneHotkeyIconsCallable; + + /// + /// Builds layout (header, sidebar, scrollable content) and wires initial structure. + /// + public RitsuModSettingsSubmenu() + { + AnchorRight = 1f; + AnchorBottom = 1f; + GrowHorizontal = GrowDirection.Both; + GrowVertical = GrowDirection.Both; + FocusMode = FocusModeEnum.None; + + var frame = new MarginContainer + { + Name = "Frame", + AnchorRight = 1f, + AnchorBottom = 1f, + GrowHorizontal = GrowDirection.Both, + GrowVertical = GrowDirection.Both, + MouseFilter = MouseFilterEnum.Ignore, + }; + frame.AddThemeConstantOverride("margin_left", 160); + frame.AddThemeConstantOverride("margin_top", 72); + frame.AddThemeConstantOverride("margin_right", 160); + frame.AddThemeConstantOverride("margin_bottom", 72); + AddChild(frame); + + var root = new VBoxContainer + { + Name = "Root", + AnchorRight = 1f, + AnchorBottom = 1f, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + root.AddThemeConstantOverride("separation", 18); + frame.AddChild(root); + + var header = new VBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + header.AddThemeConstantOverride("separation", 6); + root.AddChild(header); + + _titleLabel = CreateTitleLabel(32, HorizontalAlignment.Left); + _titleLabel.CustomMinimumSize = new(0f, 42f); + _titleLabel.SizeFlagsHorizontal = SizeFlags.ExpandFill; + header.AddChild(_titleLabel); + + _subtitleLabel = CreateTitleLabel(16, HorizontalAlignment.Left); + _subtitleLabel.CustomMinimumSize = new(0f, 24f); + _subtitleLabel.Modulate = new(0.82f, 0.79f, 0.72f, 0.92f); + _subtitleLabel.SizeFlagsHorizontal = SizeFlags.ExpandFill; + header.AddChild(_subtitleLabel); + + root.AddChild(CreatePaneHotkeyHintRow()); + + var body = new HBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + body.AddThemeConstantOverride("separation", 20); + root.AddChild(body); + + body.AddChild(CreateSidebarPanel()); + body.AddChild(CreateContentPanel()); + } + + /// + protected override Control? InitialFocusedControl => _initialFocusedControl; + + /// + public override void _Ready() + { + var backButton = PreloadManager.Cache.GetScene(SceneHelper.GetScenePath("ui/back_button")) + .Instantiate(); + backButton.Name = "BackButton"; + AddChild(backButton); + + ConnectSignals(); + _updatePaneHotkeyIconsCallable = Callable.From(UpdatePaneHotkeyHintIcons); + TryConnectPaneHotkeyStyleSignals(); + _scrollContainer.GetVScrollBar().ValueChanged += OnContentScrollChanged; + SubscribeLocaleChanges(); + Rebuild(); + ProcessMode = ProcessModeEnum.Disabled; + FocusMode = FocusModeEnum.None; + } + + /// + protected override void ConnectSignals() + { + base.ConnectSignals(); + var vp = GetViewport(); + if (vp == null) + return; + + _modSettingsGuiFocusCallable = Callable.From(OnModSettingsGuiFocusChanged); + vp.Connect(Viewport.SignalName.GuiFocusChanged, _modSettingsGuiFocusCallable); + _guiFocusSignalConnected = true; + } + + /// + public override void _ExitTree() + { + var vp = GetViewport(); + if (vp != null && _guiFocusSignalConnected && + vp.IsConnected(Viewport.SignalName.GuiFocusChanged, _modSettingsGuiFocusCallable)) + { + vp.Disconnect(Viewport.SignalName.GuiFocusChanged, _modSettingsGuiFocusCallable); + _guiFocusSignalConnected = false; + } + + TryDisconnectPaneHotkeyStyleSignals(); + PopPaneHotkeys(); + base._ExitTree(); + FlushDirtyBindings(); + UnsubscribeLocaleChanges(); + } + + /// + public override void OnSubmenuOpened() + { + base.OnSubmenuOpened(); + FocusMode = FocusModeEnum.None; + FocusBehaviorRecursive = FocusBehaviorRecursiveEnum.Enabled; + ProcessMode = ProcessModeEnum.Inherit; + Rebuild(); + } + + /// + public override void OnSubmenuClosed() + { + PopPaneHotkeys(); + FlushDirtyBindings(); + ProcessMode = ProcessModeEnum.Disabled; + Callable.From(this.UpdateControllerNavEnabled).CallDeferred(); + base.OnSubmenuClosed(); + } + + /// + protected override void OnSubmenuShown() + { + base.OnSubmenuShown(); + SetProcessInput(true); + PushPaneHotkeys(); + UpdatePaneHotkeyHintIcons(); + } + + /// + protected override void OnSubmenuHidden() + { + PopPaneHotkeys(); + FlushPendingRefreshActionsImmediate(); + FlushDirtyBindings(); + ProcessMode = ProcessModeEnum.Disabled; + Callable.From(this.UpdateControllerNavEnabled).CallDeferred(); + base.OnSubmenuHidden(); + } + + /// + public override void _Process(double delta) + { + base._Process(delta); + if (_saveTimer < 0) + return; + + _saveTimer -= delta; + if (_saveTimer <= 0) + FlushDirtyBindings(); + } + + internal void MarkDirty(IModSettingsBinding binding) + { + _dirtyBindings.Add(binding); + _saveTimer = AutosaveDelaySeconds; + } + + internal void RequestRefresh() + { + _pendingRefreshFlush = true; + EnsureRefreshDebounceTimer(); + _refreshDebounceTimer!.Stop(); + _refreshDebounceTimer.Start(); + } + + internal void RegisterRefreshAction(Action action) + { + _refreshActions.Add(action); + } + + internal void RegisterDynamicVisibility(Control control, Func predicate) + { + ArgumentNullException.ThrowIfNull(control); + ArgumentNullException.ThrowIfNull(predicate); + _dynamicVisibilityTargets.Add((control, predicate)); + } + + private void ApplyDynamicVisibilityTargets() + { + foreach (var (control, predicate) in _dynamicVisibilityTargets) + { + if (!IsInstanceValid(control)) + continue; + try + { + control.Visible = predicate(); + } + catch + { + control.Visible = true; + } + } + } + + internal void ShowPasteFailure(ModSettingsPasteFailureReason reason) + { + if (reason == ModSettingsPasteFailureReason.None) + return; + + var key = reason switch + { + ModSettingsPasteFailureReason.ClipboardEmpty => "clipboard.pasteFailedEmpty", + ModSettingsPasteFailureReason.PasteRuleDenied => "clipboard.pasteFailedBlocked", + _ => "clipboard.pasteFailedIncompatible", + }; + + var fallback = reason switch + { + ModSettingsPasteFailureReason.ClipboardEmpty => "Clipboard is empty or unavailable.", + ModSettingsPasteFailureReason.PasteRuleDenied => "Paste was blocked by a custom rule.", + _ => "Clipboard contents are not compatible with this setting.", + }; + + EnsurePasteErrorDialog(); + _pasteErrorDialog!.Title = + ModSettingsLocalization.Get("clipboard.pasteFailedTitle", "Paste failed"); + _pasteErrorDialog.OkButtonText = ModSettingsLocalization.Get("clipboard.pasteErrorOk", "OK"); + _pasteErrorDialog.DialogText = ModSettingsLocalization.Get(key, fallback); + _pasteErrorDialog.PopupCentered(); + } + + private void EnsurePasteErrorDialog() + { + if (_pasteErrorDialog != null) + return; + + _pasteErrorDialog = new() { Name = "PasteErrorDialog" }; + AddChild(_pasteErrorDialog); + } + + private void EnsureRefreshDebounceTimer() + { + if (_refreshDebounceTimer != null) + return; + + _refreshDebounceTimer = new() + { + Name = "ModSettingsRefreshDebounce", + OneShot = true, + WaitTime = 0.07, + ProcessCallback = Timer.TimerProcessCallback.Idle, + }; + AddChild(_refreshDebounceTimer); + _refreshDebounceTimer.Timeout += OnRefreshDebounceTimeout; + } + + private void OnRefreshDebounceTimeout() + { + if (!_pendingRefreshFlush) + return; + + _pendingRefreshFlush = false; + foreach (var action in _refreshActions.ToArray()) + action(); + ApplyDynamicVisibilityTargets(); + } + + private void CancelDeferredRefreshFlush() + { + _pendingRefreshFlush = false; + _refreshDebounceTimer?.Stop(); + } + + private void FlushPendingRefreshActionsImmediate() + { + _refreshDebounceTimer?.Stop(); + if (!_pendingRefreshFlush) + return; + + _pendingRefreshFlush = false; + foreach (var action in _refreshActions.ToArray()) + action(); + } + + private void OnModSettingsGuiFocusChanged(Control node) + { + if (!Visible || !IsInstanceValid(this) || !IsInstanceValid(node)) + return; + + if (!ActiveScreenContext.Instance.IsCurrent(this)) + return; + + if (NControllerManager.Instance?.IsUsingController != true) + return; + + if (_suppressScrollSync) + return; + + if (_sidebarScrollContainer.IsAncestorOf(node)) + _sidebarScrollContainer.EnsureControlVisible(node); + else if (_scrollContainer.IsAncestorOf(node)) + _scrollContainer.EnsureControlVisible(node); + } + + /// + /// Selects a mod in the sidebar, optionally opening , and rebuilds the UI. + /// + public void SelectMod(string modId, string? pageId = null) + { + _selectedModId = modId; + _selectedPageId = pageId; + _selectedSectionId = null; + ExpandOnlyMod(modId); + _focusSelectedPageButtonOnNextRefresh = true; + Rebuild(); + } + + /// + /// Switches to within the currently selected mod. + /// + public void NavigateToPage(string pageId) + { + if (string.IsNullOrWhiteSpace(_selectedModId)) + return; + + _selectedPageId = pageId; + _selectedSectionId = null; + ModSettingsBaseLibReflectionMirror.TryRegisterMirroredPages(); + ModSettingsBaseLibToRitsuGeneratedReflectionMirror.TryRegisterMirroredPages(); + ModSettingsModConfigReflectionMirror.TryRegisterMirroredPages(); + ModSettingsRuntimeReflectionInteropMirror.TryRegisterMirroredPages(); + Rebuild(); + } + + /// + /// Opens and scrolls/focuses . + /// + public void NavigateToSection(string pageId, string sectionId) + { + if (string.IsNullOrWhiteSpace(_selectedModId)) + return; + + if (string.Equals(_selectedPageId, pageId, StringComparison.OrdinalIgnoreCase) && + string.Equals(_selectedSectionId, sectionId, StringComparison.OrdinalIgnoreCase)) + { + Callable.From(ScrollToSelectedAnchor).CallDeferred(); + RefreshFocusNavigation(); + Callable.From(() => + { + if (_sectionButtons.TryGetValue(sectionId, out var btn) && btn.IsVisibleInTree()) + btn.GrabFocus(); + }).CallDeferred(); + return; + } + + var pageChanged = !string.Equals(_selectedPageId, pageId, StringComparison.OrdinalIgnoreCase); + _selectedPageId = pageId; + _selectedSectionId = sectionId; + ModSettingsBaseLibReflectionMirror.TryRegisterMirroredPages(); + ModSettingsBaseLibToRitsuGeneratedReflectionMirror.TryRegisterMirroredPages(); + ModSettingsModConfigReflectionMirror.TryRegisterMirroredPages(); + ModSettingsRuntimeReflectionInteropMirror.TryRegisterMirroredPages(); + if (pageChanged) + Rebuild(); + else + RebuildContent(); + } + + private Control CreatePaneHotkeyHintRow() + { + var row = new HBoxContainer + { + Name = "PaneHotkeyHints", + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + Visible = false, + }; + _paneHotkeyHintRow = row; + + _leftPaneHotkeyIcon = new() + { + CustomMinimumSize = new(44f, 32f), + MouseFilter = MouseFilterEnum.Ignore, + ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize, + StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered, + }; + row.AddChild(_leftPaneHotkeyIcon); + + row.AddChild(new Control + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }); + + _rightPaneHotkeyIcon = new() + { + CustomMinimumSize = new(44f, 32f), + MouseFilter = MouseFilterEnum.Ignore, + ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize, + StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered, + }; + row.AddChild(_rightPaneHotkeyIcon); + + return row; + } + + private void TryConnectPaneHotkeyStyleSignals() + { + if (_paneHotkeySignalsConnected) + return; + + if (NControllerManager.Instance != null) + { + NControllerManager.Instance.Connect(NControllerManager.SignalName.MouseDetected, + _updatePaneHotkeyIconsCallable); + NControllerManager.Instance.Connect(NControllerManager.SignalName.ControllerDetected, + _updatePaneHotkeyIconsCallable); + } + + if (NInputManager.Instance != null) + NInputManager.Instance.Connect(NInputManager.SignalName.InputRebound, _updatePaneHotkeyIconsCallable); + + _paneHotkeySignalsConnected = true; + } + + private void TryDisconnectPaneHotkeyStyleSignals() + { + if (!_paneHotkeySignalsConnected) + return; + + if (NControllerManager.Instance != null) + { + NControllerManager.Instance.Disconnect(NControllerManager.SignalName.MouseDetected, + _updatePaneHotkeyIconsCallable); + NControllerManager.Instance.Disconnect(NControllerManager.SignalName.ControllerDetected, + _updatePaneHotkeyIconsCallable); + } + + if (NInputManager.Instance != null) + NInputManager.Instance.Disconnect(NInputManager.SignalName.InputRebound, + _updatePaneHotkeyIconsCallable); + + _paneHotkeySignalsConnected = false; + } + + private void UpdatePaneHotkeyHintIcons() + { + if (_paneHotkeyHintRow == null) + return; + + var usingController = NControllerManager.Instance?.IsUsingController ?? false; + _paneHotkeyHintRow.Visible = usingController && Visible; + if (!usingController) + return; + + if (NInputManager.Instance == null) + return; + + _leftPaneHotkeyIcon?.Texture = NInputManager.Instance.GetHotkeyIcon(PaneSidebarHotkey); + _rightPaneHotkeyIcon?.Texture = NInputManager.Instance.GetHotkeyIcon(PaneContentHotkey); + } + + private void PushPaneHotkeys() + { + if (_paneHotkeysPushed || NHotkeyManager.Instance == null) + return; + + _hotkeyPaneSidebar = OnHotkeyPressedFocusSidebar; + _hotkeyPaneContent = OnHotkeyPressedFocusContent; + NHotkeyManager.Instance.PushHotkeyPressedBinding(PaneSidebarHotkey, _hotkeyPaneSidebar); + NHotkeyManager.Instance.PushHotkeyPressedBinding(PaneContentHotkey, _hotkeyPaneContent); + _paneHotkeysPushed = true; + } + + private void PopPaneHotkeys() + { + if (!_paneHotkeysPushed || NHotkeyManager.Instance == null) + return; + + if (_hotkeyPaneSidebar != null) + NHotkeyManager.Instance.RemoveHotkeyPressedBinding(PaneSidebarHotkey, _hotkeyPaneSidebar); + if (_hotkeyPaneContent != null) + NHotkeyManager.Instance.RemoveHotkeyPressedBinding(PaneContentHotkey, _hotkeyPaneContent); + + _hotkeyPaneSidebar = null; + _hotkeyPaneContent = null; + _paneHotkeysPushed = false; + } + + private void OnHotkeyPressedFocusSidebar() + { + if (!Visible || !IsInstanceValid(this) || !ActiveScreenContext.Instance.IsCurrent(this)) + return; + + FocusSidebarPaneFromInput(); + } + + private void OnHotkeyPressedFocusContent() + { + if (!Visible || !IsInstanceValid(this) || !ActiveScreenContext.Instance.IsCurrent(this)) + return; + + FocusContentPaneFromInput(); + } + + private static bool IsFocusUnderPopupOrTransientWindow(Control? c) + { + for (Node? n = c; n != null; n = n.GetParent()) + switch (n) + { + case PopupMenu: + case Window { Visible: true, PopupWindow: true }: + return true; + } + + return false; + } + + private void FocusContentPaneFromInput() + { + if (!IsInstanceValid(this) || !Visible || !ActiveScreenContext.Instance.IsCurrent(this)) + return; + + var fo = GetViewport()?.GuiGetFocusOwner(); + if (IsFocusUnderPopupOrTransientWindow(fo)) + return; + + if (fo != null && IsInstanceValid(fo) && _contentPanelRoot.IsAncestorOf(fo)) + return; + + RebuildFocusChainsOnly(); + GrabControlDeferred(ResolveContentFocusFirstInContentPanel()); + } + + private Control? ResolveContentFocusFirstInContentPanel() + { + return _contentFocusChain.FirstOrDefault(); + } + + private Control? ResolveContentFocusTargetForSection() + { + if (_contentFocusChain.Count == 0) + return null; + + if (!string.IsNullOrWhiteSpace(_selectedSectionId)) + if (_contentList.FindChild($"Section_{_selectedSectionId}", true, false) is Control anchor) + foreach (var c in _contentFocusChain.Where(UnderScrollBody) + .Where(c => anchor == c || anchor.IsAncestorOf(c))) + return c; + + foreach (var c in _contentFocusChain.Where(UnderScrollBody)) + return c; + + return _contentFocusChain.FirstOrDefault(); + + bool UnderScrollBody(Control c) + { + return _contentList.IsAncestorOf(c); + } + } + + private void FocusSidebarPaneFromInput() + { + if (!IsInstanceValid(this) || !Visible || !ActiveScreenContext.Instance.IsCurrent(this)) + return; + + var fo = GetViewport()?.GuiGetFocusOwner(); + if (IsFocusUnderPopupOrTransientWindow(fo)) + return; + + if (fo != null && IsInstanceValid(fo) && _sidebarPanelRoot.IsAncestorOf(fo)) + return; + + RebuildFocusChainsOnly(); + GrabControlDeferred(ResolveSidebarTargetMatchingContent()); + } + + private Control? ResolveSidebarTargetMatchingContent() + { + if (!string.IsNullOrWhiteSpace(_selectedSectionId) + && _sectionButtons.TryGetValue(_selectedSectionId, out var sectionBtn) + && sectionBtn.IsVisibleInTree()) + return sectionBtn; + + if (!string.IsNullOrWhiteSpace(_selectedPageId) + && _pageButtons.TryGetValue(_selectedPageId, out var pageBtn) + && pageBtn.IsVisibleInTree()) + return pageBtn; + + if (!string.IsNullOrWhiteSpace(_selectedModId) + && _modButtons.TryGetValue(_selectedModId, out var modBtn) + && modBtn.IsVisibleInTree()) + return modBtn; + + return _sidebarFocusChain.FirstOrDefault(); + } + + private Control? ResolveInitialSidebarFocus() + { + if (_focusSelectedPageButtonOnNextRefresh) + { + _focusSelectedPageButtonOnNextRefresh = false; + if (!string.IsNullOrWhiteSpace(_selectedPageId) + && _pageButtons.TryGetValue(_selectedPageId, out var pageButton) + && pageButton.Visible) + return pageButton; + + if (!string.IsNullOrWhiteSpace(_selectedModId) + && _modButtons.TryGetValue(_selectedModId, out var modButton) + && modButton.Visible) + return modButton; + } + + if (!string.IsNullOrWhiteSpace(_selectedSectionId) + && _sectionButtons.TryGetValue(_selectedSectionId, out var sectionBtn) + && sectionBtn.IsVisibleInTree()) + return sectionBtn; + + if (!string.IsNullOrWhiteSpace(_selectedPageId) + && _pageButtons.TryGetValue(_selectedPageId, out var pb) + && pb.Visible) + return pb; + + if (!string.IsNullOrWhiteSpace(_selectedModId) + && _modButtons.TryGetValue(_selectedModId, out var mb) + && mb.Visible) + return mb; + + return null; + } + + private Control CreateSidebarPanel() + { + var panel = new Panel + { + Name = "RitsuSidebarPanel", + CustomMinimumSize = new(SidebarWidth, 0f), + SizeFlagsVertical = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + _sidebarPanelRoot = panel; + panel.AddThemeStyleboxOverride("panel", CreatePanelStyle(new(0.10f, 0.115f, 0.145f, 0.96f))); + + var frame = new MarginContainer + { + AnchorRight = 1f, + AnchorBottom = 1f, + MouseFilter = MouseFilterEnum.Ignore, + }; + frame.AddThemeConstantOverride("margin_left", 16); + frame.AddThemeConstantOverride("margin_top", 16); + frame.AddThemeConstantOverride("margin_right", 16); + frame.AddThemeConstantOverride("margin_bottom", 16); + panel.AddChild(frame); + + var root = new VBoxContainer + { + AnchorRight = 1f, + AnchorBottom = 1f, + MouseFilter = MouseFilterEnum.Ignore, + }; + root.AddThemeConstantOverride("separation", 14); + frame.AddChild(root); + + var headerCard = new PanelContainer + { + MouseFilter = MouseFilterEnum.Ignore, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + }; + headerCard.AddThemeStyleboxOverride("panel", ModSettingsUiFactory.CreateInsetSurfaceStyle()); + root.AddChild(headerCard); + + var headerBox = new VBoxContainer + { + MouseFilter = MouseFilterEnum.Ignore, + }; + headerBox.AddThemeConstantOverride("separation", 4); + headerCard.AddChild(headerBox); + + var headerTitle = + ModSettingsUiFactory.CreateSectionTitle(ModSettingsLocalization.Get("sidebar.title", "Mods")); + headerTitle.CustomMinimumSize = new(0f, 30f); + headerBox.AddChild(headerTitle); + + headerBox.AddChild(ModSettingsUiFactory.CreateInlineDescription( + ModSettingsLocalization.Get("sidebar.subtitle", "Browse mods, pages, and sections."))); + + var scroll = new ScrollContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, + FollowFocus = false, + FocusMode = FocusModeEnum.None, + }; + _sidebarScrollContainer = scroll; + root.AddChild(scroll); + + var sidebarScrollFrame = new MarginContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + sidebarScrollFrame.AddThemeConstantOverride("margin_right", ScrollContentRightGutter); + scroll.AddChild(sidebarScrollFrame); + + _modButtonList = new() + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + _modButtonList.AddThemeConstantOverride("separation", 12); + sidebarScrollFrame.AddChild(_modButtonList); + return panel; + } + + private Control CreateContentPanel() + { + var panel = new Panel + { + Name = "RitsuContentPanel", + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + _contentPanelRoot = panel; + panel.AddThemeStyleboxOverride("panel", CreatePanelStyle(new(0.08f, 0.095f, 0.125f, 0.98f))); + + var frame = new MarginContainer + { + AnchorRight = 1f, + AnchorBottom = 1f, + MouseFilter = MouseFilterEnum.Ignore, + }; + frame.AddThemeConstantOverride("margin_left", 18); + frame.AddThemeConstantOverride("margin_top", 18); + frame.AddThemeConstantOverride("margin_right", 18); + frame.AddThemeConstantOverride("margin_bottom", 18); + panel.AddChild(frame); + + var root = new VBoxContainer + { + AnchorRight = 1f, + AnchorBottom = 1f, + MouseFilter = MouseFilterEnum.Ignore, + }; + root.AddThemeConstantOverride("separation", 10); + frame.AddChild(root); + + _pageTabRow = new() + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + _pageTabRow.AddThemeConstantOverride("separation", 8); + root.AddChild(_pageTabRow); + + _scrollContainer = new() + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, + FollowFocus = true, + FocusMode = FocusModeEnum.None, + }; + root.AddChild(_scrollContainer); + + var contentScrollFrame = new MarginContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + contentScrollFrame.AddThemeConstantOverride("margin_right", ScrollContentRightGutter); + _scrollContainer.AddChild(contentScrollFrame); + + _contentList = new() + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + _contentList.AddThemeConstantOverride("separation", 8); + contentScrollFrame.AddChild(_contentList); + + return panel; + } + + private void Rebuild() + { + ModSettingsBaseLibReflectionMirror.TryRegisterMirroredPages(); + ModSettingsBaseLibToRitsuGeneratedReflectionMirror.TryRegisterMirroredPages(); + ModSettingsModConfigReflectionMirror.TryRegisterMirroredPages(); + ModSettingsRuntimeReflectionInteropMirror.TryRegisterMirroredPages(); + ApplyStaticTexts(); + RebuildSidebar(); + RebuildContent(true); + } + + private void RebuildSidebar() + { + _dynamicVisibilityTargets.Clear(); + _modButtonList.FreeChildren(); + _modButtons.Clear(); + _pageButtons.Clear(); + _sectionButtons.Clear(); + + var rootPages = ModSettingsRegistry.GetPages() + .Where(page => string.IsNullOrWhiteSpace(page.ParentPageId)) + .GroupBy(page => page.ModId, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => ModSettingsRegistry.GetModSidebarOrder(group.Key)) + .ThenBy(group => ModSettingsLocalization.ResolveModName(group.Key, group.Key), + StringComparer.OrdinalIgnoreCase) + .ThenBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (rootPages.Length == 0) + { + _selectedModId = null; + return; + } + + if (string.IsNullOrWhiteSpace(_selectedModId) || rootPages.All(group => + !string.Equals(group.Key, _selectedModId, StringComparison.OrdinalIgnoreCase))) + _selectedModId = rootPages[0].Key; + + ExpandOnlyMod(_selectedModId); + + foreach (var group in rootPages) + { + var modId = group.Key; + var pages = ModSettingsRegistry.GetPages() + .Where(page => string.Equals(page.ModId, modId, StringComparison.OrdinalIgnoreCase)) + .OrderBy(ModSettingsRegistry.GetEffectivePageSortOrder) + .ThenBy(page => page.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var section = new VBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + section.AddThemeConstantOverride("separation", 8); + + var card = new PanelContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + card.AddThemeStyleboxOverride("panel", CreateSidebarGroupStyle( + string.Equals(modId, _selectedModId, StringComparison.OrdinalIgnoreCase))); + section.AddChild(card); + + var cardContent = new VBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + cardContent.AddThemeConstantOverride("separation", 8); + card.AddChild(cardContent); + + var button = ModSettingsUiFactory.CreateSidebarButton( + ResolveSidebarModTitle(group.ToArray()), + () => + { + _selectedModId = modId; + _selectedPageId = pages.FirstOrDefault(page => string.IsNullOrWhiteSpace(page.ParentPageId)) + ?.Id; + _selectedSectionId = null; + ExpandOnlyMod(modId); + _focusSelectedPageButtonOnNextRefresh = true; + Rebuild(); + }, + ModSettingsSidebarItemKind.ModGroup, + _expandedModIds.Contains(modId) ? "▼" : "▶"); + button.Name = $"Mod_{modId}"; + cardContent.AddChild(button); + + var isExpanded = _expandedModIds.Contains(modId); + if (isExpanded) + { + var meta = ModSettingsUiFactory.CreateInlineDescription(string.Format( + ModSettingsLocalization.Get("sidebar.modMeta", "{0} pages"), + pages.Length)); + cardContent.AddChild(meta); + + var navStack = new VBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + navStack.AddThemeConstantOverride("separation", 6); + cardContent.AddChild(navStack); + + foreach (var page in pages.Where(page => string.IsNullOrWhiteSpace(page.ParentPageId))) + navStack.AddChild(CreateSidebarPageTreeButton(pages, page, 1)); + } + + _modButtonList.AddChild(section); + _modButtons[modId] = button; + } + } + + private void RebuildContent(bool fromFullRebuild = false) + { + CancelDeferredRefreshFlush(); + _contentOnlyRebuildNeedsContentFocus = !fromFullRebuild; + _pageTabRow.FreeChildren(); + _pageTabRow.Visible = false; + _contentList.FreeChildren(); + _refreshActions.Clear(); + + foreach (var pair in _modButtons) + pair.Value.SetSelected(string.Equals(pair.Key, _selectedModId, StringComparison.OrdinalIgnoreCase)); + + foreach (var pair in _pageButtons) + pair.Value.SetSelected(string.Equals(pair.Key, _selectedPageId, StringComparison.OrdinalIgnoreCase)); + + foreach (var pair in _sectionButtons) + pair.Value.SetSelected(string.Equals(pair.Key, _selectedSectionId, StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrWhiteSpace(_selectedModId)) + { + _contentList.AddChild(CreateEmptyStateLabel(ModSettingsLocalization.Get("empty.none", + "No mod settings pages are currently registered."))); + RefreshFocusNavigation(); + return; + } + + var rootPages = ModSettingsRegistry.GetPages() + .Where(page => string.Equals(page.ModId, _selectedModId, StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(page.ParentPageId)) + .OrderBy(ModSettingsRegistry.GetEffectivePageSortOrder) + .ThenBy(page => page.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (rootPages.Length == 0) + { + _contentList.AddChild(CreateEmptyStateLabel(ModSettingsLocalization.Get("empty.mod", + "This mod does not currently expose a settings page."))); + RefreshFocusNavigation(); + return; + } + + if (string.IsNullOrWhiteSpace(_selectedPageId) || + (rootPages.All(page => !string.Equals(page.Id, _selectedPageId, StringComparison.OrdinalIgnoreCase)) && + ModSettingsRegistry.GetPages().All(page => + !string.Equals(page.ModId, _selectedModId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(page.Id, _selectedPageId, StringComparison.OrdinalIgnoreCase)))) + _selectedPageId = rootPages[0].Id; + + var pageToRender = ResolveSelectedPage(); + if (pageToRender == null) + { + _contentList.AddChild(CreateEmptyStateLabel(ModSettingsLocalization.Get("empty.page", + "The selected settings page could not be found."))); + RefreshFocusNavigation(); + return; + } + + var context = new ModSettingsUiContext(this); + var isChildPage = !string.IsNullOrWhiteSpace(pageToRender.ParentPageId); + Action onBack = isChildPage + ? () => + { + _selectedPageId = pageToRender.ParentPageId!; + RebuildContent(); + } + : static () => { }; + + _pageTabRow.Visible = true; + var pageHeader = ModSettingsUiFactory.CreateModSettingsPageHeaderBar(context, pageToRender, isChildPage, + onBack); + pageHeader.SizeFlagsHorizontal = SizeFlags.ExpandFill; + _pageTabRow.AddChild(pageHeader); + + _contentList.AddChild(ModSettingsUiFactory.CreatePageContent(context, pageToRender)); + ApplyDynamicVisibilityTargets(); + RefreshFocusNavigation(); + Callable.From(ScrollToSelectedAnchor).CallDeferred(); + } + + private Control CreateSidebarPageTreeButton(IReadOnlyList pages, ModSettingsPage page, + int depth) + { + var button = ModSettingsUiFactory.CreateSidebarButton( + ResolvePageTabTitle(page), () => + { + var samePage = string.Equals(_selectedPageId, page.Id, StringComparison.OrdinalIgnoreCase); + _selectedModId = page.ModId; + _selectedPageId = page.Id; + if (!samePage) + _selectedSectionId = null; + ExpandOnlyMod(page.ModId); + Rebuild(); + }, + ModSettingsSidebarItemKind.Page, + "◦", + Math.Max(0, depth - 1)); + button.CustomMinimumSize = new(0f, 48f); + button.SetSelected(string.Equals(page.Id, _selectedPageId, StringComparison.OrdinalIgnoreCase)); + _pageButtons[page.Id] = button; + if (page.VisibleWhen != null) + RegisterDynamicVisibility(button, page.VisibleWhen); + + var container = new VBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + container.AddThemeConstantOverride("separation", 4); + container.AddChild(button); + + if (string.Equals(page.Id, _selectedPageId, StringComparison.OrdinalIgnoreCase)) + { + var sectionRail = new VBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + sectionRail.AddThemeConstantOverride("separation", 4); + foreach (var section in page.Sections) + { + var sectionButton = ModSettingsUiFactory.CreateSidebarButton(ResolveSectionTitle(section), () => + { + _selectedModId = page.ModId; + NavigateToSection(page.Id, section.Id); + }, + ModSettingsSidebarItemKind.Section, + "·", + depth + 1); + sectionButton.CustomMinimumSize = new(0f, 40f); + sectionButton.SetSelected(string.Equals(section.Id, _selectedSectionId, + StringComparison.OrdinalIgnoreCase)); + _sectionButtons[section.Id] = sectionButton; + if (section.VisibleWhen != null) + RegisterDynamicVisibility(sectionButton, section.VisibleWhen); + sectionRail.AddChild(sectionButton); + } + + container.AddChild(sectionRail); + } + + foreach (var child in pages.Where(candidate => + string.Equals(candidate.ParentPageId, page.Id, StringComparison.OrdinalIgnoreCase)) + .OrderBy(ModSettingsRegistry.GetEffectivePageSortOrder) + .ThenBy(candidate => candidate.Id, StringComparer.OrdinalIgnoreCase)) + container.AddChild(CreateSidebarPageTreeButton(pages, child, depth + 1)); + + return container; + } + + private ModSettingsPage? ResolveSelectedPage() + { + return ModSettingsRegistry.GetPages().FirstOrDefault(page => + string.Equals(page.ModId, _selectedModId, StringComparison.OrdinalIgnoreCase) && + string.Equals(page.Id, _selectedPageId, StringComparison.OrdinalIgnoreCase)); + } + + private static string ResolvePageTabTitle(ModSettingsPage page) + { + return ModSettingsLocalization.ResolvePageDisplayName(page); + } + + private static string ResolveSidebarModTitle(IReadOnlyList pages) + { + var modId = pages[0].ModId; + return ModSettingsLocalization.ResolveModName(modId, modId); + } + + private static string ResolveSectionTitle(ModSettingsSection section) + { + return section.Title?.Resolve() ?? ModSettingsLocalization.Get("section.default", "Section"); + } + + private void ScrollToSelectedAnchor() + { + _suppressScrollSync = true; + if (!string.IsNullOrWhiteSpace(_selectedSectionId)) + if (_contentList.FindChild($"Section_{_selectedSectionId}", true, false) is Control target) + { + _scrollContainer.ScrollVertical = Mathf.RoundToInt(target.GlobalPosition.Y - + _scrollContainer.GlobalPosition.Y + _scrollContainer.ScrollVertical - 12f); + Callable.From(() => _suppressScrollSync = false).CallDeferred(); + return; + } + + _scrollContainer.ScrollVertical = 0; + Callable.From(() => _suppressScrollSync = false).CallDeferred(); + } + + private void OnContentScrollChanged(double value) + { + if (_suppressScrollSync) + return; + + var page = ResolveSelectedPage(); + if (page == null || page.Sections.Count == 0) + return; + + var viewportTop = _scrollContainer.GlobalPosition.Y + 24f; + var bestSectionId = page.Sections[0].Id; + var bestDistance = float.MaxValue; + + foreach (var section in page.Sections) + { + if (_contentList.FindChild($"Section_{section.Id}", true, false) is not Control target) + continue; + + var distance = MathF.Abs(target.GlobalPosition.Y - viewportTop); + if (!(distance < bestDistance)) continue; + bestDistance = distance; + bestSectionId = section.Id; + } + + if (string.Equals(bestSectionId, _selectedSectionId, StringComparison.OrdinalIgnoreCase)) + return; + + _selectedSectionId = bestSectionId; + foreach (var pair in _sectionButtons) + pair.Value.SetSelected(string.Equals(pair.Key, _selectedSectionId, StringComparison.OrdinalIgnoreCase)); + } + + private void RefreshFocusNavigation() + { + if (_focusNavigationRefreshScheduled) + return; + _focusNavigationRefreshScheduled = true; + Callable.From(FlushFocusNavigationDeferred).CallDeferred(); + } + + private void FlushFocusNavigationDeferred() + { + _focusNavigationRefreshScheduled = false; + if (!IsInstanceValid(this) || !Visible) + return; + + ApplySplitPaneFocusNavigation(); + this.UpdateControllerNavEnabled(); + } + + private void RebuildFocusChainsOnly() + { + _sidebarFocusChain.Clear(); + _contentFocusChain.Clear(); + CollectSettingsFocusChainPreorder(_sidebarPanelRoot, _sidebarFocusChain); + CollectSettingsFocusChainPreorder(_contentPanelRoot, _contentFocusChain); + + WireVerticalOnlyChain(_sidebarFocusChain); + WireVerticalOnlyChain(_contentFocusChain); + + _initialFocusedControl = ResolveInitialSidebarFocus() ?? _sidebarFocusChain.FirstOrDefault(); + + UpdatePaneHotkeyHintIcons(); + } + + private void ApplySplitPaneFocusNavigation() + { + RebuildFocusChainsOnly(); + var owner = GetViewport()?.GuiGetFocusOwner(); + switch (_contentOnlyRebuildNeedsContentFocus) + { + case false when + IsInstanceValid(owner) && IsAncestorOf(owner): + return; + case true: + { + _contentOnlyRebuildNeedsContentFocus = false; + var contentTarget = ResolveContentFocusTargetForSection(); + if (contentTarget != null && contentTarget.IsVisibleInTree()) + { + GrabControlDeferred(contentTarget); + return; + } + + break; + } + } + + if (IsFocusUnderPopupOrTransientWindow(owner)) + return; + + var focusLost = owner == null || !IsInstanceValid(owner) || !IsAncestorOf(owner); + if (focusLost) + GrabControlDeferred(_initialFocusedControl); + else + _initialFocusedControl?.TryGrabFocus(); + } + + private static void GrabControlDeferred(Control? target) + { + if (target == null) + return; + + var t = target; + Callable.From(() => + { + if (!IsInstanceValid(t) || !t.IsVisibleInTree()) + return; + + t.GrabFocus(); + }).CallDeferred(); + } + + private static void WireVerticalOnlyChain(IReadOnlyList chain) + { + for (var index = 0; index < chain.Count; index++) + { + var current = chain[index]; + var selfPath = current.GetPath(); + current.FocusNeighborLeft = selfPath; + current.FocusNeighborRight = selfPath; + current.FocusNeighborTop = index > 0 ? chain[index - 1].GetPath() : null; + current.FocusNeighborBottom = + index < chain.Count - 1 ? chain[index + 1].GetPath() : null; + } + } + + private static void CollectSettingsFocusChainPreorder(Control parent, List controls) + { + foreach (var child in parent.GetChildren()) + { + if (child is not Control item || !item.IsVisibleInTree()) + continue; + + if (IsSettingsFocusTerminal(item)) + { + if (item.FocusMode == FocusModeEnum.All) + controls.Add(item); + continue; + } + + CollectSettingsFocusChainPreorder(item, controls); + } + } + + private static bool IsSettingsFocusTerminal(Control c) + { + return c switch + { + ModSettingsSidebarButton or ModSettingsTextButton or ModSettingsCollapsibleHeaderButton + or ModSettingsToggleControl or ModSettingsMiniButton or ModSettingsDragHandle + or ModSettingsActionsButton or NButton + or HSlider or OptionButton or ColorPickerButton or MenuButton => true, + LineEdit or TextEdit => c.FocusMode == FocusModeEnum.All, + _ => c is Button, + }; + } + + private void ApplyStaticTexts() + { + _titleLabel.SetTextAutoSize(ModSettingsLocalization.Get("entry.title", "Mod Settings (RitsuLib)")); + _subtitleLabel.SetTextAutoSize(ModSettingsLocalization.Get("entry.subtitle", + "Edit player-facing mod options here.")); + } + + private void ExpandOnlyMod(string? modId) + { + _expandedModIds.Clear(); + if (!string.IsNullOrWhiteSpace(modId)) + _expandedModIds.Add(modId); + } + + private void FlushDirtyBindings() + { + if (_dirtyBindings.Count == 0) + { + _saveTimer = -1; + return; + } + + foreach (var binding in _dirtyBindings.ToArray()) + try + { + binding.Save(); + } + catch (Exception ex) + { + RitsuLibFramework.Logger.Warn( + $"[Settings] Failed to save '{binding.ModId}:{binding.DataKey}': {ex.Message}"); + } + + _dirtyBindings.Clear(); + _saveTimer = -1; + } + + private void SubscribeLocaleChanges() + { + if (_localeSubscribed) + return; + + try + { + LocManager.Instance.SubscribeToLocaleChange(OnLocaleChanged); + _localeSubscribed = true; + } + catch + { + // ignored + } + } + + private void UnsubscribeLocaleChanges() + { + if (!_localeSubscribed) + return; + + try + { + LocManager.Instance.UnsubscribeToLocaleChange(OnLocaleChanged); + } + catch + { + // ignored + } + + _localeSubscribed = false; + } + + private void OnLocaleChanged() + { + FlushDirtyBindings(); + Callable.From(Rebuild).CallDeferred(); + } + + private static MegaRichTextLabel CreateTitleLabel(int fontSize, HorizontalAlignment alignment) + { + var label = new MegaRichTextLabel + { + Theme = ModSettingsUiResources.SettingsLineTheme, + BbcodeEnabled = true, + AutoSizeEnabled = false, + ScrollActive = false, + HorizontalAlignment = alignment, + VerticalAlignment = VerticalAlignment.Center, + MouseFilter = MouseFilterEnum.Ignore, + FocusMode = FocusModeEnum.None, + }; + + label.AddThemeFontOverride("normal_font", ModSettingsUiResources.KreonRegular); + label.AddThemeFontOverride("bold_font", ModSettingsUiResources.KreonBold); + label.AddThemeFontSizeOverride("normal_font_size", fontSize); + label.AddThemeFontSizeOverride("bold_font_size", fontSize); + label.AddThemeFontSizeOverride("italics_font_size", fontSize); + label.AddThemeFontSizeOverride("bold_italics_font_size", fontSize); + label.AddThemeFontSizeOverride("mono_font_size", fontSize); + label.MinFontSize = Math.Min(fontSize, 16); + label.MaxFontSize = fontSize; + return label; + } + + private static MegaRichTextLabel CreateEmptyStateLabel(string text) + { + var label = CreateTitleLabel(24, HorizontalAlignment.Center); + label.CustomMinimumSize = new(0f, 120f); + label.SizeFlagsHorizontal = SizeFlags.ExpandFill; + label.SetTextAutoSize(text); + return label; + } + + private static StyleBoxFlat CreatePanelStyle(Color bg) + { + return new() + { + BgColor = bg, + BorderColor = new(0.44f, 0.68f, 0.80f, 0.36f), + BorderWidthLeft = 1, + BorderWidthTop = 1, + BorderWidthRight = 1, + BorderWidthBottom = 1, + CornerRadiusTopLeft = ModSettingsUiMetrics.CornerRadius, + CornerRadiusTopRight = ModSettingsUiMetrics.CornerRadius, + CornerRadiusBottomRight = ModSettingsUiMetrics.CornerRadius, + CornerRadiusBottomLeft = ModSettingsUiMetrics.CornerRadius, + ShadowColor = new(0f, 0f, 0f, 0.32f), + ShadowSize = 12, + ContentMarginLeft = 0, + ContentMarginTop = 0, + ContentMarginRight = 0, + ContentMarginBottom = 0, + }; + } + + private static StyleBoxFlat CreateSidebarGroupStyle(bool selected) + { + return new() + { + BgColor = selected + ? new(0.085f, 0.125f, 0.165f, 0.97f) + : new Color(0.07f, 0.095f, 0.13f, 0.94f), + BorderColor = selected + ? new(0.58f, 0.80f, 0.90f, 0.58f) + : new Color(0.30f, 0.44f, 0.54f, 0.36f), + BorderWidthLeft = 1, + BorderWidthTop = 1, + BorderWidthRight = 1, + BorderWidthBottom = 1, + CornerRadiusTopLeft = ModSettingsUiMetrics.CornerRadius, + CornerRadiusTopRight = ModSettingsUiMetrics.CornerRadius, + CornerRadiusBottomRight = ModSettingsUiMetrics.CornerRadius, + CornerRadiusBottomLeft = ModSettingsUiMetrics.CornerRadius, + ShadowColor = new(0f, 0f, 0f, 0.16f), + ShadowSize = 4, + ContentMarginLeft = 10, + ContentMarginTop = 10, + ContentMarginRight = 10, + ContentMarginBottom = 10, + }; + } + } +} From 8ef6627583e272c7687e9de2d7c61b04dabbfcbd Mon Sep 17 00:00:00 2001 From: fox <110327098+mugongzi520@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:38:46 +0800 Subject: [PATCH 2/2] Add files via upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android 兼容补丁 --- .../Content/Patches/ModelDbContentPatches.cs | 527 ++++++ ...IdSerializationCacheDynamicContentPatch.cs | 177 ++ ...eLibLegacyGeneratedCompatibilityPatches.cs | 182 ++ ...seLibStringPropertyCompatibilityPatches.cs | 262 +++ ...ndroidBaseLibVisualCompatibilityPatches.cs | 303 ++++ .../Patches/LocTableCompatibilityPatches.cs | 403 +++++ .../Patching/Core/ModPatcher.cs | 470 +++++ .../RitsuLibFramework.PatcherSetup.cs | 417 +++++ .../Patches/CardLibraryCompendiumPatch.cs | 226 +++ .../Patches/CharacterAssetOverridePatches.cs | 690 ++++++++ ...BaseLibToRitsuGeneratedReflectionMirror.cs | 558 ++++++ .../ModSettingsUi/RitsuModSettingsSubmenu.cs | 1506 +++++++++++++++++ ...droidGraphicsSettingsCompatibilityPatch.cs | 39 + .../Settings/Patches/ModSettingsUiPatches.cs | 309 ++++ 14 files changed, 6069 insertions(+) create mode 100644 STS2-RitsuLib-main/Content/Patches/ModelDbContentPatches.cs create mode 100644 STS2-RitsuLib-main/Content/Patches/ModelIdSerializationCacheDynamicContentPatch.cs create mode 100644 STS2-RitsuLib-main/Interop/Patches/AndroidBaseLibLegacyGeneratedCompatibilityPatches.cs create mode 100644 STS2-RitsuLib-main/Interop/Patches/AndroidBaseLibStringPropertyCompatibilityPatches.cs create mode 100644 STS2-RitsuLib-main/Interop/Patches/AndroidBaseLibVisualCompatibilityPatches.cs create mode 100644 STS2-RitsuLib-main/Localization/Patches/LocTableCompatibilityPatches.cs create mode 100644 STS2-RitsuLib-main/Patching/Core/ModPatcher.cs create mode 100644 STS2-RitsuLib-main/RitsuLibFramework.PatcherSetup.cs create mode 100644 STS2-RitsuLib-main/Scaffolding/Characters/Patches/CardLibraryCompendiumPatch.cs create mode 100644 STS2-RitsuLib-main/Scaffolding/Characters/Patches/CharacterAssetOverridePatches.cs create mode 100644 STS2-RitsuLib-main/Settings/ModSettings/ModSettingsBaseLibToRitsuGeneratedReflectionMirror.cs create mode 100644 STS2-RitsuLib-main/Settings/ModSettingsUi/RitsuModSettingsSubmenu.cs create mode 100644 STS2-RitsuLib-main/Settings/Patches/AndroidGraphicsSettingsCompatibilityPatch.cs create mode 100644 STS2-RitsuLib-main/Settings/Patches/ModSettingsUiPatches.cs diff --git a/STS2-RitsuLib-main/Content/Patches/ModelDbContentPatches.cs b/STS2-RitsuLib-main/Content/Patches/ModelDbContentPatches.cs new file mode 100644 index 0000000..6669ee1 --- /dev/null +++ b/STS2-RitsuLib-main/Content/Patches/ModelDbContentPatches.cs @@ -0,0 +1,527 @@ +using MegaCrit.Sts2.Core.Models; +using STS2RitsuLib.Patching.Core; +using STS2RitsuLib.Patching.Models; + +namespace STS2RitsuLib.Content.Patches +{ + /// + /// Appends RitsuLib-registered characters to . + /// + public class AllCharactersPatch : IPatchMethod + { + private static bool _androidLogged; + + /// + public static string PatchId => "modeldb_all_characters"; + + /// + public static string Description => "Append registered characters to ModelDb.AllCharacters"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_AllCharacters")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Concatenates mod-registered characters onto the vanilla sequence. + /// + public static void Postfix(ref IEnumerable __result) + { + __result = ModContentRegistry.AppendCharacters(__result); + + if (OperatingSystem.IsAndroid() && !_androidLogged) + { + _androidLogged = true; + PatchLog.For().Info( + $"[AndroidCompat] ModelDb.AllCharacters appended {ModContentRegistry.GetModCharacters().Count()} mod character(s)."); + } + } + } + + /// + /// Merges RitsuLib-registered monster types into by . + /// + public class AllMonstersPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_all_monsters"; + + /// + public static string Description => "Merge registered monsters into ModelDb.Monsters"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_Monsters")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Ensures standalone-registered monsters appear in global monster enumeration even before every act lists them. + /// + public static void Postfix(ref IEnumerable __result) + // ReSharper restore InconsistentNaming + { + __result = ModContentRegistry.AppendRegisteredMonsters(__result); + } + } + + /// + /// Appends RitsuLib-registered acts to . + /// + public class ActsPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_acts"; + + /// + public static string Description => "Append registered acts to ModelDb.Acts"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_Acts")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Concatenates mod-registered acts onto the vanilla sequence. + /// + public static void Postfix(ref IEnumerable __result) + { + __result = ModContentRegistry.AppendActs(__result); + } + } + + /// + /// Appends RitsuLib-registered shared events to . + /// + public class AllSharedEventsPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_all_shared_events"; + + /// + public static string Description => "Append registered shared events to ModelDb.AllSharedEvents"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_AllSharedEvents")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Concatenates mod shared events onto the vanilla sequence. + /// + public static void Postfix(ref IEnumerable __result) + { + __result = ModContentRegistry.AppendSharedEvents(__result); + } + } + + /// + /// Appends RitsuLib-registered powers to . + /// + public class AllPowersPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_all_powers"; + + /// + public static string Description => "Append registered powers to ModelDb.AllPowers"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_AllPowers")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Concatenates mod powers onto the vanilla sequence. + /// + public static void Postfix(ref IEnumerable __result) + { + __result = ModContentRegistry.AppendPowers(__result); + } + } + + /// + /// Appends RitsuLib-registered orbs to . + /// + public class AllOrbsPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_orbs"; + + /// + public static string Description => "Append registered orbs to ModelDb.Orbs"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_Orbs")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Concatenates mod orbs onto the vanilla sequence. + /// + public static void Postfix(ref IEnumerable __result) + { + __result = ModContentRegistry.AppendOrbs(__result); + } + } + + /// + /// Appends RitsuLib-registered shared card pools to . + /// + public class AllSharedCardPoolsPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_all_shared_card_pools"; + + /// + public static string Description => "Append registered shared card pools to ModelDb.AllSharedCardPools"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_AllSharedCardPools")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Concatenates mod shared card pools onto the vanilla sequence. + /// + public static void Postfix(ref IEnumerable __result) + { + __result = ModContentRegistry.AppendSharedCardPools(__result); + } + } + + /// + /// Appends RitsuLib-registered shared events to . + /// + public class AllEventsPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_all_events"; + + /// + public static string Description => "Append registered shared events to ModelDb.AllEvents"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_AllEvents")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Concatenates mod shared events onto the sequence. + /// + public static void Postfix(ref IEnumerable __result) + { + __result = ModContentRegistry.AppendSharedEvents(__result); + } + } + + /// + /// Appends RitsuLib-registered shared ancients to . + /// + public class AllSharedAncientsPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_all_shared_ancients"; + + /// + public static string Description => "Append registered shared ancients to ModelDb.AllSharedAncients"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_AllSharedAncients")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Concatenates mod shared ancients onto the vanilla sequence. + /// + public static void Postfix(ref IEnumerable __result) + { + __result = ModContentRegistry.AppendSharedAncients(__result); + } + } + + /// + /// Appends RitsuLib-registered shared ancients to . + /// + public class AllAncientsPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_all_ancients"; + + /// + public static string Description => "Append registered shared ancients to ModelDb.AllAncients"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_AllAncients")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Concatenates mod shared ancients onto the sequence. + /// + public static void Postfix(ref IEnumerable __result) + { + __result = ModContentRegistry.AppendSharedAncients(__result); + } + } + + /// + /// Appends RitsuLib-registered enchantments to (covers dynamic types not in + /// subtype scan). + /// + public class DebugEnchantmentsPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_debug_enchantments"; + + /// + public static string Description => "Append registered enchantments to ModelDb.DebugEnchantments"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_DebugEnchantments")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Concatenates mod enchantments onto the vanilla sequence. + /// + public static void Postfix(ref IEnumerable __result) + { + __result = ModContentRegistry.AppendEnchantments(__result); + } + } + + /// + /// Appends RitsuLib-registered afflictions to . + /// + public class DebugAfflictionsPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_debug_afflictions"; + + /// + public static string Description => "Append registered afflictions to ModelDb.DebugAfflictions"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_DebugAfflictions")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Concatenates mod afflictions onto the vanilla sequence. + /// + public static void Postfix(ref IEnumerable __result) + { + __result = ModContentRegistry.AppendAfflictions(__result); + } + } + + /// + /// Appends RitsuLib-registered achievements to . + /// + public class AchievementsPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_achievements"; + + /// + public static string Description => "Append registered achievements to ModelDb.Achievements"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_Achievements")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Merges mod achievements into the vanilla list by . + /// + public static void Postfix(ref IReadOnlyList __result) + { + __result = ModContentRegistry.AppendAchievements(__result); + } + } + + /// + /// Appends RitsuLib-registered modifiers to . + /// + public class GoodModifiersPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_good_modifiers"; + + /// + public static string Description => "Append registered good modifiers to ModelDb.GoodModifiers"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_GoodModifiers")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Merges mod good modifiers into the vanilla list by . + /// + public static void Postfix(ref IReadOnlyList __result) + { + __result = ModContentRegistry.AppendGoodModifiers(__result); + } + } + + /// + /// Appends RitsuLib-registered modifiers to . + /// + public class BadModifiersPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_bad_modifiers"; + + /// + public static string Description => "Append registered bad modifiers to ModelDb.BadModifiers"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_BadModifiers")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Merges mod bad modifiers into the vanilla list by . + /// + public static void Postfix(ref IReadOnlyList __result) + { + __result = ModContentRegistry.AppendBadModifiers(__result); + } + } + + /// + /// Appends RitsuLib-registered shared relic pools to . + /// + public class AllRelicPoolsPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_all_relic_pools"; + + /// + public static string Description => "Append registered shared relic pools to ModelDb.AllRelicPools"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_AllRelicPools")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Concatenates mod shared relic pools onto the vanilla sequence. + /// + public static void Postfix(ref IEnumerable __result) + { + __result = ModContentRegistry.AppendSharedRelicPools(__result); + } + } + + /// + /// Appends RitsuLib-registered shared potion pools to . + /// + public class AllPotionPoolsPatch : IPatchMethod + { + /// + public static string PatchId => "modeldb_all_potion_pools"; + + /// + public static string Description => "Append registered shared potion pools to ModelDb.AllPotionPools"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelDb), "get_AllPotionPools")]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Concatenates mod shared potion pools onto the vanilla sequence. + /// + public static void Postfix(ref IEnumerable __result) + { + __result = ModContentRegistry.AppendSharedPotionPools(__result); + } + } +} diff --git a/STS2-RitsuLib-main/Content/Patches/ModelIdSerializationCacheDynamicContentPatch.cs b/STS2-RitsuLib-main/Content/Patches/ModelIdSerializationCacheDynamicContentPatch.cs new file mode 100644 index 0000000..0fc77d2 --- /dev/null +++ b/STS2-RitsuLib-main/Content/Patches/ModelIdSerializationCacheDynamicContentPatch.cs @@ -0,0 +1,177 @@ +using System.Buffers.Binary; +using System.Collections; +using System.IO.Hashing; +using System.Reflection; +using System.Text; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Multiplayer.Serialization; +using MegaCrit.Sts2.Core.Timeline; +using STS2RitsuLib.Patching.Core; +using STS2RitsuLib.Patching.Models; + +namespace STS2RitsuLib.Content.Patches +{ + /// + /// only walks , so + /// Reflection.Emit placeholder models (and any other injected types not returned by mod subtype scan) never receive + /// net + /// IDs. This postfix merges content and recomputes bit sizes and hash like vanilla + /// Init. + /// + public class ModelIdSerializationCacheDynamicContentPatch : IPatchMethod + { + /// + public static string PatchId => "model_id_serialization_cache_dynamic_content"; + + /// + public static string Description => + "Include ModelDb-injected dynamic mod models in ModelIdSerializationCache maps and hash"; + + /// + public static bool IsCritical => true; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(ModelIdSerializationCache), nameof(ModelIdSerializationCache.Init))]; + } + + /// + /// After vanilla , merges injected entries + /// into net ID maps and refreshes bit sizes and hash. + /// + public static void Postfix() + { + var contentById = GetModelDbContentById(); + if (contentById == null || contentById.Count == 0) + return; + + var catMap = + GetStaticField>(typeof(ModelIdSerializationCache), "_categoryNameToNetIdMap"); + var catList = GetStaticField>(typeof(ModelIdSerializationCache), "_netIdToCategoryNameMap"); + var entMap = + GetStaticField>(typeof(ModelIdSerializationCache), "_entryNameToNetIdMap"); + var entList = GetStaticField>(typeof(ModelIdSerializationCache), "_netIdToEntryNameMap"); + + if (catMap == null || catList == null || entMap == null || entList == null) + return; + + var addedCategoryCount = 0; + var addedEntryCount = 0; + foreach (DictionaryEntry entry in contentById) + { + if (entry.Key is not ModelId id) + continue; + + if (EnsureCategory(id.Category, catMap, catList)) + addedCategoryCount++; + + if (EnsureEntry(id.Entry, entMap, entList)) + addedEntryCount++; + } + + var maxCategory = catList.Count; + var maxEntry = entList.Count; + var epochList = GetStaticField>(typeof(ModelIdSerializationCache), "_netIdToEpochNameMap"); + var maxEpoch = epochList?.Count ?? 0; + + SetStaticProperty(typeof(ModelIdSerializationCache), nameof(ModelIdSerializationCache.CategoryIdBitSize), + Mathf.CeilToInt(Math.Log2(maxCategory))); + SetStaticProperty(typeof(ModelIdSerializationCache), nameof(ModelIdSerializationCache.EntryIdBitSize), + Mathf.CeilToInt(Math.Log2(maxEntry))); + SetStaticProperty(typeof(ModelIdSerializationCache), nameof(ModelIdSerializationCache.EpochIdBitSize), + Mathf.CeilToInt(Math.Log2(maxEpoch))); + + var newHash = ComputeHashLikeVanilla(contentById, maxCategory, maxEntry, maxEpoch); + SetStaticProperty(typeof(ModelIdSerializationCache), nameof(ModelIdSerializationCache.Hash), newHash); + + if (addedCategoryCount > 0 || addedEntryCount > 0) + { + PatchLog.For().Info( + $"[AndroidCompat] ModelIdSerializationCache synced dynamic content: " + + $"{addedCategoryCount} category ID(s), {addedEntryCount} entry ID(s), hash=0x{newHash:X8}"); + } + } + + private static IDictionary? GetModelDbContentById() + { + var field = AccessTools.DeclaredField(typeof(ModelDb), "_contentById"); + return field?.GetValue(null) as IDictionary; + } + + private static uint ComputeHashLikeVanilla(IDictionary contentById, int maxCategory, int maxEntry, int maxEpoch) + { + var buffer = new byte[512]; + var xxHash = new XxHash32(); + + var types = new HashSet(); + foreach (var t in ModelDb.AllAbstractModelSubtypes) + types.Add(t); + + foreach (DictionaryEntry entry in contentById) + if (entry.Value is AbstractModel model) + types.Add(model.GetType()); + + var sorted = types.ToList(); + sorted.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name)); + + foreach (var id in sorted.Select(ModelDb.GetId)) + { + AppendUtf8(xxHash, id.Category, buffer); + AppendUtf8(xxHash, id.Entry, buffer); + } + + foreach (var epochId in EpochModel.AllEpochIds) + AppendUtf8(xxHash, epochId, buffer); + + BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(), maxCategory); + xxHash.Append(buffer.AsSpan(0, 4)); + BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(), maxEntry); + xxHash.Append(buffer.AsSpan(0, 4)); + BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(), maxEpoch); + xxHash.Append(buffer.AsSpan(0, 4)); + + return xxHash.GetCurrentHashAsUInt32(); + } + + private static void AppendUtf8(XxHash32 xxHash, string text, byte[] buffer) + { + var bytes = Encoding.UTF8.GetBytes(text, 0, text.Length, buffer, 0); + xxHash.Append(buffer.AsSpan(0, bytes)); + } + + private static bool EnsureCategory(string category, Dictionary map, List list) + { + if (map.ContainsKey(category)) + return false; + + map[category] = list.Count; + list.Add(category); + return true; + } + + private static bool EnsureEntry(string entry, Dictionary map, List list) + { + if (map.ContainsKey(entry)) + return false; + + map[entry] = list.Count; + list.Add(entry); + return true; + } + + private static T? GetStaticField(Type declaringType, string name) + where T : class + { + return AccessTools.DeclaredField(declaringType, name)?.GetValue(null) as T; + } + + private static void SetStaticProperty(Type declaringType, string name, object value) + { + var prop = declaringType.GetProperty(name, BindingFlags.Public | BindingFlags.Static); + prop?.GetSetMethod(true)?.Invoke(null, [value]); + } + } +} diff --git a/STS2-RitsuLib-main/Interop/Patches/AndroidBaseLibLegacyGeneratedCompatibilityPatches.cs b/STS2-RitsuLib-main/Interop/Patches/AndroidBaseLibLegacyGeneratedCompatibilityPatches.cs new file mode 100644 index 0000000..6e0ee59 --- /dev/null +++ b/STS2-RitsuLib-main/Interop/Patches/AndroidBaseLibLegacyGeneratedCompatibilityPatches.cs @@ -0,0 +1,182 @@ +using System.Collections.Concurrent; +using System.Reflection; +using HarmonyLib; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Random; +using MegaCrit.Sts2.Core.Rooms; +using STS2RitsuLib.Patching.Models; + +namespace STS2RitsuLib.Interop.Patches +{ + internal static class AndroidBaseLibLegacyGeneratedCompatHelper + { + private static readonly BindingFlags InstanceFlags = + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + private static readonly ConcurrentDictionary EncounterAccessorCache = []; + private static readonly ConcurrentDictionary HasLegacyCompatOwnerCache = []; + private static readonly ConcurrentDictionary LoggedRoutes = []; + private static readonly ConcurrentDictionary OnUpgradeMethodCache = []; + + internal static bool HasLegacyCompatOwner(MethodBase originalMethod) + { + return HasLegacyCompatOwnerCache.GetOrAdd( + originalMethod, + static method => + { + var patchInfo = Harmony.GetPatchInfo(method); + if (patchInfo == null) + return false; + + return patchInfo.Prefixes.Concat(patchInfo.Postfixes).Concat(patchInfo.Transpilers) + .Concat(patchInfo.Finalizers) + .Any(static patch => + patch.owner.EndsWith(".BaseLibToRitsuCompat", StringComparison.Ordinal)); + }); + } + + internal static void InvokeCardUpgradeFallback(CardModel card) + { + var onUpgrade = OnUpgradeMethodCache.GetOrAdd( + card.GetType(), + static type => type.GetMethod("OnUpgrade", InstanceFlags, null, Type.EmptyTypes, null)); + + onUpgrade?.Invoke(card, null); + card.FinalizeUpgradeInternal(); + + LogOnce( + $"{card.GetType().FullName}:upgrade", + $"[AndroidCompat] Routed CardModel.UpgradeInternal through reflection fallback for '{card.GetType().FullName}'."); + } + + internal static BackgroundAssets BuildEncounterBackgroundFallback(EncounterModel encounter, ActModel parentAct, + Rng rng) + { + var accessors = EncounterAccessorCache.GetOrAdd( + encounter.GetType(), + static type => new LegacyEncounterCompatAccessors + { + PrepCustomBackground = type.GetMethod( + "PrepCustomBackground", + InstanceFlags, + null, + [typeof(ActModel), typeof(Rng)], + null), + GetPreparedBackgroundAssets = type.GetMethod( + "GetPreparedBackgroundAssets", + InstanceFlags, + null, + Type.EmptyTypes, + null), + }); + + accessors.PrepCustomBackground?.Invoke(encounter, [parentAct, rng]); + + if (accessors.GetPreparedBackgroundAssets?.Invoke(encounter, null) is BackgroundAssets prepared) + { + LogOnce( + $"{encounter.GetType().FullName}:encounter-bg-custom", + $"[AndroidCompat] Routed EncounterModel.GetBackgroundAssets through legacy custom background fallback for '{encounter.GetType().FullName}'."); + return prepared; + } + + LogOnce( + $"{encounter.GetType().FullName}:encounter-bg-act", + $"[AndroidCompat] Routed EncounterModel.GetBackgroundAssets to act background fallback for '{encounter.GetType().FullName}'."); + return parentAct.GenerateBackgroundAssets(rng); + } + + private static void LogOnce(string key, string message) + { + if (!LoggedRoutes.TryAdd(key, 0)) + return; + + RitsuLibFramework.Logger.Info(message); + } + + private sealed class LegacyEncounterCompatAccessors + { + public MethodInfo? GetPreparedBackgroundAssets { get; init; } + public MethodInfo? PrepCustomBackground { get; init; } + } + } + + internal class AndroidBaseLibCardUpgradeInternalCompatibilityPatch : IPatchMethod + { + public static string PatchId => "android_baselib_card_upgrade_internal_compat"; + + public static bool IsCritical => false; + + public static string Description => + "Route CardModel.UpgradeInternal through reflection on Android when BaseLibToRitsu legacy patches are active"; + + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CardModel), nameof(CardModel.UpgradeInternal))]; + } + + [HarmonyPriority(Priority.First)] + public static bool Prefix(MethodBase __originalMethod, CardModel __instance) + { + if (!OperatingSystem.IsAndroid()) + return true; + + if (!AndroidBaseLibLegacyGeneratedCompatHelper.HasLegacyCompatOwner(__originalMethod)) + return true; + + try + { + AndroidBaseLibLegacyGeneratedCompatHelper.InvokeCardUpgradeFallback(__instance); + return false; + } + catch (Exception ex) + { + RitsuLibFramework.Logger.Warn( + $"[AndroidCompat] Card upgrade fallback failed for '{__instance.GetType().FullName}': {ex.GetBaseException().Message}"); + return true; + } + } + } + + internal class AndroidBaseLibEncounterGetBackgroundAssetsCompatibilityPatch : IPatchMethod + { + public static string PatchId => "android_baselib_encounter_get_background_assets_compat"; + + public static bool IsCritical => false; + + public static string Description => + "Route EncounterModel.GetBackgroundAssets through Android-safe BaseLibToRitsu fallback logic"; + + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(EncounterModel), "GetBackgroundAssets", [typeof(ActModel), typeof(Rng)])]; + } + + [HarmonyPriority(Priority.First)] + public static bool Prefix(MethodBase __originalMethod, EncounterModel __instance, ActModel parentAct, Rng rng, + ref BackgroundAssets __result) + { + if (!OperatingSystem.IsAndroid()) + return true; + + if (!AndroidBaseLibLegacyGeneratedCompatHelper.HasLegacyCompatOwner(__originalMethod)) + return true; + + try + { + __result = AndroidBaseLibLegacyGeneratedCompatHelper.BuildEncounterBackgroundFallback( + __instance, + parentAct, + rng); + return false; + } + catch (Exception ex) + { + RitsuLibFramework.Logger.Warn( + $"[AndroidCompat] Encounter background fallback failed for '{__instance.GetType().FullName}': {ex.GetBaseException().Message}. Falling back to parent act background."); + __result = parentAct.GenerateBackgroundAssets(rng); + return false; + } + } + } +} diff --git a/STS2-RitsuLib-main/Interop/Patches/AndroidBaseLibStringPropertyCompatibilityPatches.cs b/STS2-RitsuLib-main/Interop/Patches/AndroidBaseLibStringPropertyCompatibilityPatches.cs new file mode 100644 index 0000000..2026ccc --- /dev/null +++ b/STS2-RitsuLib-main/Interop/Patches/AndroidBaseLibStringPropertyCompatibilityPatches.cs @@ -0,0 +1,262 @@ +using System.Collections.Concurrent; +using System.Reflection; +using HarmonyLib; +using MegaCrit.Sts2.Core.Models; +using STS2RitsuLib.Patching.Models; + +namespace STS2RitsuLib.Interop.Patches +{ + internal static class AndroidBaseLibPropertyCompatHelper + { + private static readonly ConcurrentDictionary StringPropertyCache = new(); + private static readonly ConcurrentDictionary LoggedOverrides = new(); + + internal static bool TryOverrideString( + object model, + string compatibilityMemberName, + ref string result, + params string[] propertyNames) + { + var modelType = model.GetType(); + + foreach (var propertyName in propertyNames) + { + var property = GetStringProperty(modelType, propertyName); + if (property?.GetValue(model) is not string value || string.IsNullOrWhiteSpace(value)) + continue; + + result = value; + LogOverrideOnce(modelType, compatibilityMemberName, propertyName, value); + return true; + } + + return false; + } + + private static PropertyInfo? GetStringProperty(Type type, string propertyName) + { + return StringPropertyCache.GetOrAdd( + $"{type.AssemblyQualifiedName}|{propertyName}", + _ => + { + const BindingFlags instanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + var property = type.GetProperty(propertyName, instanceFlags); + return property?.PropertyType == typeof(string) ? property : null; + }); + } + + private static void LogOverrideOnce(Type modelType, string memberName, string propertyName, string value) + { + var logKey = $"{modelType.FullName}:{memberName}:{value}"; + if (!LoggedOverrides.TryAdd(logKey, 0)) + return; + + RitsuLibFramework.Logger.Info( + $"[AndroidCompat] Routed '{memberName}' for '{modelType.FullName}' through reflective compatibility via '{propertyName}': {value}"); + } + } + + internal class AndroidBaseLibCharacterStringPropertyCompatibilityPatch : IPatchMethod + { + public static string PatchId => "android_baselib_character_string_property_compat"; + + public static bool IsCritical => false; + + public static string Description => + "Reflect BaseLib-style character asset and audio getters on Android"; + + public static ModPatchTarget[] GetTargets() + { + return + [ + new(typeof(CharacterModel), "get_VisualsPath"), + new(typeof(CharacterModel), "get_EnergyCounterPath"), + new(typeof(CharacterModel), "get_MerchantAnimPath"), + new(typeof(CharacterModel), "get_RestSiteAnimPath"), + new(typeof(CharacterModel), "get_IconTexturePath"), + new(typeof(CharacterModel), "get_IconPath"), + new(typeof(CharacterModel), "get_CharacterSelectBg"), + new(typeof(CharacterModel), "get_CharacterSelectIconPath"), + new(typeof(CharacterModel), "get_CharacterSelectLockedIconPath"), + new(typeof(CharacterModel), "get_CharacterSelectTransitionPath"), + new(typeof(CharacterModel), "get_MapMarkerPath"), + new(typeof(CharacterModel), "get_TrailPath"), + new(typeof(CharacterModel), "get_ArmPointingTexturePath"), + new(typeof(CharacterModel), "get_ArmRockTexturePath"), + new(typeof(CharacterModel), "get_ArmPaperTexturePath"), + new(typeof(CharacterModel), "get_ArmScissorsTexturePath"), + new(typeof(CharacterModel), "get_AttackSfx"), + new(typeof(CharacterModel), "get_CastSfx"), + new(typeof(CharacterModel), "get_DeathSfx"), + ]; + } + + [HarmonyPriority(Priority.First)] + public static bool Prefix(MethodBase __originalMethod, CharacterModel __instance, ref string __result) + { + if (!OperatingSystem.IsAndroid()) + return true; + + var applied = __originalMethod.Name switch + { + "get_VisualsPath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + nameof(CharacterModel.VisualsPath), + ref __result, + "CustomVisualsPath", + "CustomVisualPath"), + "get_EnergyCounterPath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + nameof(CharacterModel.EnergyCounterPath), + ref __result, + "CustomEnergyCounterPath"), + "get_MerchantAnimPath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + nameof(CharacterModel.MerchantAnimPath), + ref __result, + "CustomMerchantAnimPath"), + "get_RestSiteAnimPath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + nameof(CharacterModel.RestSiteAnimPath), + ref __result, + "CustomRestSiteAnimPath"), + "get_IconTexturePath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + "IconTexturePath", + ref __result, + "CustomIconTexturePath"), + "get_IconPath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + "IconPath", + ref __result, + "CustomIconPath"), + "get_CharacterSelectBg" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + nameof(CharacterModel.CharacterSelectBg), + ref __result, + "CustomCharacterSelectBgPath", + "CustomCharacterSelectBg"), + "get_CharacterSelectIconPath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + "CharacterSelectIconPath", + ref __result, + "CustomCharacterSelectIconPath"), + "get_CharacterSelectLockedIconPath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + "CharacterSelectLockedIconPath", + ref __result, + "CustomCharacterSelectLockedIconPath"), + "get_CharacterSelectTransitionPath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + nameof(CharacterModel.CharacterSelectTransitionPath), + ref __result, + "CustomCharacterSelectTransitionPath"), + "get_MapMarkerPath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + "MapMarkerPath", + ref __result, + "CustomMapMarkerPath"), + "get_TrailPath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + nameof(CharacterModel.TrailPath), + ref __result, + "CustomTrailPath"), + "get_ArmPointingTexturePath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + "ArmPointingTexturePath", + ref __result, + "CustomArmPointingTexturePath"), + "get_ArmRockTexturePath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + "ArmRockTexturePath", + ref __result, + "CustomArmRockTexturePath"), + "get_ArmPaperTexturePath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + "ArmPaperTexturePath", + ref __result, + "CustomArmPaperTexturePath"), + "get_ArmScissorsTexturePath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + "ArmScissorsTexturePath", + ref __result, + "CustomArmScissorsTexturePath"), + "get_AttackSfx" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + nameof(CharacterModel.AttackSfx), + ref __result, + "CustomAttackSfx"), + "get_CastSfx" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + nameof(CharacterModel.CastSfx), + ref __result, + "CustomCastSfx"), + "get_DeathSfx" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + nameof(CharacterModel.DeathSfx), + ref __result, + "CustomDeathSfx"), + _ => false, + }; + + return !applied; + } + } + + internal class AndroidBaseLibMonsterStringPropertyCompatibilityPatch : IPatchMethod + { + public static string PatchId => "android_baselib_monster_string_property_compat"; + + public static bool IsCritical => false; + + public static string Description => + "Reflect BaseLib-style monster asset and audio getters on Android"; + + public static ModPatchTarget[] GetTargets() + { + return + [ + new(typeof(MonsterModel), "get_VisualsPath"), + new(typeof(MonsterModel), "get_AttackSfx"), + new(typeof(MonsterModel), "get_CastSfx"), + new(typeof(MonsterModel), "get_DeathSfx"), + ]; + } + + [HarmonyPriority(Priority.First)] + public static bool Prefix(MethodBase __originalMethod, MonsterModel __instance, ref string __result) + { + if (!OperatingSystem.IsAndroid()) + return true; + + var applied = __originalMethod.Name switch + { + "get_VisualsPath" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + "VisualsPath", + ref __result, + "CustomVisualsPath", + "CustomVisualPath"), + "get_AttackSfx" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + "AttackSfx", + ref __result, + "CustomAttackSfx"), + "get_CastSfx" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + "CastSfx", + ref __result, + "CustomCastSfx"), + "get_DeathSfx" => AndroidBaseLibPropertyCompatHelper.TryOverrideString( + __instance, + nameof(MonsterModel.DeathSfx), + ref __result, + "CustomDeathSfx"), + _ => false, + }; + + return !applied; + } + } +} diff --git a/STS2-RitsuLib-main/Interop/Patches/AndroidBaseLibVisualCompatibilityPatches.cs b/STS2-RitsuLib-main/Interop/Patches/AndroidBaseLibVisualCompatibilityPatches.cs new file mode 100644 index 0000000..f21efb6 --- /dev/null +++ b/STS2-RitsuLib-main/Interop/Patches/AndroidBaseLibVisualCompatibilityPatches.cs @@ -0,0 +1,303 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Animation; +using MegaCrit.Sts2.Core.Bindings.MegaSpine; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Nodes.Combat; +using STS2RitsuLib.Patching.Models; +using STS2RitsuLib.Scaffolding.Godot; + +namespace STS2RitsuLib.Interop.Patches +{ + internal static class AndroidBaseLibVisualCompatHelper + { + private static readonly MethodInfo? MonsterCreateVisualsMethod = + typeof(MonsterModel).GetMethod( + nameof(MonsterModel.CreateVisuals), + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + private sealed class CompatAccessors + { + public PropertyInfo? CustomVisualsPathProperty { get; init; } + public PropertyInfo? CustomVisualPathProperty { get; init; } + public PropertyInfo? VisualsPathProperty { get; init; } + public MethodInfo? SetupCustomAnimationStatesMethod { get; init; } + } + + private static readonly ConcurrentDictionary AccessorCache = new(); + private static readonly ConcurrentDictionary LoggedVisualConversions = new(); + private static readonly ConcurrentDictionary LoggedAnimatorConversions = new(); + + internal static bool TryCreateCreatureVisuals( + object model, + bool includeBaseVisualsPath, + out NCreatureVisuals? visuals) + { + visuals = null; + + if (!TryResolveVisualScenePath(model, includeBaseVisualsPath, out var scenePath, out var sourcePropertyName)) + return false; + + try + { + visuals = RitsuGodotNodeFactories.CreateFromScenePath(scenePath); + if (visuals == null) + return false; + + LogVisualConversionOnce(model.GetType(), sourcePropertyName, scenePath); + return true; + } + catch (Exception ex) + { + RitsuLibFramework.Logger.Warn( + $"[AndroidCompat] Failed to convert visuals for '{model.GetType().FullName}' from '{scenePath}': {ex.GetBaseException().Message}"); + return false; + } + } + + internal static bool TryCreateCreatureVisuals(object model, out NCreatureVisuals? visuals) + { + return TryCreateCreatureVisuals(model, false, out visuals); + } + + internal static bool TryCreateAnimator(object model, MegaSprite controller, out CreatureAnimator? animator) + { + animator = null; + + var setupMethod = GetAccessors(model.GetType()).SetupCustomAnimationStatesMethod; + if (setupMethod == null) + return false; + + try + { + animator = setupMethod.Invoke(model, [controller]) as CreatureAnimator; + if (animator == null) + return false; + + LogAnimatorConversionOnce(model.GetType()); + return true; + } + catch (Exception ex) + { + RitsuLibFramework.Logger.Warn( + $"[AndroidCompat] Failed to create custom animator for '{model.GetType().FullName}': {ex.GetBaseException().Message}"); + return false; + } + } + + private static CompatAccessors GetAccessors(Type type) + { + return AccessorCache.GetOrAdd(type, static currentType => + { + const BindingFlags instanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + var customVisualsPathProperty = currentType.GetProperty("CustomVisualsPath", instanceFlags); + if (customVisualsPathProperty?.PropertyType != typeof(string)) + customVisualsPathProperty = null; + + var visualPathProperty = currentType.GetProperty("CustomVisualPath", instanceFlags); + if (visualPathProperty?.PropertyType != typeof(string)) + visualPathProperty = null; + + var visualsPathProperty = currentType.GetProperty("VisualsPath", instanceFlags); + if (visualsPathProperty?.PropertyType != typeof(string)) + visualsPathProperty = null; + + var setupMethod = currentType.GetMethod("SetupCustomAnimationStates", instanceFlags, [typeof(MegaSprite)]); + if (setupMethod != null && !typeof(CreatureAnimator).IsAssignableFrom(setupMethod.ReturnType)) + setupMethod = null; + + return new CompatAccessors + { + CustomVisualsPathProperty = customVisualsPathProperty, + CustomVisualPathProperty = visualPathProperty, + VisualsPathProperty = visualsPathProperty, + SetupCustomAnimationStatesMethod = setupMethod, + }; + }); + } + + private static bool TryResolveVisualScenePath( + object model, + bool includeBaseVisualsPath, + out string scenePath, + out string sourcePropertyName) + { + var accessors = GetAccessors(model.GetType()); + + if (TryGetScenePath(model, accessors.CustomVisualsPathProperty, out scenePath, out sourcePropertyName)) + return true; + + if (TryGetScenePath(model, accessors.CustomVisualPathProperty, out scenePath, out sourcePropertyName)) + return true; + + if (includeBaseVisualsPath && + TryGetScenePath(model, accessors.VisualsPathProperty, out scenePath, out sourcePropertyName)) + { + return true; + } + + scenePath = string.Empty; + sourcePropertyName = string.Empty; + return false; + } + + private static bool TryGetScenePath( + object model, + PropertyInfo? property, + out string scenePath, + out string sourcePropertyName) + { + sourcePropertyName = property?.Name ?? string.Empty; + scenePath = property?.GetValue(model) as string ?? string.Empty; + return !string.IsNullOrWhiteSpace(scenePath); + } + + private static void LogVisualConversionOnce(Type type, string sourcePropertyName, string scenePath) + { + if (!LoggedVisualConversions.TryAdd($"{type.FullName}:{sourcePropertyName}:{scenePath}", 0)) + return; + + RitsuLibFramework.Logger.Info( + $"[AndroidCompat] Routed visuals for '{type.FullName}' through RitsuGodotNodeFactories via '{sourcePropertyName}': {scenePath}"); + } + + private static void LogAnimatorConversionOnce(Type type) + { + if (!LoggedAnimatorConversions.TryAdd(type.FullName ?? type.Name, 0)) + return; + + RitsuLibFramework.Logger.Info( + $"[AndroidCompat] Routed custom animator for '{type.FullName}' through reflective BaseLib compatibility."); + } + + internal static bool ShouldForceMonsterCreateVisualsFallback() + { + return MonsterCreateVisualsMethod != null && + AndroidBaseLibLegacyGeneratedCompatHelper.HasLegacyCompatOwner(MonsterCreateVisualsMethod); + } + } + + internal class AndroidBaseLibMonsterCreateVisualsCompatibilityPatch : IPatchMethod + { + public static string PatchId => "android_baselib_monster_create_visuals_compat"; + + public static bool IsCritical => false; + + public static string Description => + "Convert BaseLib-style custom monster visuals through RitsuGodotNodeFactories on Android"; + + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(MonsterModel), nameof(MonsterModel.CreateVisuals))]; + } + + [HarmonyPriority(Priority.First)] + public static bool Prefix(MonsterModel __instance, ref NCreatureVisuals __result) + { + if (!OperatingSystem.IsAndroid()) + return true; + + var includeBaseVisualsPath = AndroidBaseLibVisualCompatHelper.ShouldForceMonsterCreateVisualsFallback(); + + if (!AndroidBaseLibVisualCompatHelper.TryCreateCreatureVisuals( + __instance, + includeBaseVisualsPath, + out var visuals) || visuals == null) + { + return true; + } + + __result = visuals; + return false; + } + } + + internal class AndroidBaseLibCharacterCreateVisualsCompatibilityPatch : IPatchMethod + { + public static string PatchId => "android_baselib_character_create_visuals_compat"; + + public static bool IsCritical => false; + + public static string Description => + "Convert BaseLib-style custom character visuals through RitsuGodotNodeFactories on Android"; + + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), nameof(CharacterModel.CreateVisuals))]; + } + + [HarmonyPriority(Priority.First)] + public static bool Prefix(CharacterModel __instance, ref NCreatureVisuals __result) + { + if (!OperatingSystem.IsAndroid()) + return true; + + if (!AndroidBaseLibVisualCompatHelper.TryCreateCreatureVisuals(__instance, out var visuals) || visuals == null) + { + return true; + } + + __result = visuals; + return false; + } + } + + internal class AndroidBaseLibMonsterGenerateAnimatorCompatibilityPatch : IPatchMethod + { + public static string PatchId => "android_baselib_monster_generate_animator_compat"; + + public static bool IsCritical => false; + + public static string Description => + "Call BaseLib-style custom monster animator setup on Android"; + + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(MonsterModel), nameof(MonsterModel.GenerateAnimator))]; + } + + [HarmonyPriority(Priority.First)] + public static bool Prefix(MonsterModel __instance, MegaSprite controller, ref CreatureAnimator __result) + { + if (!OperatingSystem.IsAndroid()) + return true; + + if (!AndroidBaseLibVisualCompatHelper.TryCreateAnimator(__instance, controller, out var animator) || animator == null) + return true; + + __result = animator; + return false; + } + } + + internal class AndroidBaseLibCharacterGenerateAnimatorCompatibilityPatch : IPatchMethod + { + public static string PatchId => "android_baselib_character_generate_animator_compat"; + + public static bool IsCritical => false; + + public static string Description => + "Call BaseLib-style custom character animator setup on Android"; + + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), nameof(CharacterModel.GenerateAnimator))]; + } + + [HarmonyPriority(Priority.First)] + public static bool Prefix(CharacterModel __instance, MegaSprite controller, ref CreatureAnimator __result) + { + if (!OperatingSystem.IsAndroid()) + return true; + + if (!AndroidBaseLibVisualCompatHelper.TryCreateAnimator(__instance, controller, out var animator) || animator == null) + return true; + + __result = animator; + return false; + } + } +} diff --git a/STS2-RitsuLib-main/Localization/Patches/LocTableCompatibilityPatches.cs b/STS2-RitsuLib-main/Localization/Patches/LocTableCompatibilityPatches.cs new file mode 100644 index 0000000..b7d6904 --- /dev/null +++ b/STS2-RitsuLib-main/Localization/Patches/LocTableCompatibilityPatches.cs @@ -0,0 +1,403 @@ +using System.Reflection; +using MegaCrit.Sts2.Core.Localization; +using STS2RitsuLib.Content; +using STS2RitsuLib.Data; +using STS2RitsuLib.Patching.Models; + +namespace STS2RitsuLib.Localization.Patches +{ + internal static class LocTableCompatibilityPatchHelper + { + private static readonly FieldInfo? NameField = typeof(LocTable) + .GetField("_name", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly FieldInfo? TranslationsField = typeof(LocTable) + .GetField("_translations", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly FieldInfo? FallbackField = typeof(LocTable) + .GetField("_fallback", BindingFlags.Instance | BindingFlags.NonPublic); + + private static readonly HashSet AliasSupportedTables = new(StringComparer.OrdinalIgnoreCase) + { + "achievements", + "ancients", + "cards", + "characters", + "encounters", + "enchantments", + "events", + "modifiers", + "monsters", + "potions", + "powers", + "relics", + }; + + private static readonly Lock WarnLock = new(); + private static readonly HashSet WarnedMissingKeys = []; + private static readonly Lock AliasLock = new(); + private static readonly HashSet LoggedAliasKeys = []; + private static Dictionary? _cachedAliases; + + internal static bool TryRewriteCompatKey(LocTable table, ref string key) + { + if (string.IsNullOrWhiteSpace(key) || + !AliasSupportedTables.Contains(GetTableName(table)) || + HasEntryRaw(table, key)) + { + return false; + } + + var aliases = GetAliasMap(); + if (aliases.Count == 0) + return false; + + var segments = key.Split('.'); + var aliasedSegments = new List<(int Index, string[] Replacements)>(); + for (var i = 0; i < segments.Length; i++) + { + if (aliases.TryGetValue(segments[i], out var replacements) && replacements.Length > 0) + { + aliasedSegments.Add((i, replacements)); + } + } + + if (aliasedSegments.Count == 0) + return false; + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var candidate in EnumerateAliasCandidates( + segments, + aliasedSegments, + 0, + replacedAny: false, + seen)) + { + if (!HasEntryRaw(table, candidate)) + continue; + + LogAliasRewriteOnce(GetTableName(table), key, candidate); + key = candidate; + return true; + } + + return false; + } + + internal static bool ShouldUsePlaceholder(LocTable table, string key, string methodName, out string tableName) + { + tableName = GetTableName(table); + + if (!RitsuLibSettingsStore.IsLocTableCompatEnabled()) + return false; + + if (HasEntryRaw(table, key)) + return false; + + WarnMissingKeyOnce(tableName, key, methodName); + return true; + } + + internal static string GetTableName(LocTable table) + { + return NameField?.GetValue(table) as string ?? ""; + } + + private static IReadOnlyDictionary GetAliasMap() + { + lock (AliasLock) + { + if (_cachedAliases is { Count: > 0 }) + return _cachedAliases; + + _cachedAliases = BuildAliasMap(); + if (_cachedAliases.Count > 0) + { + RitsuLibFramework.Logger.Info( + $"[Localization][AndroidCompat] Built {_cachedAliases.Count} model localization alias mapping(s)."); + } + + return _cachedAliases; + } + } + + private static Dictionary BuildAliasMap() + { + var aliases = new Dictionary>(StringComparer.Ordinal); + + foreach (var snapshot in ModContentRegistry.GetRegisteredTypeSnapshots()) + { + var bareEntry = snapshot.ModelDbId?.Entry; + if (string.IsNullOrWhiteSpace(bareEntry)) + continue; + + AddAlias(aliases, bareEntry, snapshot.ExpectedPublicEntry); + AddAlias(aliases, bareEntry, BuildLegacyEntry(snapshot.ModId, bareEntry)); + } + + return aliases.ToDictionary( + static pair => pair.Key, + static pair => pair.Value + .Where(static alias => !string.IsNullOrWhiteSpace(alias)) + .Distinct(StringComparer.Ordinal) + .ToArray(), + StringComparer.Ordinal); + } + + private static void AddAlias(IDictionary> aliases, string bareEntry, string? alias) + { + if (string.IsNullOrWhiteSpace(bareEntry) || + string.IsNullOrWhiteSpace(alias) || + string.Equals(bareEntry, alias, StringComparison.Ordinal)) + { + return; + } + + if (!aliases.TryGetValue(bareEntry, out var values)) + { + values = []; + aliases[bareEntry] = values; + } + + values.Add(alias); + } + + private static string BuildLegacyEntry(string modId, string bareEntry) + { + var legacyStem = new string(modId.Where(static c => char.IsLetterOrDigit(c)).ToArray()).ToUpperInvariant(); + return string.IsNullOrWhiteSpace(legacyStem) + ? bareEntry + : $"{legacyStem}-{bareEntry}"; + } + + private static IEnumerable EnumerateAliasCandidates( + string[] segments, + IReadOnlyList<(int Index, string[] Replacements)> aliasedSegments, + int aliasIndex, + bool replacedAny, + ISet seen) + { + if (aliasIndex >= aliasedSegments.Count) + { + if (replacedAny) + { + var candidate = string.Join(".", segments); + if (seen.Add(candidate)) + yield return candidate; + } + + yield break; + } + + foreach (var candidate in EnumerateAliasCandidates( + segments, + aliasedSegments, + aliasIndex + 1, + replacedAny, + seen)) + { + yield return candidate; + } + + var (segmentIndex, replacements) = aliasedSegments[aliasIndex]; + var originalSegment = segments[segmentIndex]; + foreach (var replacement in replacements) + { + if (string.Equals(originalSegment, replacement, StringComparison.Ordinal)) + continue; + + segments[segmentIndex] = replacement; + foreach (var candidate in EnumerateAliasCandidates( + segments, + aliasedSegments, + aliasIndex + 1, + replacedAny: true, + seen)) + { + yield return candidate; + } + } + + segments[segmentIndex] = originalSegment; + } + + private static bool HasEntryRaw(LocTable table, string key) + { + for (var current = table; current != null; current = GetFallback(current)) + { + if (GetTranslations(current)?.ContainsKey(key) == true) + return true; + } + + return false; + } + + private static Dictionary? GetTranslations(LocTable table) + { + return TranslationsField?.GetValue(table) as Dictionary; + } + + private static LocTable? GetFallback(LocTable table) + { + return FallbackField?.GetValue(table) as LocTable; + } + + private static void WarnMissingKeyOnce(string tableName, string key, string methodName) + { + var warnKey = $"{tableName}:{key}:{methodName}"; + + lock (WarnLock) + { + if (!WarnedMissingKeys.Add(warnKey)) + return; + } + + RitsuLibFramework.Logger.Warn( + $"[Localization][DebugCompat] Missing localization key '{key}' in table '{tableName}' during {methodName}. " + + "Resolving to key placeholder (debug compat)."); + } + + private static void LogAliasRewriteOnce(string tableName, string sourceKey, string rewrittenKey) + { + var logKey = $"{tableName}:{sourceKey}:{rewrittenKey}"; + + lock (AliasLock) + { + if (!LoggedAliasKeys.Add(logKey)) + return; + } + + RitsuLibFramework.Logger.Info( + $"[Localization][AndroidCompat] Rewrote key '{sourceKey}' -> '{rewrittenKey}' in table '{tableName}'."); + } + } + + /// + /// Rewrites bare Android-safe-mode ModelDb keys to fixed public or legacy localization entries before + /// LocTable.HasEntry evaluates them. This keeps LocString.Exists and + /// LocString.GetIfExists working for modded content when identity detours are intentionally disabled. + /// + public class LocTableHasEntryCompatibilityPatch : IPatchMethod + { + /// + public static string PatchId => "loc_table_has_entry_android_compat"; + + /// + public static string Description => + "Rewrite Android-safe-mode mod localization keys before LocTable.HasEntry runs"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return + [ + new(typeof(LocTable), nameof(LocTable.HasEntry), [typeof(string)]), + ]; + } + + // ReSharper disable InconsistentNaming + /// + /// Mutates the lookup key in-place so downstream existence checks see the aliased key. + /// + public static void Prefix(LocTable __instance, ref string key) + // ReSharper restore InconsistentNaming + { + LocTableCompatibilityPatchHelper.TryRewriteCompatKey(__instance, ref key); + } + } + + /// + /// When is true, returns a placeholder + /// LocString and logs [Localization][DebugCompat] once per key for misses in + /// LocTable.GetLocString. When false, vanilla throw-on-miss behavior applies. + /// + public class LocTableGetLocStringCompatibilityPatch : IPatchMethod + { + /// + public static string PatchId => "loc_table_get_loc_string_debug_compat"; + + /// + public static string Description => + "Use key placeholder for LocTable.GetLocString missing entries in debug compatibility mode"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return + [ + new(typeof(LocTable), nameof(LocTable.GetLocString), [typeof(string)]), + ]; + } + + // ReSharper disable InconsistentNaming + /// + /// Short-circuits the target method with a synthesized loc string when a placeholder is required. + /// + public static bool Prefix(LocTable __instance, ref string key, ref LocString __result) + // ReSharper restore InconsistentNaming + { + LocTableCompatibilityPatchHelper.TryRewriteCompatKey(__instance, ref key); + + if (!LocTableCompatibilityPatchHelper.ShouldUsePlaceholder( + __instance, + key, + nameof(LocTable.GetLocString), + out var tableName)) + return true; + + __result = new(tableName, key); + return false; + } + } + + /// + /// When is true, returns the raw key + /// string and logs [Localization][DebugCompat] once per key for misses in LocTable.GetRawText. + /// When false, vanilla throw-on-miss behavior applies. + /// + public class LocTableGetRawTextCompatibilityPatch : IPatchMethod + { + /// + public static string PatchId => "loc_table_get_raw_text_debug_compat"; + + /// + public static string Description => + "Use key placeholder for LocTable.GetRawText missing entries in debug compatibility mode"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return + [ + new(typeof(LocTable), nameof(LocTable.GetRawText), [typeof(string)]), + ]; + } + + // ReSharper disable InconsistentNaming + /// + /// Short-circuits the target method with the key as raw text when a placeholder is required. + /// + public static bool Prefix(LocTable __instance, ref string key, ref string __result) + // ReSharper restore InconsistentNaming + { + LocTableCompatibilityPatchHelper.TryRewriteCompatKey(__instance, ref key); + + if (!LocTableCompatibilityPatchHelper.ShouldUsePlaceholder( + __instance, + key, + nameof(LocTable.GetRawText), + out _)) + return true; + + __result = key; + return false; + } + } +} diff --git a/STS2-RitsuLib-main/Patching/Core/ModPatcher.cs b/STS2-RitsuLib-main/Patching/Core/ModPatcher.cs new file mode 100644 index 0000000..cd030e2 --- /dev/null +++ b/STS2-RitsuLib-main/Patching/Core/ModPatcher.cs @@ -0,0 +1,470 @@ +using System.Reflection; +using System.Text; +using HarmonyLib; +using MegaCrit.Sts2.Core.Logging; +using STS2RitsuLib.Patching.Models; + +namespace STS2RitsuLib.Patching.Core +{ + /// + /// Owns one Harmony instance: registers static and dynamic patches, applies them, and can roll back. + /// + /// Harmony id (must be unique per logical patcher). + /// Logger used for patch diagnostics. + /// Optional display name included in log prefix. + public class ModPatcher(string patcherId, Logger logger, string patcherName = "") + { + private readonly Harmony _harmony = new(patcherId); + + private readonly string _logPrefix = + string.IsNullOrEmpty(patcherName) ? "[Patcher] " : $"[Patcher - {patcherName}] "; + + private readonly Dictionary _patchedStatus = []; + private readonly List _registeredDynamicPatches = []; + private readonly List _registeredPatches = []; + + /// + /// Harmony instance id passed to the constructor. + /// + public string PatcherId => patcherId; + + /// + /// Human-readable patcher label for logs. + /// + public string PatcherName => patcherName; + + /// + /// Logger associated with this patcher. + /// + public Logger Logger => logger; + + /// + /// Count of registered static entries. + /// + public int RegisteredPatchCount => _registeredPatches.Count; + + /// + /// Count of registered entries. + /// + public int RegisteredDynamicPatchCount => _registeredDynamicPatches.Count; + + /// + /// Number of patches currently marked applied in internal state. + /// + public int AppliedPatchCount => _patchedStatus.Count(kvp => kvp.Value); + + /// + /// Snapshot of static patch registrations. + /// + public IReadOnlyList RegisteredPatches => _registeredPatches; + + /// + /// True after succeeds without rolling back. + /// + public bool IsApplied { get; private set; } + + /// + /// Queues a static patch; throws if is already true. + /// + public void RegisterPatch(ModPatchInfo modPatchInfo) + { + if (IsApplied) + { + logger.Error( + $"{_logPrefix}Cannot register patch '{modPatchInfo.Id}': Patches have already been applied"); + throw new InvalidOperationException("Cannot register patches after they have been applied"); + } + + if (_registeredPatches.Any(p => p.Id == modPatchInfo.Id)) + { + logger.Warn($"{_logPrefix}Patch '{modPatchInfo.Id}' already registered, skipping duplicate"); + return; + } + + ValidatePatchType(modPatchInfo); + PatchLog.Bind(modPatchInfo.PatchType, logger); + + _registeredPatches.Add(modPatchInfo); + logger.Debug($"{_logPrefix}Registered patch: {modPatchInfo.Id} - {modPatchInfo.Description}"); + } + + /// + /// Calls for each entry in . + /// + public void RegisterPatches(params ReadOnlySpan patches) + { + foreach (var patch in patches) RegisterPatch(patch); + } + + /// + /// Queues a dynamic patch (resolved + Harmony methods). + /// + public void RegisterDynamicPatch(DynamicPatchInfo dynamicPatchInfo) + { + ArgumentNullException.ThrowIfNull(dynamicPatchInfo); + + if (_registeredDynamicPatches.Any(p => p.Id == dynamicPatchInfo.Id)) + { + logger.Warn( + $"{_logPrefix}Dynamic patch '{dynamicPatchInfo.Id}' already registered, skipping duplicate"); + return; + } + + _registeredDynamicPatches.Add(dynamicPatchInfo); + logger.Debug( + $"{_logPrefix}Registered dynamic patch: {dynamicPatchInfo.Id} - {dynamicPatchInfo.Description}"); + } + + /// + /// Calls for each entry. + /// + public void RegisterDynamicPatches(params ReadOnlySpan dynamicPatches) + { + foreach (var patch in dynamicPatches) RegisterDynamicPatch(patch); + } + + /// + /// Registers and immediately applies dynamic patches; optionally rolls back all Harmony patches on critical + /// failure. + /// + /// False when any critical patch fails and rollback was requested or needed. + public bool ApplyDynamicPatches(IEnumerable dynamicPatches, + bool rollbackOnCriticalFailure = false) + { + ArgumentNullException.ThrowIfNull(dynamicPatches); + + var patches = dynamicPatches.ToArray(); + if (patches.Length == 0) + return true; + + RegisterDynamicPatches(patches); + + logger.Info($"{_logPrefix}Applying {patches.Length} dynamic patch(es)..."); + + var successCount = 0; + var failureCount = 0; + var criticalFailureCount = 0; + + foreach (var patch in patches) + { + var (success, errorMessage, exception) = ApplyDynamicPatch(patch); + + if (success) + { + successCount++; + logger.Info($"{_logPrefix}[{(patch.IsCritical ? "Critical" : "Optional")}] {patch.Id} - Success ✓"); + continue; + } + + failureCount++; + if (patch.IsCritical) + criticalFailureCount++; + + var sb = new StringBuilder(); + sb.AppendLine($"{_logPrefix}[{(patch.IsCritical ? "Critical" : "Optional")}] {patch.Id} - Failed ✗"); + if (exception != null) + sb.Append($"Exception: {exception}"); + else + sb.Append($"Error: {errorMessage}"); + logger.Error(sb.ToString()); + } + + logger.Info($"{_logPrefix}Dynamic patch application complete: {successCount}/{patches.Length} succeeded"); + + if (failureCount > 0) + logger.Warn( + criticalFailureCount > 0 + ? $"{_logPrefix}{failureCount} dynamic patch(es) failed, including {criticalFailureCount} critical failure(s)" + : $"{_logPrefix}{failureCount} dynamic patch(es) failed, but no critical failures"); + + if (criticalFailureCount == 0) + return true; + + if (rollbackOnCriticalFailure) + UnpatchAll(); + + return false; + } + + /// + /// Applies all registered static patches once; on critical failure calls . + /// + /// True when no critical patch failed. + public bool PatchAll() + { + if (IsApplied) + { + logger.Warn($"{_logPrefix}Patches have already been applied, skipping"); + return true; + } + + logger.Info($"{_logPrefix}Applying {_registeredPatches.Count} patches..."); + var results = new ModPatchResult[_registeredPatches.Count]; + for (var i = 0; i < _registeredPatches.Count; i++) + results[i] = ApplyPatch(_registeredPatches[i]); + var success = ProcessPatchResults(results); + var ignoredCount = results.Count(result => result.Ignored); + var failedCount = results.Count(result => !result.Success); + + if (success) + { + IsApplied = true; + if (ignoredCount == 0 && failedCount == 0) + logger.Info($"{_logPrefix}All patches applied successfully"); + else if (failedCount == 0) + logger.Info( + $"{_logPrefix}All required patches applied; {ignoredCount} optional patch target(s) were ignored"); + else + logger.Warn( + $"{_logPrefix}Critical patches succeeded, but some optional patches failed to apply"); + } + else + { + logger.Error($"{_logPrefix}Critical patch(es) failed, rolling back all patches..."); + UnpatchAll(); + IsApplied = false; + } + + return success; + } + + /// + /// Removes all applied patches tracked by this instance from the underlying Harmony id. + /// + public void UnpatchAll() + { + if (_registeredPatches.Count == 0 && _registeredDynamicPatches.Count == 0) + { + logger.Debug($"{_logPrefix}No patches registered, skipping unpatch"); + return; + } + + var appliedCount = + _registeredPatches.Count(patchInfo => _patchedStatus.GetValueOrDefault(patchInfo.Id, false)) + + _registeredDynamicPatches.Count(patchInfo => _patchedStatus.GetValueOrDefault(patchInfo.Id, false)); + + if (appliedCount == 0) + { + logger.Debug($"{_logPrefix}No patches applied, skipping unpatch"); + IsApplied = false; + return; + } + + logger.Info($"{_logPrefix}Removing {appliedCount} applied patches..."); + + foreach (var patchInfo in _registeredPatches.Where(patchInfo => + _patchedStatus.GetValueOrDefault(patchInfo.Id, false))) + try + { + var originalMethod = GetOriginalMethod(patchInfo); + if (originalMethod == null) continue; + _harmony.Unpatch(originalMethod, HarmonyPatchType.All, _harmony.Id); + _patchedStatus[patchInfo.Id] = false; + logger.Info($"{_logPrefix}✓ Removed patch: {patchInfo.Id}"); + } + catch (Exception ex) + { + logger.Error($"{_logPrefix}✗ Failed to remove patch: {patchInfo.Id} - {ex.Message}"); + } + + foreach (var patchInfo in _registeredDynamicPatches.Where(patchInfo => + _patchedStatus.GetValueOrDefault(patchInfo.Id, false))) + try + { + _harmony.Unpatch(patchInfo.OriginalMethod, HarmonyPatchType.All, _harmony.Id); + _patchedStatus[patchInfo.Id] = false; + logger.Info($"{_logPrefix}✓ Removed dynamic patch: {patchInfo.Id}"); + } + catch (Exception ex) + { + logger.Error($"{_logPrefix}✗ Failed to remove dynamic patch: {patchInfo.Id} - {ex.Message}"); + } + + IsApplied = false; + logger.Info($"{_logPrefix}All patches removed"); + } + + private ModPatchResult ApplyPatch(ModPatchInfo modPatchInfo) + { + try + { + logger.Info( + $"{_logPrefix}Applying patch '{modPatchInfo.Id}' " + + $"-> {modPatchInfo.TargetType.FullName}.{modPatchInfo.MethodName}" + ); + + var originalMethod = GetOriginalMethod(modPatchInfo); + if (originalMethod == null) + { + _patchedStatus[modPatchInfo.Id] = false; + if (modPatchInfo.IgnoreIfTargetMissing) + return ModPatchResult.CreateIgnored( + modPatchInfo, + $"Target method not found but patch is marked ignorable: {modPatchInfo.TargetType.Name}.{modPatchInfo.MethodName}"); + + return ModPatchResult.CreateFailure( + modPatchInfo, + $"Target method not found: {modPatchInfo.TargetType.Name}.{modPatchInfo.MethodName}" + ); + } + + var prefix = GetPatchMethod(modPatchInfo.PatchType, "Prefix"); + var postfix = GetPatchMethod(modPatchInfo.PatchType, "Postfix"); + var transpiler = GetPatchMethod(modPatchInfo.PatchType, "Transpiler"); + var finalizer = GetPatchMethod(modPatchInfo.PatchType, "Finalizer"); + + if (prefix == null && postfix == null && transpiler == null && finalizer == null) + { + _patchedStatus[modPatchInfo.Id] = false; + return ModPatchResult.CreateFailure( + modPatchInfo, + $"No valid patch methods found in {modPatchInfo.PatchType.Name}" + ); + } + + _harmony.Patch( + originalMethod, + prefix != null ? new HarmonyMethod(prefix) : null, + postfix != null ? new HarmonyMethod(postfix) : null, + transpiler != null ? new HarmonyMethod(transpiler) : null, + finalizer != null ? new HarmonyMethod(finalizer) : null + ); + + _patchedStatus[modPatchInfo.Id] = true; + return ModPatchResult.CreateSuccess(modPatchInfo); + } + catch (Exception ex) + { + _patchedStatus[modPatchInfo.Id] = false; + return ModPatchResult.CreateFailure(modPatchInfo, ex.Message, ex); + } + } + + private (bool Success, string ErrorMessage, Exception? Exception) ApplyDynamicPatch( + DynamicPatchInfo dynamicPatchInfo) + { + try + { + logger.Info( + $"{_logPrefix}Applying dynamic patch '{dynamicPatchInfo.Id}' " + + $"-> {dynamicPatchInfo.OriginalMethod.DeclaringType?.FullName}.{dynamicPatchInfo.OriginalMethod.Name}" + ); + + if (!dynamicPatchInfo.HasPatchMethods) + { + _patchedStatus[dynamicPatchInfo.Id] = false; + return (false, $"No valid patch methods found for dynamic patch '{dynamicPatchInfo.Id}'", null); + } + + _harmony.Patch( + dynamicPatchInfo.OriginalMethod, + dynamicPatchInfo.Prefix, + dynamicPatchInfo.Postfix, + dynamicPatchInfo.Transpiler, + dynamicPatchInfo.Finalizer); + + _patchedStatus[dynamicPatchInfo.Id] = true; + return (true, string.Empty, null); + } + catch (Exception ex) + { + _patchedStatus[dynamicPatchInfo.Id] = false; + return (false, ex.Message, ex); + } + } + + private bool ProcessPatchResults(ReadOnlySpan results) + { + var successCount = 0; + var ignoredCount = 0; + var failureCount = 0; + var criticalFailureCount = 0; + + var sortedResults = results.ToArray() + .OrderBy(r => r.Success) + .ThenByDescending(r => r.ModPatchInfo.IsCritical) + .ThenBy(r => r.ModPatchInfo.Id); + + foreach (var result in sortedResults) + { + var importance = result.ModPatchInfo.IsCritical ? "Critical" : "Optional"; + + if (result.Success) + { + successCount++; + if (result.Ignored) + ignoredCount++; + + logger.Info(result.Ignored + ? $"{_logPrefix}[{importance}] {result.ModPatchInfo.Id} - Ignored (target missing)" + : $"{_logPrefix}[{importance}] {result.ModPatchInfo.Id} - Success ✓"); + } + else + { + failureCount++; + if (result.ModPatchInfo.IsCritical) + criticalFailureCount++; + + var failureLog = new StringBuilder(); + failureLog.AppendLine($"{_logPrefix}[{importance}] {result.ModPatchInfo.Id} - Failed ✗"); + failureLog.AppendLine($"{_logPrefix} Description: {result.ModPatchInfo.Description}"); + failureLog.AppendLine($"{_logPrefix} Error: {result.ErrorMessage}"); + if (result.Exception != null) + failureLog.Append($"{_logPrefix} Exception: {result.Exception}"); + logger.Error(failureLog.ToString()); + } + } + + logger.Info( + $"{_logPrefix}Patch application complete: {successCount - ignoredCount} applied, {ignoredCount} ignored, {failureCount} failed, {results.Length} total"); + + if (failureCount > 0) logger.Warn($"{_logPrefix}{failureCount} patch(es) failed"); + + if (criticalFailureCount == 0) return true; + logger.Error($"{_logPrefix}{criticalFailureCount} critical patch(es) failed, mod loading blocked"); + return false; + } + + private static MethodInfo? GetOriginalMethod(ModPatchInfo modPatchInfo) + { + if (modPatchInfo.ParameterTypes != null) + return modPatchInfo.TargetType.GetMethod( + modPatchInfo.MethodName, + BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, + null, + modPatchInfo.ParameterTypes, + null + ); + + return modPatchInfo.TargetType.GetMethod( + modPatchInfo.MethodName, + BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic + ); + } + + private static MethodInfo? GetPatchMethod(Type patchType, string methodName) + { + return patchType.GetMethod( + methodName, + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic + ); + } + + /// + /// Validate that patch type implements IPatchMethod interface (optional but recommended) + /// + private void ValidatePatchType(ModPatchInfo modPatchInfo) + { + var patchType = modPatchInfo.PatchType; + var implementsIPatchMethod = patchType.GetInterfaces() + .Any(i => i.Name == nameof(IPatchMethod) || + (i.IsGenericType && i.GetGenericTypeDefinition().GetInterfaces() + .Any(gi => gi.Name == nameof(IPatchMethod)))); + + if (!implementsIPatchMethod) + logger.Warn( + $"{_logPrefix}Patch type '{patchType.Name}' does not implement IPatchMethod interface. " + + "Consider implementing IPatchMethod interfaces for better type safety and IDE support."); + } + } +} diff --git a/STS2-RitsuLib-main/RitsuLibFramework.PatcherSetup.cs b/STS2-RitsuLib-main/RitsuLibFramework.PatcherSetup.cs new file mode 100644 index 0000000..0b1fc10 --- /dev/null +++ b/STS2-RitsuLib-main/RitsuLibFramework.PatcherSetup.cs @@ -0,0 +1,417 @@ +using STS2RitsuLib.Cards.FreePlay.Patches; +using STS2RitsuLib.Cards.Patches; +using STS2RitsuLib.Combat.CardTargeting.Patches; +using STS2RitsuLib.Combat.HealthBars.Patches; +using STS2RitsuLib.Combat.Rewards.Patches; +using STS2RitsuLib.Content.Patches; +using STS2RitsuLib.Interop.Patches; +using STS2RitsuLib.Lifecycle.Patches; +using STS2RitsuLib.Localization.Patches; +using STS2RitsuLib.Patching.Core; +using STS2RitsuLib.Relics.Patches; +using STS2RitsuLib.Scaffolding.Cards.HandGlow.Patches; +using STS2RitsuLib.Scaffolding.Cards.HandOutline.Patches; +using STS2RitsuLib.Scaffolding.Characters.Patches; +using STS2RitsuLib.Scaffolding.Content.Patches; +using STS2RitsuLib.Scaffolding.Godot; +using STS2RitsuLib.Settings.Patches; +using STS2RitsuLib.Timeline.Patches; +using STS2RitsuLib.Unlocks.Patches; +using STS2RitsuLib.Utils.Persistence.Patches; + +namespace STS2RitsuLib +{ + public static partial class RitsuLibFramework + { + /// + /// Android Arm64 + Mono 上 Harmony detour 仍存在 native SIGSEGV 风险。 + /// 当前先启用保守兼容模式,按补丁分组整体降级,优先保证游戏能启动。 + /// + private static bool IsAndroidHarmonySafeMode() + { + return OperatingSystem.IsAndroid(); + } + + internal static ModPatcher GetFrameworkPatcher(FrameworkPatcherArea area) + { + lock (SyncRoot) + { + return FrameworkPatchersByArea.TryGetValue(area, out var patcher) + ? patcher + : throw new InvalidOperationException($"Framework patcher for area '{area}' is not available yet."); + } + } + + private static bool PatchAllRequired() + { + foreach (var area in Enum.GetValues()) + { + if (!FrameworkPatchersByArea.TryGetValue(area, out var patcher)) + throw new InvalidOperationException($"Framework patcher for area '{area}' was not initialized."); + + if (!patcher.PatchAll()) + return false; + } + + return true; + } + + private static void RegisterFrameworkPatcher(FrameworkPatcherArea area, ModPatcher patcher) + { + if (!FrameworkPatchersByArea.TryAdd(area, patcher)) + throw new InvalidOperationException($"Duplicate framework patcher registration for area '{area}'."); + } + + private static void RegisterLifecyclePatches() + { + var patcher = CreatePatcher(Const.ModId, "framework-core", "framework core"); + if (IsAndroidHarmonySafeMode()) + { + Logger.Warn( + "[Patcher - framework core] Android safe mode enabled. " + + "Keeping minimal ModelDb init compatibility, localization, BaseLib visual/property compatibility, and ancient dialogue shims on Arm64/Mono." + ); + // Android still needs the lightweight ModelDb init lifecycle hook so ReflectionHelper type caches + // are refreshed before ModelDb scans mod assemblies, and dynamically registered models can still + // be injected without restoring the heavier lifecycle detour set. + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + RegisterFrameworkPatcher(FrameworkPatcherArea.Core, patcher); + return; + } + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + RegisterFrameworkPatcher(FrameworkPatcherArea.Core, patcher); + } + + private static void RegisterContentAssetPatches() + { + RitsuGodotNodeFactoryBootstrap.EnsureRegistered(); + + var patcher = CreatePatcher(Const.ModId, "framework-content-assets", "content assets"); + if (IsAndroidHarmonySafeMode()) + { + Logger.Warn( + "[Patcher - content assets] Android safe mode enabled. " + + "Skipping content-asset Harmony detours on Arm64/Mono." + ); + RegisterFrameworkPatcher(FrameworkPatcherArea.ContentAssets, patcher); + return; + } + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + + patcher.RegisterPatch(); + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + + patcher.RegisterPatch(); + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + RegisterFrameworkPatcher(FrameworkPatcherArea.ContentAssets, patcher); + } + + private static void RegisterSettingsUiPatches() + { + var patcher = CreatePatcher(Const.ModId, "framework-settings-ui", "settings ui"); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + RegisterFrameworkPatcher(FrameworkPatcherArea.SettingsUi, patcher); + } + + private static void RegisterCharacterAssetPatches() + { + var patcher = CreatePatcher(Const.ModId, "framework-character-assets", "character assets"); + if (IsAndroidHarmonySafeMode()) + { + Logger.Warn( + "[Patcher - character assets] Android safe mode enabled. " + + "Keeping the compendium UI compatibility patch and skipping the rest of character-asset Harmony detours on Arm64/Mono." + ); + patcher.RegisterPatch(); + RegisterFrameworkPatcher(FrameworkPatcherArea.CharacterAssets, patcher); + return; + } + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + RegisterFrameworkPatcher(FrameworkPatcherArea.CharacterAssets, patcher); + } + + private static void RegisterContentRegistryPatches() + { + var patcher = CreatePatcher(Const.ModId, "framework-content-registry", "content registry"); + if (IsAndroidHarmonySafeMode()) + { + Logger.Warn( + "[Patcher - content registry] Android safe mode enabled. " + + "Keeping fixed ModelDb entry compatibility, minimal model enumeration patches, and ModelIdSerializationCache sync patch, " + + "and skipping the rest of content-registry Harmony detours on Arm64/Mono." + ); + + // Android still needs a minimal set of getter postfixes so ModelDb-injected content can show up in + // character selection, card/relic/potion pool enumeration, and runtime lookups without restoring the + // higher-risk act/unlock/content-registry detour chain. + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + RegisterFrameworkPatcher(FrameworkPatcherArea.ContentRegistry, patcher); + return; + } + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + RegisterFrameworkPatcher(FrameworkPatcherArea.ContentRegistry, patcher); + } + + private static void RegisterPersistencePatches() + { + var patcher = CreatePatcher(Const.ModId, "framework-persistence", "persistence"); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + RegisterFrameworkPatcher(FrameworkPatcherArea.Persistence, patcher); + } + + private static void RegisterUnlockPatches() + { + var patcher = CreatePatcher(Const.ModId, "framework-unlocks", "unlocks"); + if (IsAndroidHarmonySafeMode()) + { + Logger.Warn( + "[Patcher - unlocks] Android safe mode enabled. " + + "Skipping unlock Harmony detours on Arm64/Mono." + ); + RegisterFrameworkPatcher(FrameworkPatcherArea.Unlocks, patcher); + return; + } + + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + patcher.RegisterPatch(); + RegisterFrameworkPatcher(FrameworkPatcherArea.Unlocks, patcher); + } + + internal enum FrameworkPatcherArea + { + Core, + SettingsUi, + ContentAssets, + CharacterAssets, + ContentRegistry, + Persistence, + Unlocks, + } + } +} diff --git a/STS2-RitsuLib-main/Scaffolding/Characters/Patches/CardLibraryCompendiumPatch.cs b/STS2-RitsuLib-main/Scaffolding/Characters/Patches/CardLibraryCompendiumPatch.cs new file mode 100644 index 0000000..b0002e1 --- /dev/null +++ b/STS2-RitsuLib-main/Scaffolding/Characters/Patches/CardLibraryCompendiumPatch.cs @@ -0,0 +1,226 @@ +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Assets; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Nodes.Combat; +using MegaCrit.Sts2.Core.Nodes.Screens.CardLibrary; +using STS2RitsuLib.Content; +using STS2RitsuLib.Patching.Models; +using STS2RitsuLib.Utils; + +namespace STS2RitsuLib.Scaffolding.Characters.Patches +{ + /// + /// Adds a pool-filter button for each registered mod character in the card library compendium. + /// Without this patch, mod character cards are not visible in any filter category, and opening + /// the card library during a run with a mod character causes a KeyNotFoundException crash. + /// Buttons are inserted before the colorless pool filter when possible (then ancients, misc), + /// so they stay with playable-character filters rather than after misc/token-style pools. + /// + public class CardLibraryCompendiumPatch : IPatchMethod + { + /// + public static string PatchId => "card_library_compendium_mod_character_filter"; + + /// + public static string Description => + "Add mod character pool filter buttons to the card library compendium"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(NCardLibrary), nameof(NCardLibrary._Ready))]; + } + + // ReSharper disable InconsistentNaming + /// + /// Clones vanilla pool-filter UI for each mod character and wires pool predicates so compendium filtering + /// works without KeyNotFoundException. + /// + public static void Postfix( + NCardLibrary __instance, + Dictionary> ____poolFilters, + Dictionary ____cardPoolFilters) + // ReSharper restore InconsistentNaming + { + CardLibraryCompendiumPatchHelper.TryEnsureModFilters(__instance, ____poolFilters, ____cardPoolFilters); + } + } + + /// + /// Android/Mono safe-mode variant: avoid patching NCardLibrary._Ready directly because the generated + /// Harmony wrapper hits MethodAccessException when the base method calls inaccessible submenu helpers. + /// Instead, inject the filters right before the submenu opens, after the unpatched _Ready has already + /// initialized the filter dictionaries. + /// + public class AndroidCardLibraryCompendiumPatch : IPatchMethod + { + public static string PatchId => "android_card_library_compendium_mod_character_filter"; + + public static string Description => + "Add mod character pool filter buttons to the card library compendium on Android without patching NCardLibrary._Ready"; + + public static bool IsCritical => false; + + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(NCardLibrary), "OnSubmenuOpened")]; + } + + // ReSharper disable InconsistentNaming + public static void Prefix( + NCardLibrary __instance, + Dictionary> ____poolFilters, + Dictionary ____cardPoolFilters) + // ReSharper restore InconsistentNaming + { + CardLibraryCompendiumPatchHelper.TryEnsureModFilters(__instance, ____poolFilters, ____cardPoolFilters); + } + } + + internal static class CardLibraryCompendiumPatchHelper + { + /// + /// Prefer inserting mod character filters immediately before non-character pool toggles: colorless, then + /// ancients, then misc (vanilla has no separate token node; those pools follow). Falls back when no anchor + /// resolves under . + /// + internal static bool TryGetModFilterInsertIndex( + NCardLibrary library, + Node expectedParent, + out int insertIndex) + { + ReadOnlySpan anchorNames = + [ + "%ColorlessPool", + "%AncientsPool", + "%MiscPool", + ]; + + foreach (var name in anchorNames) + { + if (library.GetNodeOrNull(name) is not { } anchor) + continue; + if (anchor.GetParent() != expectedParent) + continue; + + insertIndex = anchor.GetIndex(); + return true; + } + + insertIndex = 0; + return false; + } + + internal static void TryEnsureModFilters( + NCardLibrary library, + Dictionary> poolFilters, + Dictionary cardPoolFilters) + { + var modCharacters = ModContentRegistry.GetModCharacters().ToArray(); + if (modCharacters.Length == 0) return; + if (cardPoolFilters.Count == 0) return; + + var referenceFilter = cardPoolFilters.Values.First(); + var filterParent = referenceFilter.GetParent(); + if (filterParent == null) return; + + var useOrderedInsert = TryGetModFilterInsertIndex(library, filterParent, out var insertIndex); + + ShaderMaterial? referenceMat = null; + if (referenceFilter.GetNodeOrNull("Image") is { Material: ShaderMaterial refMat }) + referenceMat = refMat; + + var updateMethod = AccessTools.Method(typeof(NCardLibrary), "UpdateCardPoolFilter"); + var updateCallable = Callable.From(f => updateMethod.Invoke(library, [f])); + var lastHoveredField = AccessTools.Field(typeof(NCardLibrary), "_lastHoveredControl"); + + var nextIndex = insertIndex; + foreach (var character in modCharacters) + { + if (cardPoolFilters.ContainsKey(character)) + continue; + + var existingFilter = filterParent.GetNodeOrNull($"MOD_FILTER_{character.Id.Entry}"); + if (existingFilter != null) + { + cardPoolFilters[character] = existingFilter; + continue; + } + + string? iconTexturePath = null; + if (character is IModCharacterAssetOverrides assetOverrides) + iconTexturePath = assetOverrides.CustomIconTexturePath; + + var filter = CreateFilter(character, iconTexturePath, referenceMat); + filterParent.AddChild(filter, true); + if (useOrderedInsert) + { + filterParent.MoveChild(filter, nextIndex); + nextIndex++; + } + + var pool = character.CardPool; + poolFilters[filter] = c => pool.AllCardIds.Contains(c.Id); + cardPoolFilters[character] = filter; + + filter.Connect(NCardPoolFilter.SignalName.Toggled, updateCallable); + filter.Connect(Control.SignalName.FocusEntered, + Callable.From(delegate { lastHoveredField.SetValue(library, filter); })); + } + } + + private static NCardPoolFilter CreateFilter( + CharacterModel character, + string? iconTexturePath, + ShaderMaterial? referenceMat) + { + const float size = 64f; + const float imageSize = 56f; + const float imagePos = 4f; + + var filter = new NCardPoolFilter + { + Name = $"MOD_FILTER_{character.Id.Entry}", + CustomMinimumSize = new(size, size), + Size = new(size, size), + }; + + var mat = (ShaderMaterial?)referenceMat?.Duplicate(); + + var image = new TextureRect + { + Name = "Image", + ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize, + StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered, + Size = new(imageSize, imageSize), + Position = new(imagePos, imagePos), + Scale = new(0.9f, 0.9f), + PivotOffset = new(28f, 28f), + }; + + image.Material = mat ?? MaterialUtils.CreateHsvShaderMaterial(1, 1, 1); + + if (!string.IsNullOrWhiteSpace(iconTexturePath) && + AssetPathDiagnostics.Exists(iconTexturePath, character, + nameof(IModCharacterAssetOverrides.CustomIconTexturePath))) + image.Texture = ResourceLoader.Load(iconTexturePath); + + filter.AddChild(image); + image.Owner = filter; + + var reticlePath = SceneHelper.GetScenePath("ui/selection_reticle"); + var reticle = PreloadManager.Cache.GetScene(reticlePath).Instantiate(); + reticle.Name = "SelectionReticle"; + reticle.UniqueNameInOwner = true; + filter.AddChild(reticle); + reticle.Owner = filter; + + return filter; + } + } +} diff --git a/STS2-RitsuLib-main/Scaffolding/Characters/Patches/CharacterAssetOverridePatches.cs b/STS2-RitsuLib-main/Scaffolding/Characters/Patches/CharacterAssetOverridePatches.cs new file mode 100644 index 0000000..ed1bb6c --- /dev/null +++ b/STS2-RitsuLib-main/Scaffolding/Characters/Patches/CharacterAssetOverridePatches.cs @@ -0,0 +1,690 @@ +using Godot; +using MegaCrit.Sts2.Core.Models; +using STS2RitsuLib.Patching.Models; +using STS2RitsuLib.Utils; + +namespace STS2RitsuLib.Scaffolding.Characters.Patches +{ + internal static class CharacterAssetOverridePatchHelper + { + internal static bool TryUseOverride( + CharacterModel instance, + // ReSharper disable once InconsistentNaming + ref string __result, + Func selector, + string memberName, + bool requireExistingResource = true) + { + if (instance is not IModCharacterAssetOverrides overrides) + return true; + + var overrideValue = selector(overrides); + if (string.IsNullOrWhiteSpace(overrideValue)) + return true; + + if (requireExistingResource && !ResourceLoader.Exists(overrideValue)) + { + AssetPathDiagnostics.WarnModCharacterAssetOverrideMissing(instance, memberName, overrideValue); + return true; + } + + __result = overrideValue; + return false; + } + } + + /// + /// Patches so + /// can supply a custom outline texture path. + /// + public class CharacterIconOutlineTexturePathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_icon_outline_texture_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.IconOutlineTexturePath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_IconOutlineTexturePath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// When the instance implements and a valid override path exists, + /// replaces the getter result; otherwise runs the original method. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride( + __instance, + ref __result, + o => o.CustomIconOutlineTexturePath, + nameof(IModCharacterAssetOverrides.CustomIconOutlineTexturePath)); + } + } + + /// + /// Patches for custom mod character scene paths. + /// + public class CharacterVisualsPathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_visuals_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.VisualsPath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_VisualsPath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride( + __instance, + ref __result, + o => o.CustomVisualsPath, + nameof(IModCharacterAssetOverrides.CustomVisualsPath)); + } + } + + /// + /// Patches for mod character UI assets. + /// + public class CharacterEnergyCounterPathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_energy_counter_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.EnergyCounterPath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_EnergyCounterPath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride(__instance, ref __result, + o => o.CustomEnergyCounterPath, + nameof(IModCharacterAssetOverrides.CustomEnergyCounterPath)); + } + } + + /// + /// Patches for merchant-room animations. + /// + public class CharacterMerchantAnimPathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_merchant_anim_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.MerchantAnimPath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_MerchantAnimPath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride(__instance, ref __result, + o => o.CustomMerchantAnimPath, + nameof(IModCharacterAssetOverrides.CustomMerchantAnimPath)); + } + } + + /// + /// Patches for rest-site animations. + /// + public class CharacterRestSiteAnimPathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_rest_site_anim_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.RestSiteAnimPath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_RestSiteAnimPath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride(__instance, ref __result, + o => o.CustomRestSiteAnimPath, + nameof(IModCharacterAssetOverrides.CustomRestSiteAnimPath)); + } + } + + /// + /// Patches for mod character UI icon textures. + /// + public class CharacterIconTexturePathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_icon_texture_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.IconTexturePath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_IconTexturePath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride(__instance, ref __result, + o => o.CustomIconTexturePath, + nameof(IModCharacterAssetOverrides.CustomIconTexturePath)); + } + } + + /// + /// Patches for compact mod character icons. + /// + public class CharacterIconPathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_icon_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.IconPath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_IconPath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride( + __instance, + ref __result, + o => o.CustomIconPath, + nameof(IModCharacterAssetOverrides.CustomIconPath)); + } + } + + /// + /// Patches character-select background path so mods can replace CharacterSelectBg. + /// + public class CharacterSelectBgPathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_select_bg_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.CharacterSelectBg"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_CharacterSelectBg")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride(__instance, ref __result, + o => o.CustomCharacterSelectBgPath, + nameof(IModCharacterAssetOverrides.CustomCharacterSelectBgPath)); + } + } + + /// + /// Patches character-select portrait path so mods can replace CharacterSelectIconPath. + /// + public class CharacterSelectIconPathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_select_icon_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.CharacterSelectIconPath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_CharacterSelectIconPath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride( + __instance, + ref __result, + o => o.CustomCharacterSelectIconPath, + nameof(IModCharacterAssetOverrides.CustomCharacterSelectIconPath)); + } + } + + /// + /// Patches locked character-select portrait path so mods can replace CharacterSelectLockedIconPath. + /// + public class CharacterSelectLockedIconPathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_select_locked_icon_path"; + + /// + public static string Description => + "Allow mod characters to override CharacterModel.CharacterSelectLockedIconPath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_CharacterSelectLockedIconPath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource + /// exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride( + __instance, + ref __result, + o => o.CustomCharacterSelectLockedIconPath, + nameof(IModCharacterAssetOverrides.CustomCharacterSelectLockedIconPath)); + } + } + + /// + /// Patches for custom select-screen transitions. + /// + public class CharacterSelectTransitionPathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_transition_path"; + + /// + public static string Description => + "Allow mod characters to override CharacterModel.CharacterSelectTransitionPath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_CharacterSelectTransitionPath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when valid. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride( + __instance, + ref __result, + o => o.CustomCharacterSelectTransitionPath, + nameof(IModCharacterAssetOverrides.CustomCharacterSelectTransitionPath)); + } + } + + /// + /// Patches for card-trail VFX scenes. + /// + public class CharacterTrailPathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_trail_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.TrailPath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_TrailPath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride( + __instance, + ref __result, + o => o.CustomTrailPath, + nameof(IModCharacterAssetOverrides.CustomTrailPath)); + } + } + + /// + /// Patches ; does not require the FMOD path to exist as a Godot resource. + /// + public class CharacterAttackSfxPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_attack_sfx"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.AttackSfx"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_AttackSfx")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when non-empty. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride( + __instance, + ref __result, + o => o.CustomAttackSfx, + nameof(IModCharacterAssetOverrides.CustomAttackSfx), + false); + } + } + + /// + /// Patches for custom cast audio. + /// + public class CharacterCastSfxPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_cast_sfx"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.CastSfx"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_CastSfx")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when non-empty. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride( + __instance, + ref __result, + o => o.CustomCastSfx, + nameof(IModCharacterAssetOverrides.CustomCastSfx), + false); + } + } + + /// + /// Patches for custom death audio. + /// + public class CharacterDeathSfxPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_death_sfx"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.DeathSfx"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_DeathSfx")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when non-empty. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride( + __instance, + ref __result, + o => o.CustomDeathSfx, + nameof(IModCharacterAssetOverrides.CustomDeathSfx), + false); + } + } + + /// + /// Patches multiplayer arm texture path for the pointing pose. + /// + public class CharacterArmPointingTexturePathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_arm_pointing_texture_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.ArmPointingTexturePath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_ArmPointingTexturePath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride(__instance, ref __result, + o => o.CustomArmPointingTexturePath, + nameof(IModCharacterAssetOverrides.CustomArmPointingTexturePath)); + } + } + + /// + /// Patches multiplayer RPS “rock” arm texture path. + /// + public class CharacterArmRockTexturePathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_arm_rock_texture_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.ArmRockTexturePath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_ArmRockTexturePath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride(__instance, ref __result, + o => o.CustomArmRockTexturePath, + nameof(IModCharacterAssetOverrides.CustomArmRockTexturePath)); + } + } + + /// + /// Patches multiplayer RPS “paper” arm texture path. + /// + public class CharacterArmPaperTexturePathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_arm_paper_texture_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.ArmPaperTexturePath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_ArmPaperTexturePath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride(__instance, ref __result, + o => o.CustomArmPaperTexturePath, + nameof(IModCharacterAssetOverrides.CustomArmPaperTexturePath)); + } + } + + /// + /// Patches multiplayer RPS “scissors” arm texture path. + /// + public class CharacterArmScissorsTexturePathPatch : IPatchMethod + { + /// + public static string PatchId => "character_asset_override_arm_scissors_texture_path"; + + /// + public static string Description => "Allow mod characters to override CharacterModel.ArmScissorsTexturePath"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(CharacterModel), "get_ArmScissorsTexturePath")]; + } + + // ReSharper disable InconsistentNaming + /// + /// Supplies when the resource exists. + /// + public static bool Prefix(CharacterModel __instance, ref string __result) + // ReSharper restore InconsistentNaming + { + return CharacterAssetOverridePatchHelper.TryUseOverride(__instance, ref __result, + o => o.CustomArmScissorsTexturePath, + nameof(IModCharacterAssetOverrides.CustomArmScissorsTexturePath)); + } + } +} diff --git a/STS2-RitsuLib-main/Settings/ModSettings/ModSettingsBaseLibToRitsuGeneratedReflectionMirror.cs b/STS2-RitsuLib-main/Settings/ModSettings/ModSettingsBaseLibToRitsuGeneratedReflectionMirror.cs new file mode 100644 index 0000000..311b762 --- /dev/null +++ b/STS2-RitsuLib-main/Settings/ModSettings/ModSettingsBaseLibToRitsuGeneratedReflectionMirror.cs @@ -0,0 +1,558 @@ +using System.Collections; +using System.Reflection; +using Godot; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using STS2RitsuLib.Compat; + +namespace STS2RitsuLib.Settings +{ + /// + /// Mirrors converter-generated BaseLib compatibility configs into RitsuLib's settings UI. + /// + public static class ModSettingsBaseLibToRitsuGeneratedReflectionMirror + { + private const string RegistryTypeName = "BaseLibToRitsu.Generated.ModConfigRegistry"; + private const string ModConfigTypeName = "BaseLibToRitsu.Generated.ModConfig"; + private const string SectionAttrName = "BaseLibToRitsu.Generated.ConfigSectionAttribute"; + private const string HideUiAttrName = "BaseLibToRitsu.Generated.ConfigHideInUI"; + private const string ButtonAttrName = "BaseLibToRitsu.Generated.ConfigButtonAttribute"; + private const string ColorPickerAttrName = "BaseLibToRitsu.Generated.ConfigColorPickerAttribute"; + private const string HoverTipAttrName = "BaseLibToRitsu.Generated.ConfigHoverTipAttribute"; + private const string HoverTipsByDefaultAttrName = + "BaseLibToRitsu.Generated.ConfigHoverTipsByDefaultAttribute"; + private const string LegacyHoverTipsByDefaultAttrName = + "BaseLibToRitsu.Generated.HoverTipsByDefaultAttribute"; + + private static readonly Lock Gate = new(); + + /// + /// Registers mirrored settings pages for converter-generated BaseLib compatibility configs discovered in + /// loaded mod assemblies. + /// + /// Stable page id under each mod. + /// Sidebar ordering for mirrored pages. + /// Optional page title. + public static int TryRegisterMirroredPages( + string pageId = "baselib", + int sortOrder = 10_000, + ModSettingsText? pageTitle = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pageId); + + lock (Gate) + { + pageTitle ??= ModSettingsText.I18N(ModSettingsLocalization.Instance, "baselib.mirroredPage.title", + "Mod config"); + var pageDescription = ModSettingsText.I18N(ModSettingsLocalization.Instance, + "baselib.mirroredPage.description", + "This page mirrors BaseLib-generated mod configuration entries."); + + var added = 0; + foreach (var ctx in EnumerateContexts()) + added += RegisterFromContext(ctx, pageId, sortOrder, pageTitle, pageDescription); + return added; + } + } + + private static int RegisterFromContext( + MirrorContext ctx, + string pageId, + int sortOrder, + ModSettingsText pageTitle, + ModSettingsText pageDescription) + { + var modIdProp = ctx.ModConfigType.GetProperty("ModId", BindingFlags.Instance | BindingFlags.Public); + var propsField = ctx.ModConfigType.GetField("_configProperties", BindingFlags.Instance | BindingFlags.NonPublic); + var changed = ctx.ModConfigType.GetMethod("Changed", BindingFlags.Instance | BindingFlags.Public); + var save = ctx.ModConfigType.GetMethod("Save", BindingFlags.Instance | BindingFlags.Public); + var restore = ctx.ModConfigType.GetMethod("RestoreDefaultsNoConfirm", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (modIdProp == null || propsField == null || changed == null || save == null || restore == null) + return 0; + + var added = 0; + foreach (var config in EnumerateConfigs(ctx)) + { + var modId = modIdProp.GetValue(config) as string; + if (string.IsNullOrWhiteSpace(modId) || ModSettingsRegistry.TryGetPage(modId, pageId, out _)) + continue; + + var configType = config.GetType(); + if (!ModSettingsMirrorInteropPolicy.ShouldMirror(ModSettingsMirrorSource.BaseLib, modId, configType)) + continue; + + var host = new Host(config, changed, save, restore); + var propNames = ReadPropertyNames(propsField, config); + if (!TryBuildPage(modId, pageId, sortOrder, pageTitle, pageDescription, host, propNames, ctx, configType)) + continue; + + added++; + RitsuLibFramework.Logger.Info( + $"[ModSettingsBaseLibToRitsuGeneratedReflectionMirror] Registered '{modId}::{pageId}' from '{ctx.Assembly.GetName().Name}'."); + } + + return added; + } + + private static bool TryBuildPage( + string modId, + string pageId, + int sortOrder, + ModSettingsText pageTitle, + ModSettingsText pageDescription, + Host host, + IReadOnlySet propNames, + MirrorContext ctx, + Type configType) + { + var members = configType.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance) + .Where(m => IsVisibleMember(m, propNames, ctx.HideUiAttrType, ctx.ButtonAttrType)) + .OrderBy(GetSourceOrder) + .ToList(); + if (members.Count == 0) + return false; + + var sections = BuildSections(members, ctx.SectionAttrType); + if (sections.Count == 0) + return false; + + try + { + ModSettingsRegistry.Register(modId, builder => + { + builder.WithTitle(pageTitle) + .WithDescription(pageDescription) + .WithSortOrder(sortOrder) + .WithModDisplayName(ModSettingsText.Dynamic(() => host.ResolveModDisplayName(modId))); + + for (var i = 0; i < sections.Count; i++) + { + var sec = sections[i]; + var isLast = i == sections.Count - 1; + builder.AddSection(sec.Id, section => + { + if (!string.IsNullOrWhiteSpace(sec.Title)) + section.WithTitle(ModSettingsText.Dynamic(() => host.ResolveLabel(sec.Title!))); + + foreach (var member in sec.Entries) + { + if (member is PropertyInfo prop) + AddProperty(section, modId, prop, host, ctx, configType); + else if (member is MethodInfo method) + AddButton(section, method, host, ctx, configType); + } + + if (!isLast) + return; + + var label = host.ResolveBaseLibLabel("RestoreDefaultsButton"); + section.AddButton("baselib_restore_defaults", ModSettingsText.Literal(label), + ModSettingsText.Literal(label), () => ConfirmAndRestoreDefaults(host), + ModSettingsButtonTone.Danger); + }); + } + }, pageId); + return true; + } + catch (Exception ex) + { + RitsuLibFramework.Logger.Warn( + $"[ModSettingsBaseLibToRitsuGeneratedReflectionMirror] Failed to register '{modId}::{pageId}': {ex.Message}"); + return false; + } + } + + private static List BuildSections(List members, Type? sectionAttrType) + { + var result = new List(); + PendingSection current = new("main", null, []); + string? currentTitle = null; + + foreach (var member in members) + { + if (sectionAttrType != null && member.GetCustomAttribute(sectionAttrType, false) is { } attr) + { + var title = sectionAttrType.GetProperty("Name")?.GetValue(attr) as string; + if (!string.IsNullOrWhiteSpace(title) && title != currentTitle) + { + if (current.Entries.Count > 0) + result.Add(current); + currentTitle = title; + current = new($"sec_{StringHelper.Slugify(title)}_{result.Count}", title, []); + } + } + current.Entries.Add(member); + } + + if (current.Entries.Count > 0) + result.Add(current); + return result; + } + + private static void AddProperty( + ModSettingsSectionBuilder section, + string modId, + PropertyInfo prop, + Host host, + MirrorContext ctx, + Type configType) + { + var id = $"bl_{StringHelper.Slugify(prop.Name)}"; + var label = ModSettingsText.Dynamic(() => host.ResolveLabel(prop.Name)); + var desc = TryHoverTip(prop, configType, host, ctx.HoverTipAttrType, ctx.HoverTipsByDefaultAttrType, + ctx.LegacyHoverTipsByDefaultAttrType); + var dataKey = $"baselib::{prop.Name}"; + var type = prop.PropertyType; + + if (type == typeof(bool)) + { + section.AddToggle(id, label, CallbackForStaticProperty(modId, dataKey, prop, host), desc); + return; + } + + if (type == typeof(Color)) + { + var colorBinding = ModSettingsBindings.Callback(modId, dataKey, + () => ModSettingsColorControl.FormatStoredColorString((Color)prop.GetValue(null)!), + value => + { + if (string.IsNullOrWhiteSpace(value) || + !ModSettingsColorControl.TryDeserializeColorForSettings(value, out var color)) + return; + prop.SetValue(null, color); + host.NotifyChanged(); + }, + host.Save); + section.AddColor(id, label, colorBinding, desc, true, false); + return; + } + + var asColor = ctx.ColorPickerAttrType != null && prop.GetCustomAttribute(ctx.ColorPickerAttrType, false) != null; + if (type == typeof(string) && asColor) + { + section.AddColor(id, label, CallbackForStaticProperty(modId, dataKey, prop, host), desc, true, false); + return; + } + + if (type == typeof(string)) + { + section.AddString(id, label, CallbackForStaticProperty(modId, dataKey, prop, host), description: desc); + return; + } + + if (type == typeof(int)) + { + var intBinding = ModSettingsBindings.Callback(modId, dataKey, + () => Convert.ToDouble((int)prop.GetValue(null)!), + value => { prop.SetValue(null, (int)Math.Round(value)); host.NotifyChanged(); }, + host.Save); + section.AddSlider(id, label, intBinding, 0d, 100d, 1d, value => ((int)Math.Round(value)).ToString(), desc); + return; + } + + if (type == typeof(float)) + { +#pragma warning disable CS0618 + section.AddSlider(id, label, CallbackForStaticProperty(modId, dataKey, prop, host), 0f, 100f, 1f, + value => value.ToString("0.##"), desc); +#pragma warning restore CS0618 + return; + } + + if (type == typeof(double)) + { + section.AddSlider(id, label, CallbackForStaticProperty(modId, dataKey, prop, host), 0d, 100d, 1d, + value => value.ToString("0.##"), desc); + return; + } + + if (!type.IsEnum) + return; + + var enumBinding = typeof(ModSettingsBaseLibToRitsuGeneratedReflectionMirror) + .GetMethod(nameof(CallbackForStaticProperty), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(type) + .Invoke(null, [modId, dataKey, prop, host]); + typeof(ModSettingsSectionBuilder).GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Single(m => m is { Name: nameof(ModSettingsSectionBuilder.AddEnumChoice), IsGenericMethodDefinition: true }) + .MakeGenericMethod(type) + .Invoke(section, [id, label, enumBinding, null, desc, ModSettingsChoicePresentation.Stepper]); + } + + private static void AddButton( + ModSettingsSectionBuilder section, + MethodInfo method, + Host host, + MirrorContext ctx, + Type configType) + { + if (ctx.ButtonAttrType == null || method.GetCustomAttribute(ctx.ButtonAttrType, false) is not { } attr) + return; + + var key = ctx.ButtonAttrType.GetProperty("ButtonLabelKey")?.GetValue(attr) as string ?? method.Name; + var id = $"bl_btn_{StringHelper.Slugify(method.Name)}"; + var label = ModSettingsText.Dynamic(() => host.ResolveLabel(method.Name)); + var button = ModSettingsText.Dynamic(() => host.ResolveLabel(key)); + var desc = TryHoverTip(method, configType, host, ctx.HoverTipAttrType, ctx.HoverTipsByDefaultAttrType, + ctx.LegacyHoverTipsByDefaultAttrType); + section.AddButton(id, label, button, () => InvokeConfigButton(method, host), ModSettingsButtonTone.Normal, desc); + } + + private static void InvokeConfigButton(MethodInfo method, Host host) + { + try + { + var args = method.GetParameters(); + var values = new object?[args.Length]; + for (var i = 0; i < args.Length; i++) + values[i] = args[i].ParameterType.IsInstanceOfType(host.Instance) + ? host.Instance + : (args[i].ParameterType.IsValueType ? Activator.CreateInstance(args[i].ParameterType) : null); + method.Invoke(method.IsStatic ? null : host.Instance, values); + host.NotifyChanged(); + } + catch (Exception ex) + { + RitsuLibFramework.Logger.Warn( + $"[ModSettingsBaseLibToRitsuGeneratedReflectionMirror] ConfigButton '{method.Name}' failed: {ex.Message}"); + } + } + + private static ModSettingsText? TryHoverTip( + MemberInfo member, + Type configType, + Host host, + Type? hoverTipAttrType, + Type? hoverTipsByDefaultAttrType, + Type? legacyHoverTipsByDefaultAttrType) + { + if (!ShouldShowHoverTip(member, configType, hoverTipAttrType, hoverTipsByDefaultAttrType, + legacyHoverTipsByDefaultAttrType)) + return null; + + var prefix = host.ModPrefix; + if (string.IsNullOrWhiteSpace(prefix)) + return null; + + var key = prefix + StringHelper.Slugify(member.Name) + ".hover.desc"; + if (!LocString.Exists("settings_ui", key)) + return null; + return ModSettingsText.Dynamic(() => LocString.GetIfExists("settings_ui", key)?.GetFormattedText() ?? ""); + } + + private static bool ShouldShowHoverTip( + MemberInfo member, + Type configType, + Type? hoverTipAttrType, + Type? hoverTipsByDefaultAttrType, + Type? legacyHoverTipsByDefaultAttrType) + { + bool? explicitFlag = null; + if (hoverTipAttrType != null && member.GetCustomAttribute(hoverTipAttrType, false) is { } attr && + hoverTipAttrType.GetProperty("Enabled")?.GetValue(attr) is bool enabled) + { + explicitFlag = enabled; + } + + var byDefault = + (hoverTipsByDefaultAttrType != null && configType.GetCustomAttribute(hoverTipsByDefaultAttrType, false) != null) || + (legacyHoverTipsByDefaultAttrType != null && + configType.GetCustomAttribute(legacyHoverTipsByDefaultAttrType, false) != null); + return explicitFlag ?? byDefault; + } + + private static ModSettingsCallbackValueBinding CallbackForStaticProperty( + string modId, + string dataKey, + PropertyInfo prop, + Host host) + { + return ModSettingsBindings.Callback(modId, dataKey, () => (T)prop.GetValue(null)!, + value => { prop.SetValue(null, value); host.NotifyChanged(); }, host.Save); + } + + private static bool IsVisibleMember(MemberInfo member, IReadOnlySet propNames, Type? hideUiAttrType, Type? buttonAttrType) + { + return member switch + { + PropertyInfo p => propNames.Contains(p.Name) && (hideUiAttrType == null || p.GetCustomAttribute(hideUiAttrType) == null), + MethodInfo m => buttonAttrType != null && m.GetCustomAttribute(buttonAttrType) != null, + _ => false, + }; + } + + private static int GetSourceOrder(MemberInfo member) + { + return member switch + { + MethodInfo m => m.MetadataToken, + PropertyInfo p => p.GetMethod?.MetadataToken ?? p.SetMethod?.MetadataToken ?? 0, + _ => 0, + }; + } + + private static IReadOnlySet ReadPropertyNames(FieldInfo propsField, object config) + { + var result = new HashSet(StringComparer.Ordinal); + if (propsField.GetValue(config) is not IEnumerable enumerable) + return result; + foreach (var item in enumerable) + if (item is PropertyInfo prop) + result.Add(prop.Name); + return result; + } + + private static IEnumerable EnumerateConfigs(MirrorContext ctx) + { + var getAll = ctx.RegistryType.GetMethod("GetAll", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, + null, Type.EmptyTypes, null); + if (getAll?.Invoke(null, null) is IEnumerable all) + { + foreach (var item in all) + if (item != null) + yield return item; + yield break; + } + + if (ctx.RegistryType.GetField("Configs", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public) + ?.GetValue(null) is not IDictionary map) + yield break; + foreach (DictionaryEntry entry in map) + if (entry.Value != null) + yield return entry.Value; + } + + private static void ConfirmAndRestoreDefaults(Host host) + { + if (Engine.GetMainLoop() is not SceneTree { Root: { } root }) + { + host.RestoreDefaultsNoConfirm(); + return; + } + + var body = LocString.GetIfExists("settings_ui", "BASELIB-RESTORE_MODCONFIG_CONFIRMATION.body")?.GetFormattedText() + ?? "Reset all options for this mod to their default values?"; + var header = LocString.GetIfExists("settings_ui", "BASELIB-RESTORE_MODCONFIG_CONFIRMATION.header")?.GetFormattedText() + ?? "Restore defaults"; + var submenu = FindSubmenu(root); + var attachParent = (Node?)submenu ?? root; + ModSettingsUiFactory.ShowStyledConfirm(attachParent, header, body, + ModSettingsLocalization.Get("baselib.restoreDefaults.cancel", "Cancel"), + ModSettingsLocalization.Get("baselib.restoreDefaults.confirm", "Restore defaults"), true, () => + { + host.RestoreDefaultsNoConfirm(); + host.NotifyChanged(); + host.Save(); + submenu?.RequestRefresh(); + }); + } + + private static RitsuModSettingsSubmenu? FindSubmenu(Node root) + { + var queue = new Queue(); + queue.Enqueue(root); + while (queue.Count > 0) + { + var node = queue.Dequeue(); + if (node is RitsuModSettingsSubmenu submenu) + return submenu; + foreach (var child in node.GetChildren()) + queue.Enqueue(child); + } + return null; + } + + private static IEnumerable EnumerateContexts() + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + Type? registryType; + Type? modConfigType; + try + { + registryType = asm.GetType(RegistryTypeName, false); + modConfigType = asm.GetType(ModConfigTypeName, false); + } + catch + { + continue; + } + + if (registryType == null || modConfigType == null) + continue; + + yield return new(asm, registryType, modConfigType, asm.GetType(SectionAttrName, false), + asm.GetType(HideUiAttrName, false), asm.GetType(ButtonAttrName, false), + asm.GetType(ColorPickerAttrName, false), asm.GetType(HoverTipAttrName, false), + asm.GetType(HoverTipsByDefaultAttrName, false), asm.GetType(LegacyHoverTipsByDefaultAttrName, false)); + } + } + + private sealed record MirrorContext( + Assembly Assembly, + Type RegistryType, + Type ModConfigType, + Type? SectionAttrType, + Type? HideUiAttrType, + Type? ButtonAttrType, + Type? ColorPickerAttrType, + Type? HoverTipAttrType, + Type? HoverTipsByDefaultAttrType, + Type? LegacyHoverTipsByDefaultAttrType); + + private sealed record PendingSection(string Id, string? Title, List Entries); + + private sealed class Host(object instance, MethodInfo changed, MethodInfo save, MethodInfo restore) + { + public object Instance { get; } = instance; + public string ModPrefix => ResolveRootNamespace() is { Length: > 0 } root ? root.ToUpperInvariant() + "-" : ""; + + public void NotifyChanged() => changed.Invoke(Instance, []); + public void Save() => save.Invoke(Instance, []); + public void RestoreDefaultsNoConfirm() => restore.Invoke(Instance, []); + + public string ResolveLabel(string name) + { + var key = ModPrefix + StringHelper.Slugify(name) + ".title"; + return LocString.GetIfExists("settings_ui", key)?.GetFormattedText() ?? name; + } + + public string ResolveBaseLibLabel(string name) + { + var key = "BASELIB-" + StringHelper.Slugify(name) + ".title"; + return LocString.GetIfExists("settings_ui", key)?.GetFormattedText() ?? name; + } + + public string ResolveModDisplayName(string fallback) + { + var root = ResolveRootNamespace(); + if (!string.IsNullOrWhiteSpace(root)) + { + var key = root.ToUpperInvariant() + ".mod_title"; + var localized = LocString.GetIfExists("settings_ui", key)?.GetFormattedText(); + if (!string.IsNullOrWhiteSpace(localized)) + return localized; + } + + return Sts2ModManagerCompat.EnumerateModsForManifestLookup().FirstOrDefault(mod => + string.Equals(mod.manifest?.id, fallback, StringComparison.OrdinalIgnoreCase))?.manifest?.name + ?? fallback; + } + + private string ResolveRootNamespace() + { + var type = Instance.GetType(); + if (!string.IsNullOrWhiteSpace(type.Namespace)) + { + var dot = type.Namespace.IndexOf('.'); + return dot < 0 ? type.Namespace : type.Namespace[..dot]; + } + + var asm = type.Assembly.GetName().Name ?? ""; + var asmDot = asm.IndexOf('.'); + return asmDot < 0 ? asm : asm[..asmDot]; + } + } + } +} diff --git a/STS2-RitsuLib-main/Settings/ModSettingsUi/RitsuModSettingsSubmenu.cs b/STS2-RitsuLib-main/Settings/ModSettingsUi/RitsuModSettingsSubmenu.cs new file mode 100644 index 0000000..0e5d533 --- /dev/null +++ b/STS2-RitsuLib-main/Settings/ModSettingsUi/RitsuModSettingsSubmenu.cs @@ -0,0 +1,1506 @@ +using Godot; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.Assets; +using MegaCrit.Sts2.Core.ControllerInput; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes.GodotExtensions; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using MegaCrit.Sts2.Core.Nodes.Screens.ScreenContext; +using Timer = Godot.Timer; + +namespace STS2RitsuLib.Settings +{ + /// + /// Full-screen mod settings browser: sidebar (mods, pages, sections) and content pane. + /// + public partial class RitsuModSettingsSubmenu : NSubmenu + { + private const float SidebarWidth = 324f; + private const double AutosaveDelaySeconds = 0.35; + private const int ScrollContentRightGutter = 12; + + private static readonly StringName PaneSidebarHotkey = MegaInput.viewDeckAndTabLeft; + private static readonly StringName PaneContentHotkey = MegaInput.viewExhaustPileAndTabRight; + + private readonly List _contentFocusChain = []; + + private readonly HashSet _dirtyBindings = []; + + private readonly List<(Control Control, Func Predicate)> _dynamicVisibilityTargets = []; + private readonly HashSet _expandedModIds = new(StringComparer.OrdinalIgnoreCase); + + private readonly Dictionary _modButtons = + new(StringComparer.OrdinalIgnoreCase); + + private readonly Dictionary _pageButtons = + new(StringComparer.OrdinalIgnoreCase); + + private readonly List _refreshActions = []; + + private readonly Dictionary _sectionButtons = + new(StringComparer.OrdinalIgnoreCase); + + private readonly List _sidebarFocusChain = []; + + private VBoxContainer _contentList = null!; + + private bool _contentOnlyRebuildNeedsContentFocus; + private Control _contentPanelRoot = null!; + private bool _focusNavigationRefreshScheduled; + private bool _focusSelectedPageButtonOnNextRefresh; + private bool _guiFocusSignalConnected; + private Action? _hotkeyPaneContent; + private Action? _hotkeyPaneSidebar; + private Control? _initialFocusedControl; + private TextureRect? _leftPaneHotkeyIcon; + private bool _localeSubscribed; + private VBoxContainer _modButtonList = null!; + private Callable _modSettingsGuiFocusCallable; + private HBoxContainer _pageTabRow = null!; + private HBoxContainer? _paneHotkeyHintRow; + private bool _paneHotkeySignalsConnected; + private bool _paneHotkeysPushed; + private AcceptDialog? _pasteErrorDialog; + private bool _pendingRefreshFlush; + private Timer? _refreshDebounceTimer; + private TextureRect? _rightPaneHotkeyIcon; + private double _saveTimer = -1; + private ScrollContainer _scrollContainer = null!; + private string? _selectedModId; + private string? _selectedPageId; + private string? _selectedSectionId; + private Control _sidebarPanelRoot = null!; + private ScrollContainer _sidebarScrollContainer = null!; + private MegaRichTextLabel _subtitleLabel; + private bool _suppressScrollSync; + private MegaRichTextLabel _titleLabel; + private Callable _updatePaneHotkeyIconsCallable; + + /// + /// Builds layout (header, sidebar, scrollable content) and wires initial structure. + /// + public RitsuModSettingsSubmenu() + { + AnchorRight = 1f; + AnchorBottom = 1f; + GrowHorizontal = GrowDirection.Both; + GrowVertical = GrowDirection.Both; + FocusMode = FocusModeEnum.None; + + var frame = new MarginContainer + { + Name = "Frame", + AnchorRight = 1f, + AnchorBottom = 1f, + GrowHorizontal = GrowDirection.Both, + GrowVertical = GrowDirection.Both, + MouseFilter = MouseFilterEnum.Ignore, + }; + frame.AddThemeConstantOverride("margin_left", 160); + frame.AddThemeConstantOverride("margin_top", 72); + frame.AddThemeConstantOverride("margin_right", 160); + frame.AddThemeConstantOverride("margin_bottom", 72); + AddChild(frame); + + var root = new VBoxContainer + { + Name = "Root", + AnchorRight = 1f, + AnchorBottom = 1f, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + root.AddThemeConstantOverride("separation", 18); + frame.AddChild(root); + + var header = new VBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + header.AddThemeConstantOverride("separation", 6); + root.AddChild(header); + + _titleLabel = CreateTitleLabel(32, HorizontalAlignment.Left); + _titleLabel.CustomMinimumSize = new(0f, 42f); + _titleLabel.SizeFlagsHorizontal = SizeFlags.ExpandFill; + header.AddChild(_titleLabel); + + _subtitleLabel = CreateTitleLabel(16, HorizontalAlignment.Left); + _subtitleLabel.CustomMinimumSize = new(0f, 24f); + _subtitleLabel.Modulate = new(0.82f, 0.79f, 0.72f, 0.92f); + _subtitleLabel.SizeFlagsHorizontal = SizeFlags.ExpandFill; + header.AddChild(_subtitleLabel); + + root.AddChild(CreatePaneHotkeyHintRow()); + + var body = new HBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + body.AddThemeConstantOverride("separation", 20); + root.AddChild(body); + + body.AddChild(CreateSidebarPanel()); + body.AddChild(CreateContentPanel()); + } + + /// + protected override Control? InitialFocusedControl => _initialFocusedControl; + + /// + public override void _Ready() + { + var backButton = PreloadManager.Cache.GetScene(SceneHelper.GetScenePath("ui/back_button")) + .Instantiate(); + backButton.Name = "BackButton"; + AddChild(backButton); + + ConnectSignals(); + _updatePaneHotkeyIconsCallable = Callable.From(UpdatePaneHotkeyHintIcons); + TryConnectPaneHotkeyStyleSignals(); + _scrollContainer.GetVScrollBar().ValueChanged += OnContentScrollChanged; + SubscribeLocaleChanges(); + Rebuild(); + ProcessMode = ProcessModeEnum.Disabled; + FocusMode = FocusModeEnum.None; + } + + /// + protected override void ConnectSignals() + { + base.ConnectSignals(); + var vp = GetViewport(); + if (vp == null) + return; + + _modSettingsGuiFocusCallable = Callable.From(OnModSettingsGuiFocusChanged); + vp.Connect(Viewport.SignalName.GuiFocusChanged, _modSettingsGuiFocusCallable); + _guiFocusSignalConnected = true; + } + + /// + public override void _ExitTree() + { + var vp = GetViewport(); + if (vp != null && _guiFocusSignalConnected && + vp.IsConnected(Viewport.SignalName.GuiFocusChanged, _modSettingsGuiFocusCallable)) + { + vp.Disconnect(Viewport.SignalName.GuiFocusChanged, _modSettingsGuiFocusCallable); + _guiFocusSignalConnected = false; + } + + TryDisconnectPaneHotkeyStyleSignals(); + PopPaneHotkeys(); + base._ExitTree(); + FlushDirtyBindings(); + UnsubscribeLocaleChanges(); + } + + /// + public override void OnSubmenuOpened() + { + base.OnSubmenuOpened(); + FocusMode = FocusModeEnum.None; + FocusBehaviorRecursive = FocusBehaviorRecursiveEnum.Enabled; + ProcessMode = ProcessModeEnum.Inherit; + Rebuild(); + } + + /// + public override void OnSubmenuClosed() + { + PopPaneHotkeys(); + FlushDirtyBindings(); + ProcessMode = ProcessModeEnum.Disabled; + Callable.From(this.UpdateControllerNavEnabled).CallDeferred(); + base.OnSubmenuClosed(); + } + + /// + protected override void OnSubmenuShown() + { + base.OnSubmenuShown(); + SetProcessInput(true); + PushPaneHotkeys(); + UpdatePaneHotkeyHintIcons(); + } + + /// + protected override void OnSubmenuHidden() + { + PopPaneHotkeys(); + FlushPendingRefreshActionsImmediate(); + FlushDirtyBindings(); + ProcessMode = ProcessModeEnum.Disabled; + Callable.From(this.UpdateControllerNavEnabled).CallDeferred(); + base.OnSubmenuHidden(); + } + + /// + public override void _Process(double delta) + { + base._Process(delta); + if (_saveTimer < 0) + return; + + _saveTimer -= delta; + if (_saveTimer <= 0) + FlushDirtyBindings(); + } + + internal void MarkDirty(IModSettingsBinding binding) + { + _dirtyBindings.Add(binding); + _saveTimer = AutosaveDelaySeconds; + } + + internal void RequestRefresh() + { + _pendingRefreshFlush = true; + EnsureRefreshDebounceTimer(); + _refreshDebounceTimer!.Stop(); + _refreshDebounceTimer.Start(); + } + + internal void RegisterRefreshAction(Action action) + { + _refreshActions.Add(action); + } + + internal void RegisterDynamicVisibility(Control control, Func predicate) + { + ArgumentNullException.ThrowIfNull(control); + ArgumentNullException.ThrowIfNull(predicate); + _dynamicVisibilityTargets.Add((control, predicate)); + } + + private void ApplyDynamicVisibilityTargets() + { + foreach (var (control, predicate) in _dynamicVisibilityTargets) + { + if (!IsInstanceValid(control)) + continue; + try + { + control.Visible = predicate(); + } + catch + { + control.Visible = true; + } + } + } + + internal void ShowPasteFailure(ModSettingsPasteFailureReason reason) + { + if (reason == ModSettingsPasteFailureReason.None) + return; + + var key = reason switch + { + ModSettingsPasteFailureReason.ClipboardEmpty => "clipboard.pasteFailedEmpty", + ModSettingsPasteFailureReason.PasteRuleDenied => "clipboard.pasteFailedBlocked", + _ => "clipboard.pasteFailedIncompatible", + }; + + var fallback = reason switch + { + ModSettingsPasteFailureReason.ClipboardEmpty => "Clipboard is empty or unavailable.", + ModSettingsPasteFailureReason.PasteRuleDenied => "Paste was blocked by a custom rule.", + _ => "Clipboard contents are not compatible with this setting.", + }; + + EnsurePasteErrorDialog(); + _pasteErrorDialog!.Title = + ModSettingsLocalization.Get("clipboard.pasteFailedTitle", "Paste failed"); + _pasteErrorDialog.OkButtonText = ModSettingsLocalization.Get("clipboard.pasteErrorOk", "OK"); + _pasteErrorDialog.DialogText = ModSettingsLocalization.Get(key, fallback); + _pasteErrorDialog.PopupCentered(); + } + + private void EnsurePasteErrorDialog() + { + if (_pasteErrorDialog != null) + return; + + _pasteErrorDialog = new() { Name = "PasteErrorDialog" }; + AddChild(_pasteErrorDialog); + } + + private void EnsureRefreshDebounceTimer() + { + if (_refreshDebounceTimer != null) + return; + + _refreshDebounceTimer = new() + { + Name = "ModSettingsRefreshDebounce", + OneShot = true, + WaitTime = 0.07, + ProcessCallback = Timer.TimerProcessCallback.Idle, + }; + AddChild(_refreshDebounceTimer); + _refreshDebounceTimer.Timeout += OnRefreshDebounceTimeout; + } + + private void OnRefreshDebounceTimeout() + { + if (!_pendingRefreshFlush) + return; + + _pendingRefreshFlush = false; + foreach (var action in _refreshActions.ToArray()) + action(); + ApplyDynamicVisibilityTargets(); + } + + private void CancelDeferredRefreshFlush() + { + _pendingRefreshFlush = false; + _refreshDebounceTimer?.Stop(); + } + + private void FlushPendingRefreshActionsImmediate() + { + _refreshDebounceTimer?.Stop(); + if (!_pendingRefreshFlush) + return; + + _pendingRefreshFlush = false; + foreach (var action in _refreshActions.ToArray()) + action(); + } + + private void OnModSettingsGuiFocusChanged(Control node) + { + if (!Visible || !IsInstanceValid(this) || !IsInstanceValid(node)) + return; + + if (!ActiveScreenContext.Instance.IsCurrent(this)) + return; + + if (NControllerManager.Instance?.IsUsingController != true) + return; + + if (_suppressScrollSync) + return; + + if (_sidebarScrollContainer.IsAncestorOf(node)) + _sidebarScrollContainer.EnsureControlVisible(node); + else if (_scrollContainer.IsAncestorOf(node)) + _scrollContainer.EnsureControlVisible(node); + } + + /// + /// Selects a mod in the sidebar, optionally opening , and rebuilds the UI. + /// + public void SelectMod(string modId, string? pageId = null) + { + _selectedModId = modId; + _selectedPageId = pageId; + _selectedSectionId = null; + ExpandOnlyMod(modId); + _focusSelectedPageButtonOnNextRefresh = true; + Rebuild(); + } + + /// + /// Switches to within the currently selected mod. + /// + public void NavigateToPage(string pageId) + { + if (string.IsNullOrWhiteSpace(_selectedModId)) + return; + + _selectedPageId = pageId; + _selectedSectionId = null; + ModSettingsBaseLibReflectionMirror.TryRegisterMirroredPages(); + ModSettingsBaseLibToRitsuGeneratedReflectionMirror.TryRegisterMirroredPages(); + ModSettingsModConfigReflectionMirror.TryRegisterMirroredPages(); + ModSettingsRuntimeReflectionInteropMirror.TryRegisterMirroredPages(); + Rebuild(); + } + + /// + /// Opens and scrolls/focuses . + /// + public void NavigateToSection(string pageId, string sectionId) + { + if (string.IsNullOrWhiteSpace(_selectedModId)) + return; + + if (string.Equals(_selectedPageId, pageId, StringComparison.OrdinalIgnoreCase) && + string.Equals(_selectedSectionId, sectionId, StringComparison.OrdinalIgnoreCase)) + { + Callable.From(ScrollToSelectedAnchor).CallDeferred(); + RefreshFocusNavigation(); + Callable.From(() => + { + if (_sectionButtons.TryGetValue(sectionId, out var btn) && btn.IsVisibleInTree()) + btn.GrabFocus(); + }).CallDeferred(); + return; + } + + var pageChanged = !string.Equals(_selectedPageId, pageId, StringComparison.OrdinalIgnoreCase); + _selectedPageId = pageId; + _selectedSectionId = sectionId; + ModSettingsBaseLibReflectionMirror.TryRegisterMirroredPages(); + ModSettingsBaseLibToRitsuGeneratedReflectionMirror.TryRegisterMirroredPages(); + ModSettingsModConfigReflectionMirror.TryRegisterMirroredPages(); + ModSettingsRuntimeReflectionInteropMirror.TryRegisterMirroredPages(); + if (pageChanged) + Rebuild(); + else + RebuildContent(); + } + + private Control CreatePaneHotkeyHintRow() + { + var row = new HBoxContainer + { + Name = "PaneHotkeyHints", + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + Visible = false, + }; + _paneHotkeyHintRow = row; + + _leftPaneHotkeyIcon = new() + { + CustomMinimumSize = new(44f, 32f), + MouseFilter = MouseFilterEnum.Ignore, + ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize, + StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered, + }; + row.AddChild(_leftPaneHotkeyIcon); + + row.AddChild(new Control + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }); + + _rightPaneHotkeyIcon = new() + { + CustomMinimumSize = new(44f, 32f), + MouseFilter = MouseFilterEnum.Ignore, + ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize, + StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered, + }; + row.AddChild(_rightPaneHotkeyIcon); + + return row; + } + + private void TryConnectPaneHotkeyStyleSignals() + { + if (_paneHotkeySignalsConnected) + return; + + if (NControllerManager.Instance != null) + { + NControllerManager.Instance.Connect(NControllerManager.SignalName.MouseDetected, + _updatePaneHotkeyIconsCallable); + NControllerManager.Instance.Connect(NControllerManager.SignalName.ControllerDetected, + _updatePaneHotkeyIconsCallable); + } + + if (NInputManager.Instance != null) + NInputManager.Instance.Connect(NInputManager.SignalName.InputRebound, _updatePaneHotkeyIconsCallable); + + _paneHotkeySignalsConnected = true; + } + + private void TryDisconnectPaneHotkeyStyleSignals() + { + if (!_paneHotkeySignalsConnected) + return; + + if (NControllerManager.Instance != null) + { + NControllerManager.Instance.Disconnect(NControllerManager.SignalName.MouseDetected, + _updatePaneHotkeyIconsCallable); + NControllerManager.Instance.Disconnect(NControllerManager.SignalName.ControllerDetected, + _updatePaneHotkeyIconsCallable); + } + + if (NInputManager.Instance != null) + NInputManager.Instance.Disconnect(NInputManager.SignalName.InputRebound, + _updatePaneHotkeyIconsCallable); + + _paneHotkeySignalsConnected = false; + } + + private void UpdatePaneHotkeyHintIcons() + { + if (_paneHotkeyHintRow == null) + return; + + var usingController = NControllerManager.Instance?.IsUsingController ?? false; + _paneHotkeyHintRow.Visible = usingController && Visible; + if (!usingController) + return; + + if (NInputManager.Instance == null) + return; + + _leftPaneHotkeyIcon?.Texture = NInputManager.Instance.GetHotkeyIcon(PaneSidebarHotkey); + _rightPaneHotkeyIcon?.Texture = NInputManager.Instance.GetHotkeyIcon(PaneContentHotkey); + } + + private void PushPaneHotkeys() + { + if (_paneHotkeysPushed || NHotkeyManager.Instance == null) + return; + + _hotkeyPaneSidebar = OnHotkeyPressedFocusSidebar; + _hotkeyPaneContent = OnHotkeyPressedFocusContent; + NHotkeyManager.Instance.PushHotkeyPressedBinding(PaneSidebarHotkey, _hotkeyPaneSidebar); + NHotkeyManager.Instance.PushHotkeyPressedBinding(PaneContentHotkey, _hotkeyPaneContent); + _paneHotkeysPushed = true; + } + + private void PopPaneHotkeys() + { + if (!_paneHotkeysPushed || NHotkeyManager.Instance == null) + return; + + if (_hotkeyPaneSidebar != null) + NHotkeyManager.Instance.RemoveHotkeyPressedBinding(PaneSidebarHotkey, _hotkeyPaneSidebar); + if (_hotkeyPaneContent != null) + NHotkeyManager.Instance.RemoveHotkeyPressedBinding(PaneContentHotkey, _hotkeyPaneContent); + + _hotkeyPaneSidebar = null; + _hotkeyPaneContent = null; + _paneHotkeysPushed = false; + } + + private void OnHotkeyPressedFocusSidebar() + { + if (!Visible || !IsInstanceValid(this) || !ActiveScreenContext.Instance.IsCurrent(this)) + return; + + FocusSidebarPaneFromInput(); + } + + private void OnHotkeyPressedFocusContent() + { + if (!Visible || !IsInstanceValid(this) || !ActiveScreenContext.Instance.IsCurrent(this)) + return; + + FocusContentPaneFromInput(); + } + + private static bool IsFocusUnderPopupOrTransientWindow(Control? c) + { + for (Node? n = c; n != null; n = n.GetParent()) + switch (n) + { + case PopupMenu: + case Window { Visible: true, PopupWindow: true }: + return true; + } + + return false; + } + + private void FocusContentPaneFromInput() + { + if (!IsInstanceValid(this) || !Visible || !ActiveScreenContext.Instance.IsCurrent(this)) + return; + + var fo = GetViewport()?.GuiGetFocusOwner(); + if (IsFocusUnderPopupOrTransientWindow(fo)) + return; + + if (fo != null && IsInstanceValid(fo) && _contentPanelRoot.IsAncestorOf(fo)) + return; + + RebuildFocusChainsOnly(); + GrabControlDeferred(ResolveContentFocusFirstInContentPanel()); + } + + private Control? ResolveContentFocusFirstInContentPanel() + { + return _contentFocusChain.FirstOrDefault(); + } + + private Control? ResolveContentFocusTargetForSection() + { + if (_contentFocusChain.Count == 0) + return null; + + if (!string.IsNullOrWhiteSpace(_selectedSectionId)) + if (_contentList.FindChild($"Section_{_selectedSectionId}", true, false) is Control anchor) + foreach (var c in _contentFocusChain.Where(UnderScrollBody) + .Where(c => anchor == c || anchor.IsAncestorOf(c))) + return c; + + foreach (var c in _contentFocusChain.Where(UnderScrollBody)) + return c; + + return _contentFocusChain.FirstOrDefault(); + + bool UnderScrollBody(Control c) + { + return _contentList.IsAncestorOf(c); + } + } + + private void FocusSidebarPaneFromInput() + { + if (!IsInstanceValid(this) || !Visible || !ActiveScreenContext.Instance.IsCurrent(this)) + return; + + var fo = GetViewport()?.GuiGetFocusOwner(); + if (IsFocusUnderPopupOrTransientWindow(fo)) + return; + + if (fo != null && IsInstanceValid(fo) && _sidebarPanelRoot.IsAncestorOf(fo)) + return; + + RebuildFocusChainsOnly(); + GrabControlDeferred(ResolveSidebarTargetMatchingContent()); + } + + private Control? ResolveSidebarTargetMatchingContent() + { + if (!string.IsNullOrWhiteSpace(_selectedSectionId) + && _sectionButtons.TryGetValue(_selectedSectionId, out var sectionBtn) + && sectionBtn.IsVisibleInTree()) + return sectionBtn; + + if (!string.IsNullOrWhiteSpace(_selectedPageId) + && _pageButtons.TryGetValue(_selectedPageId, out var pageBtn) + && pageBtn.IsVisibleInTree()) + return pageBtn; + + if (!string.IsNullOrWhiteSpace(_selectedModId) + && _modButtons.TryGetValue(_selectedModId, out var modBtn) + && modBtn.IsVisibleInTree()) + return modBtn; + + return _sidebarFocusChain.FirstOrDefault(); + } + + private Control? ResolveInitialSidebarFocus() + { + if (_focusSelectedPageButtonOnNextRefresh) + { + _focusSelectedPageButtonOnNextRefresh = false; + if (!string.IsNullOrWhiteSpace(_selectedPageId) + && _pageButtons.TryGetValue(_selectedPageId, out var pageButton) + && pageButton.Visible) + return pageButton; + + if (!string.IsNullOrWhiteSpace(_selectedModId) + && _modButtons.TryGetValue(_selectedModId, out var modButton) + && modButton.Visible) + return modButton; + } + + if (!string.IsNullOrWhiteSpace(_selectedSectionId) + && _sectionButtons.TryGetValue(_selectedSectionId, out var sectionBtn) + && sectionBtn.IsVisibleInTree()) + return sectionBtn; + + if (!string.IsNullOrWhiteSpace(_selectedPageId) + && _pageButtons.TryGetValue(_selectedPageId, out var pb) + && pb.Visible) + return pb; + + if (!string.IsNullOrWhiteSpace(_selectedModId) + && _modButtons.TryGetValue(_selectedModId, out var mb) + && mb.Visible) + return mb; + + return null; + } + + private Control CreateSidebarPanel() + { + var panel = new Panel + { + Name = "RitsuSidebarPanel", + CustomMinimumSize = new(SidebarWidth, 0f), + SizeFlagsVertical = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + _sidebarPanelRoot = panel; + panel.AddThemeStyleboxOverride("panel", CreatePanelStyle(new(0.10f, 0.115f, 0.145f, 0.96f))); + + var frame = new MarginContainer + { + AnchorRight = 1f, + AnchorBottom = 1f, + MouseFilter = MouseFilterEnum.Ignore, + }; + frame.AddThemeConstantOverride("margin_left", 16); + frame.AddThemeConstantOverride("margin_top", 16); + frame.AddThemeConstantOverride("margin_right", 16); + frame.AddThemeConstantOverride("margin_bottom", 16); + panel.AddChild(frame); + + var root = new VBoxContainer + { + AnchorRight = 1f, + AnchorBottom = 1f, + MouseFilter = MouseFilterEnum.Ignore, + }; + root.AddThemeConstantOverride("separation", 14); + frame.AddChild(root); + + var headerCard = new PanelContainer + { + MouseFilter = MouseFilterEnum.Ignore, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + }; + headerCard.AddThemeStyleboxOverride("panel", ModSettingsUiFactory.CreateInsetSurfaceStyle()); + root.AddChild(headerCard); + + var headerBox = new VBoxContainer + { + MouseFilter = MouseFilterEnum.Ignore, + }; + headerBox.AddThemeConstantOverride("separation", 4); + headerCard.AddChild(headerBox); + + var headerTitle = + ModSettingsUiFactory.CreateSectionTitle(ModSettingsLocalization.Get("sidebar.title", "Mods")); + headerTitle.CustomMinimumSize = new(0f, 30f); + headerBox.AddChild(headerTitle); + + headerBox.AddChild(ModSettingsUiFactory.CreateInlineDescription( + ModSettingsLocalization.Get("sidebar.subtitle", "Browse mods, pages, and sections."))); + + var scroll = new ScrollContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, + FollowFocus = false, + FocusMode = FocusModeEnum.None, + }; + _sidebarScrollContainer = scroll; + root.AddChild(scroll); + + var sidebarScrollFrame = new MarginContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + sidebarScrollFrame.AddThemeConstantOverride("margin_right", ScrollContentRightGutter); + scroll.AddChild(sidebarScrollFrame); + + _modButtonList = new() + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + _modButtonList.AddThemeConstantOverride("separation", 12); + sidebarScrollFrame.AddChild(_modButtonList); + return panel; + } + + private Control CreateContentPanel() + { + var panel = new Panel + { + Name = "RitsuContentPanel", + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + _contentPanelRoot = panel; + panel.AddThemeStyleboxOverride("panel", CreatePanelStyle(new(0.08f, 0.095f, 0.125f, 0.98f))); + + var frame = new MarginContainer + { + AnchorRight = 1f, + AnchorBottom = 1f, + MouseFilter = MouseFilterEnum.Ignore, + }; + frame.AddThemeConstantOverride("margin_left", 18); + frame.AddThemeConstantOverride("margin_top", 18); + frame.AddThemeConstantOverride("margin_right", 18); + frame.AddThemeConstantOverride("margin_bottom", 18); + panel.AddChild(frame); + + var root = new VBoxContainer + { + AnchorRight = 1f, + AnchorBottom = 1f, + MouseFilter = MouseFilterEnum.Ignore, + }; + root.AddThemeConstantOverride("separation", 10); + frame.AddChild(root); + + _pageTabRow = new() + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + _pageTabRow.AddThemeConstantOverride("separation", 8); + root.AddChild(_pageTabRow); + + _scrollContainer = new() + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, + FollowFocus = true, + FocusMode = FocusModeEnum.None, + }; + root.AddChild(_scrollContainer); + + var contentScrollFrame = new MarginContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + contentScrollFrame.AddThemeConstantOverride("margin_right", ScrollContentRightGutter); + _scrollContainer.AddChild(contentScrollFrame); + + _contentList = new() + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + _contentList.AddThemeConstantOverride("separation", 8); + contentScrollFrame.AddChild(_contentList); + + return panel; + } + + private void Rebuild() + { + ModSettingsBaseLibReflectionMirror.TryRegisterMirroredPages(); + ModSettingsBaseLibToRitsuGeneratedReflectionMirror.TryRegisterMirroredPages(); + ModSettingsModConfigReflectionMirror.TryRegisterMirroredPages(); + ModSettingsRuntimeReflectionInteropMirror.TryRegisterMirroredPages(); + ApplyStaticTexts(); + RebuildSidebar(); + RebuildContent(true); + } + + private void RebuildSidebar() + { + _dynamicVisibilityTargets.Clear(); + _modButtonList.FreeChildren(); + _modButtons.Clear(); + _pageButtons.Clear(); + _sectionButtons.Clear(); + + var rootPages = ModSettingsRegistry.GetPages() + .Where(page => string.IsNullOrWhiteSpace(page.ParentPageId)) + .GroupBy(page => page.ModId, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => ModSettingsRegistry.GetModSidebarOrder(group.Key)) + .ThenBy(group => ModSettingsLocalization.ResolveModName(group.Key, group.Key), + StringComparer.OrdinalIgnoreCase) + .ThenBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (rootPages.Length == 0) + { + _selectedModId = null; + return; + } + + if (string.IsNullOrWhiteSpace(_selectedModId) || rootPages.All(group => + !string.Equals(group.Key, _selectedModId, StringComparison.OrdinalIgnoreCase))) + _selectedModId = rootPages[0].Key; + + ExpandOnlyMod(_selectedModId); + + foreach (var group in rootPages) + { + var modId = group.Key; + var pages = ModSettingsRegistry.GetPages() + .Where(page => string.Equals(page.ModId, modId, StringComparison.OrdinalIgnoreCase)) + .OrderBy(ModSettingsRegistry.GetEffectivePageSortOrder) + .ThenBy(page => page.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var section = new VBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + section.AddThemeConstantOverride("separation", 8); + + var card = new PanelContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + card.AddThemeStyleboxOverride("panel", CreateSidebarGroupStyle( + string.Equals(modId, _selectedModId, StringComparison.OrdinalIgnoreCase))); + section.AddChild(card); + + var cardContent = new VBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + cardContent.AddThemeConstantOverride("separation", 8); + card.AddChild(cardContent); + + var button = ModSettingsUiFactory.CreateSidebarButton( + ResolveSidebarModTitle(group.ToArray()), + () => + { + _selectedModId = modId; + _selectedPageId = pages.FirstOrDefault(page => string.IsNullOrWhiteSpace(page.ParentPageId)) + ?.Id; + _selectedSectionId = null; + ExpandOnlyMod(modId); + _focusSelectedPageButtonOnNextRefresh = true; + Rebuild(); + }, + ModSettingsSidebarItemKind.ModGroup, + _expandedModIds.Contains(modId) ? "▼" : "▶"); + button.Name = $"Mod_{modId}"; + cardContent.AddChild(button); + + var isExpanded = _expandedModIds.Contains(modId); + if (isExpanded) + { + var meta = ModSettingsUiFactory.CreateInlineDescription(string.Format( + ModSettingsLocalization.Get("sidebar.modMeta", "{0} pages"), + pages.Length)); + cardContent.AddChild(meta); + + var navStack = new VBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + navStack.AddThemeConstantOverride("separation", 6); + cardContent.AddChild(navStack); + + foreach (var page in pages.Where(page => string.IsNullOrWhiteSpace(page.ParentPageId))) + navStack.AddChild(CreateSidebarPageTreeButton(pages, page, 1)); + } + + _modButtonList.AddChild(section); + _modButtons[modId] = button; + } + } + + private void RebuildContent(bool fromFullRebuild = false) + { + CancelDeferredRefreshFlush(); + _contentOnlyRebuildNeedsContentFocus = !fromFullRebuild; + _pageTabRow.FreeChildren(); + _pageTabRow.Visible = false; + _contentList.FreeChildren(); + _refreshActions.Clear(); + + foreach (var pair in _modButtons) + pair.Value.SetSelected(string.Equals(pair.Key, _selectedModId, StringComparison.OrdinalIgnoreCase)); + + foreach (var pair in _pageButtons) + pair.Value.SetSelected(string.Equals(pair.Key, _selectedPageId, StringComparison.OrdinalIgnoreCase)); + + foreach (var pair in _sectionButtons) + pair.Value.SetSelected(string.Equals(pair.Key, _selectedSectionId, StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrWhiteSpace(_selectedModId)) + { + _contentList.AddChild(CreateEmptyStateLabel(ModSettingsLocalization.Get("empty.none", + "No mod settings pages are currently registered."))); + RefreshFocusNavigation(); + return; + } + + var rootPages = ModSettingsRegistry.GetPages() + .Where(page => string.Equals(page.ModId, _selectedModId, StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(page.ParentPageId)) + .OrderBy(ModSettingsRegistry.GetEffectivePageSortOrder) + .ThenBy(page => page.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (rootPages.Length == 0) + { + _contentList.AddChild(CreateEmptyStateLabel(ModSettingsLocalization.Get("empty.mod", + "This mod does not currently expose a settings page."))); + RefreshFocusNavigation(); + return; + } + + if (string.IsNullOrWhiteSpace(_selectedPageId) || + (rootPages.All(page => !string.Equals(page.Id, _selectedPageId, StringComparison.OrdinalIgnoreCase)) && + ModSettingsRegistry.GetPages().All(page => + !string.Equals(page.ModId, _selectedModId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(page.Id, _selectedPageId, StringComparison.OrdinalIgnoreCase)))) + _selectedPageId = rootPages[0].Id; + + var pageToRender = ResolveSelectedPage(); + if (pageToRender == null) + { + _contentList.AddChild(CreateEmptyStateLabel(ModSettingsLocalization.Get("empty.page", + "The selected settings page could not be found."))); + RefreshFocusNavigation(); + return; + } + + var context = new ModSettingsUiContext(this); + var isChildPage = !string.IsNullOrWhiteSpace(pageToRender.ParentPageId); + Action onBack = isChildPage + ? () => + { + _selectedPageId = pageToRender.ParentPageId!; + RebuildContent(); + } + : static () => { }; + + _pageTabRow.Visible = true; + var pageHeader = ModSettingsUiFactory.CreateModSettingsPageHeaderBar(context, pageToRender, isChildPage, + onBack); + pageHeader.SizeFlagsHorizontal = SizeFlags.ExpandFill; + _pageTabRow.AddChild(pageHeader); + + _contentList.AddChild(ModSettingsUiFactory.CreatePageContent(context, pageToRender)); + ApplyDynamicVisibilityTargets(); + RefreshFocusNavigation(); + Callable.From(ScrollToSelectedAnchor).CallDeferred(); + } + + private Control CreateSidebarPageTreeButton(IReadOnlyList pages, ModSettingsPage page, + int depth) + { + var button = ModSettingsUiFactory.CreateSidebarButton( + ResolvePageTabTitle(page), () => + { + var samePage = string.Equals(_selectedPageId, page.Id, StringComparison.OrdinalIgnoreCase); + _selectedModId = page.ModId; + _selectedPageId = page.Id; + if (!samePage) + _selectedSectionId = null; + ExpandOnlyMod(page.ModId); + Rebuild(); + }, + ModSettingsSidebarItemKind.Page, + "◦", + Math.Max(0, depth - 1)); + button.CustomMinimumSize = new(0f, 48f); + button.SetSelected(string.Equals(page.Id, _selectedPageId, StringComparison.OrdinalIgnoreCase)); + _pageButtons[page.Id] = button; + if (page.VisibleWhen != null) + RegisterDynamicVisibility(button, page.VisibleWhen); + + var container = new VBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + container.AddThemeConstantOverride("separation", 4); + container.AddChild(button); + + if (string.Equals(page.Id, _selectedPageId, StringComparison.OrdinalIgnoreCase)) + { + var sectionRail = new VBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + sectionRail.AddThemeConstantOverride("separation", 4); + foreach (var section in page.Sections) + { + var sectionButton = ModSettingsUiFactory.CreateSidebarButton(ResolveSectionTitle(section), () => + { + _selectedModId = page.ModId; + NavigateToSection(page.Id, section.Id); + }, + ModSettingsSidebarItemKind.Section, + "·", + depth + 1); + sectionButton.CustomMinimumSize = new(0f, 40f); + sectionButton.SetSelected(string.Equals(section.Id, _selectedSectionId, + StringComparison.OrdinalIgnoreCase)); + _sectionButtons[section.Id] = sectionButton; + if (section.VisibleWhen != null) + RegisterDynamicVisibility(sectionButton, section.VisibleWhen); + sectionRail.AddChild(sectionButton); + } + + container.AddChild(sectionRail); + } + + foreach (var child in pages.Where(candidate => + string.Equals(candidate.ParentPageId, page.Id, StringComparison.OrdinalIgnoreCase)) + .OrderBy(ModSettingsRegistry.GetEffectivePageSortOrder) + .ThenBy(candidate => candidate.Id, StringComparer.OrdinalIgnoreCase)) + container.AddChild(CreateSidebarPageTreeButton(pages, child, depth + 1)); + + return container; + } + + private ModSettingsPage? ResolveSelectedPage() + { + return ModSettingsRegistry.GetPages().FirstOrDefault(page => + string.Equals(page.ModId, _selectedModId, StringComparison.OrdinalIgnoreCase) && + string.Equals(page.Id, _selectedPageId, StringComparison.OrdinalIgnoreCase)); + } + + private static string ResolvePageTabTitle(ModSettingsPage page) + { + return ModSettingsLocalization.ResolvePageDisplayName(page); + } + + private static string ResolveSidebarModTitle(IReadOnlyList pages) + { + var modId = pages[0].ModId; + return ModSettingsLocalization.ResolveModName(modId, modId); + } + + private static string ResolveSectionTitle(ModSettingsSection section) + { + return section.Title?.Resolve() ?? ModSettingsLocalization.Get("section.default", "Section"); + } + + private void ScrollToSelectedAnchor() + { + _suppressScrollSync = true; + if (!string.IsNullOrWhiteSpace(_selectedSectionId)) + if (_contentList.FindChild($"Section_{_selectedSectionId}", true, false) is Control target) + { + _scrollContainer.ScrollVertical = Mathf.RoundToInt(target.GlobalPosition.Y - + _scrollContainer.GlobalPosition.Y + _scrollContainer.ScrollVertical - 12f); + Callable.From(() => _suppressScrollSync = false).CallDeferred(); + return; + } + + _scrollContainer.ScrollVertical = 0; + Callable.From(() => _suppressScrollSync = false).CallDeferred(); + } + + private void OnContentScrollChanged(double value) + { + if (_suppressScrollSync) + return; + + var page = ResolveSelectedPage(); + if (page == null || page.Sections.Count == 0) + return; + + var viewportTop = _scrollContainer.GlobalPosition.Y + 24f; + var bestSectionId = page.Sections[0].Id; + var bestDistance = float.MaxValue; + + foreach (var section in page.Sections) + { + if (_contentList.FindChild($"Section_{section.Id}", true, false) is not Control target) + continue; + + var distance = MathF.Abs(target.GlobalPosition.Y - viewportTop); + if (!(distance < bestDistance)) continue; + bestDistance = distance; + bestSectionId = section.Id; + } + + if (string.Equals(bestSectionId, _selectedSectionId, StringComparison.OrdinalIgnoreCase)) + return; + + _selectedSectionId = bestSectionId; + foreach (var pair in _sectionButtons) + pair.Value.SetSelected(string.Equals(pair.Key, _selectedSectionId, StringComparison.OrdinalIgnoreCase)); + } + + private void RefreshFocusNavigation() + { + if (_focusNavigationRefreshScheduled) + return; + _focusNavigationRefreshScheduled = true; + Callable.From(FlushFocusNavigationDeferred).CallDeferred(); + } + + private void FlushFocusNavigationDeferred() + { + _focusNavigationRefreshScheduled = false; + if (!IsInstanceValid(this) || !Visible) + return; + + ApplySplitPaneFocusNavigation(); + this.UpdateControllerNavEnabled(); + } + + private void RebuildFocusChainsOnly() + { + _sidebarFocusChain.Clear(); + _contentFocusChain.Clear(); + CollectSettingsFocusChainPreorder(_sidebarPanelRoot, _sidebarFocusChain); + CollectSettingsFocusChainPreorder(_contentPanelRoot, _contentFocusChain); + + WireVerticalOnlyChain(_sidebarFocusChain); + WireVerticalOnlyChain(_contentFocusChain); + + _initialFocusedControl = ResolveInitialSidebarFocus() ?? _sidebarFocusChain.FirstOrDefault(); + + UpdatePaneHotkeyHintIcons(); + } + + private void ApplySplitPaneFocusNavigation() + { + RebuildFocusChainsOnly(); + var owner = GetViewport()?.GuiGetFocusOwner(); + switch (_contentOnlyRebuildNeedsContentFocus) + { + case false when + IsInstanceValid(owner) && IsAncestorOf(owner): + return; + case true: + { + _contentOnlyRebuildNeedsContentFocus = false; + var contentTarget = ResolveContentFocusTargetForSection(); + if (contentTarget != null && contentTarget.IsVisibleInTree()) + { + GrabControlDeferred(contentTarget); + return; + } + + break; + } + } + + if (IsFocusUnderPopupOrTransientWindow(owner)) + return; + + var focusLost = owner == null || !IsInstanceValid(owner) || !IsAncestorOf(owner); + if (focusLost) + GrabControlDeferred(_initialFocusedControl); + else + _initialFocusedControl?.TryGrabFocus(); + } + + private static void GrabControlDeferred(Control? target) + { + if (target == null) + return; + + var t = target; + Callable.From(() => + { + if (!IsInstanceValid(t) || !t.IsVisibleInTree()) + return; + + t.GrabFocus(); + }).CallDeferred(); + } + + private static void WireVerticalOnlyChain(IReadOnlyList chain) + { + for (var index = 0; index < chain.Count; index++) + { + var current = chain[index]; + var selfPath = current.GetPath(); + current.FocusNeighborLeft = selfPath; + current.FocusNeighborRight = selfPath; + current.FocusNeighborTop = index > 0 ? chain[index - 1].GetPath() : null; + current.FocusNeighborBottom = + index < chain.Count - 1 ? chain[index + 1].GetPath() : null; + } + } + + private static void CollectSettingsFocusChainPreorder(Control parent, List controls) + { + foreach (var child in parent.GetChildren()) + { + if (child is not Control item || !item.IsVisibleInTree()) + continue; + + if (IsSettingsFocusTerminal(item)) + { + if (item.FocusMode == FocusModeEnum.All) + controls.Add(item); + continue; + } + + CollectSettingsFocusChainPreorder(item, controls); + } + } + + private static bool IsSettingsFocusTerminal(Control c) + { + return c switch + { + ModSettingsSidebarButton or ModSettingsTextButton or ModSettingsCollapsibleHeaderButton + or ModSettingsToggleControl or ModSettingsMiniButton or ModSettingsDragHandle + or ModSettingsActionsButton or NButton + or HSlider or OptionButton or ColorPickerButton or MenuButton => true, + LineEdit or TextEdit => c.FocusMode == FocusModeEnum.All, + _ => c is Button, + }; + } + + private void ApplyStaticTexts() + { + _titleLabel.SetTextAutoSize(ModSettingsLocalization.Get("entry.title", "Mod Settings (RitsuLib)")); + _subtitleLabel.SetTextAutoSize(ModSettingsLocalization.Get("entry.subtitle", + "Edit player-facing mod options here.")); + } + + private void ExpandOnlyMod(string? modId) + { + _expandedModIds.Clear(); + if (!string.IsNullOrWhiteSpace(modId)) + _expandedModIds.Add(modId); + } + + private void FlushDirtyBindings() + { + if (_dirtyBindings.Count == 0) + { + _saveTimer = -1; + return; + } + + foreach (var binding in _dirtyBindings.ToArray()) + try + { + binding.Save(); + } + catch (Exception ex) + { + RitsuLibFramework.Logger.Warn( + $"[Settings] Failed to save '{binding.ModId}:{binding.DataKey}': {ex.Message}"); + } + + _dirtyBindings.Clear(); + _saveTimer = -1; + } + + private void SubscribeLocaleChanges() + { + if (_localeSubscribed) + return; + + try + { + LocManager.Instance.SubscribeToLocaleChange(OnLocaleChanged); + _localeSubscribed = true; + } + catch + { + // ignored + } + } + + private void UnsubscribeLocaleChanges() + { + if (!_localeSubscribed) + return; + + try + { + LocManager.Instance.UnsubscribeToLocaleChange(OnLocaleChanged); + } + catch + { + // ignored + } + + _localeSubscribed = false; + } + + private void OnLocaleChanged() + { + FlushDirtyBindings(); + Callable.From(Rebuild).CallDeferred(); + } + + private static MegaRichTextLabel CreateTitleLabel(int fontSize, HorizontalAlignment alignment) + { + var label = new MegaRichTextLabel + { + Theme = ModSettingsUiResources.SettingsLineTheme, + BbcodeEnabled = true, + AutoSizeEnabled = false, + ScrollActive = false, + HorizontalAlignment = alignment, + VerticalAlignment = VerticalAlignment.Center, + MouseFilter = MouseFilterEnum.Ignore, + FocusMode = FocusModeEnum.None, + }; + + label.AddThemeFontOverride("normal_font", ModSettingsUiResources.KreonRegular); + label.AddThemeFontOverride("bold_font", ModSettingsUiResources.KreonBold); + label.AddThemeFontSizeOverride("normal_font_size", fontSize); + label.AddThemeFontSizeOverride("bold_font_size", fontSize); + label.AddThemeFontSizeOverride("italics_font_size", fontSize); + label.AddThemeFontSizeOverride("bold_italics_font_size", fontSize); + label.AddThemeFontSizeOverride("mono_font_size", fontSize); + label.MinFontSize = Math.Min(fontSize, 16); + label.MaxFontSize = fontSize; + return label; + } + + private static MegaRichTextLabel CreateEmptyStateLabel(string text) + { + var label = CreateTitleLabel(24, HorizontalAlignment.Center); + label.CustomMinimumSize = new(0f, 120f); + label.SizeFlagsHorizontal = SizeFlags.ExpandFill; + label.SetTextAutoSize(text); + return label; + } + + private static StyleBoxFlat CreatePanelStyle(Color bg) + { + return new() + { + BgColor = bg, + BorderColor = new(0.44f, 0.68f, 0.80f, 0.36f), + BorderWidthLeft = 1, + BorderWidthTop = 1, + BorderWidthRight = 1, + BorderWidthBottom = 1, + CornerRadiusTopLeft = ModSettingsUiMetrics.CornerRadius, + CornerRadiusTopRight = ModSettingsUiMetrics.CornerRadius, + CornerRadiusBottomRight = ModSettingsUiMetrics.CornerRadius, + CornerRadiusBottomLeft = ModSettingsUiMetrics.CornerRadius, + ShadowColor = new(0f, 0f, 0f, 0.32f), + ShadowSize = 12, + ContentMarginLeft = 0, + ContentMarginTop = 0, + ContentMarginRight = 0, + ContentMarginBottom = 0, + }; + } + + private static StyleBoxFlat CreateSidebarGroupStyle(bool selected) + { + return new() + { + BgColor = selected + ? new(0.085f, 0.125f, 0.165f, 0.97f) + : new Color(0.07f, 0.095f, 0.13f, 0.94f), + BorderColor = selected + ? new(0.58f, 0.80f, 0.90f, 0.58f) + : new Color(0.30f, 0.44f, 0.54f, 0.36f), + BorderWidthLeft = 1, + BorderWidthTop = 1, + BorderWidthRight = 1, + BorderWidthBottom = 1, + CornerRadiusTopLeft = ModSettingsUiMetrics.CornerRadius, + CornerRadiusTopRight = ModSettingsUiMetrics.CornerRadius, + CornerRadiusBottomRight = ModSettingsUiMetrics.CornerRadius, + CornerRadiusBottomLeft = ModSettingsUiMetrics.CornerRadius, + ShadowColor = new(0f, 0f, 0f, 0.16f), + ShadowSize = 4, + ContentMarginLeft = 10, + ContentMarginTop = 10, + ContentMarginRight = 10, + ContentMarginBottom = 10, + }; + } + } +} diff --git a/STS2-RitsuLib-main/Settings/Patches/AndroidGraphicsSettingsCompatibilityPatch.cs b/STS2-RitsuLib-main/Settings/Patches/AndroidGraphicsSettingsCompatibilityPatch.cs new file mode 100644 index 0000000..f90b62d --- /dev/null +++ b/STS2-RitsuLib-main/Settings/Patches/AndroidGraphicsSettingsCompatibilityPatch.cs @@ -0,0 +1,39 @@ +using Godot; +using MegaCrit.Sts2.Core.Nodes.Screens.Settings; +using STS2RitsuLib.Patching.Models; + +namespace STS2RitsuLib.Settings.Patches +{ + /// + /// Some Android builds omit the ShaderCompatibility entry from the settings scene. Skip the vanilla wiring call + /// in that case so opening settings does not throw node lookup exceptions. + /// + public class AndroidGraphicsSettingsCompatibilityPatch : IPatchMethod + { + public static string PatchId => "android_graphics_settings_missing_shader_compatibility"; + + public static bool IsCritical => false; + + public static string Description => + "Skip ConfigureAndroidGraphicsEntries when %ShaderCompatibility is absent"; + + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(NSettingsScreen), "ConfigureAndroidGraphicsEntries")]; + } + + // ReSharper disable once InconsistentNaming + public static bool Prefix(NSettingsScreen __instance) + { + if (!OperatingSystem.IsAndroid()) + return true; + + if (__instance.GetNodeOrNull("%ShaderCompatibility") != null) + return true; + + RitsuLibFramework.Logger.Warn( + "[Settings][AndroidCompat] Missing %ShaderCompatibility node. Skipping ConfigureAndroidGraphicsEntries()."); + return false; + } + } +} diff --git a/STS2-RitsuLib-main/Settings/Patches/ModSettingsUiPatches.cs b/STS2-RitsuLib-main/Settings/Patches/ModSettingsUiPatches.cs new file mode 100644 index 0000000..22cfa7c --- /dev/null +++ b/STS2-RitsuLib-main/Settings/Patches/ModSettingsUiPatches.cs @@ -0,0 +1,309 @@ +using System.Runtime.CompilerServices; +using Godot; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes.GodotExtensions; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using MegaCrit.Sts2.Core.Nodes.Screens.Settings; +using STS2RitsuLib.Patching.Models; + +namespace STS2RitsuLib.Settings.Patches +{ + /// + /// Harmony patch that reuses one per + /// instance. + /// + public class ModSettingsSubmenuPatch : IPatchMethod + { + private static readonly ConditionalWeakTable Submenus = new(); + + /// + public static string PatchId => "ritsulib_mod_settings_submenu"; + + /// + public static string Description => "Inject RitsuLib mod settings submenu into the main menu stack"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + return [new(typeof(NMainMenuSubmenuStack), nameof(NMainMenuSubmenuStack.GetSubmenuType), [typeof(Type)])]; + } + + // ReSharper disable InconsistentNaming + /// + /// Returns a cached for the stack when the requested type matches. + /// + public static bool Prefix(NMainMenuSubmenuStack __instance, Type type, ref NSubmenu __result) + // ReSharper restore InconsistentNaming + { + if (type != typeof(RitsuModSettingsSubmenu)) + return true; + + __result = Submenus.GetValue(__instance, CreateSubmenu); + return false; + } + + private static RitsuModSettingsSubmenu CreateSubmenu(NMainMenuSubmenuStack stack) + { + var submenu = new RitsuModSettingsSubmenu + { + Visible = false, + MouseFilter = Control.MouseFilterEnum.Ignore, + FocusMode = Control.FocusModeEnum.None, + }; + + stack.AddChildSafely(submenu); + return submenu; + } + } + + /// + /// Injects the “Mod Settings (RitsuLib)” row into the vanilla settings screen and keeps general panel height in sync. + /// + public class SettingsScreenModSettingsButtonPatch : IPatchMethod + { + private const string GeneralSettingsResizeHookMeta = "ritsulib_general_settings_content_resize_hook"; + + /// + public static string PatchId => "ritsulib_mod_settings_button"; + + /// + public static string Description => "Add RitsuLib mod settings entry point to the settings screen"; + + /// + public static bool IsCritical => false; + + /// + public static ModPatchTarget[] GetTargets() + { + // Android Arm64/Mono + Cecil DMD on some game methods still trips MethodAccessException when + // patching _Ready. Keep the runtime injection on submenu-open only in safe mode so the game can + // finish booting while preserving a chance to surface the settings entry when the screen opens. + if (OperatingSystem.IsAndroid()) + { + return [new(typeof(NSettingsScreen), nameof(NSettingsScreen.OnSubmenuOpened))]; + } + + return + [ + new(typeof(NSettingsScreen), nameof(NSettingsScreen._Ready)), + new(typeof(NSettingsScreen), nameof(NSettingsScreen.OnSubmenuOpened)), + ]; + } + + // ReSharper disable once InconsistentNaming + /// + /// Ensures the entry line exists, refreshes copy, and schedules panel height refresh when mod pages exist. + /// + public static void Postfix(NSettingsScreen __instance) + { + if (!ModSettingsRegistry.HasPages) + return; + + try + { + var line = EnsureEntryPoint(__instance); + RefreshState(line); + var generalPanel = __instance.GetNode("%GeneralSettings"); + ScheduleRefreshGeneralSettingsPanelSize(generalPanel); + if (generalPanel.Content is { } generalVBox) + GeneralSettingsModEntryFocusWire.ScheduleTryWire(generalVBox); + } + catch (Exception ex) + { + RitsuLibFramework.Logger.Warn($"[Settings] Failed to add mod settings entry point: {ex.Message}"); + } + } + + private static MarginContainer EnsureEntryPoint(NSettingsScreen screen) + { + var panel = screen.GetNode("%GeneralSettings"); + var content = panel.Content; + EnsureGeneralSettingsContentTracksChildAdds(content); + + if (content.GetNodeOrNull("RitsuLibModSettings") is { } existing) + return existing; + + var divider = ModSettingsUiFactory.CreateDivider(); + divider.Name = "RitsuLibModSettingsDivider"; + + var line = ModSettingsGameSettingsEntryLine.Create(OpenSubmenu); + + content.AddChild(divider); + content.AddChild(line); + + var creditsDivider = content.GetNodeOrNull("CreditsDivider"); + if (creditsDivider == null) return line; + var targetIndex = creditsDivider.GetIndex(); + content.MoveChild(divider, targetIndex); + content.MoveChild(line, targetIndex + 1); + + return line; + + void OpenSubmenu() + { + screen.GetAncestorOfType()?.PushSubmenuType(typeof(RitsuModSettingsSubmenu)); + } + } + + + private static void EnsureGeneralSettingsContentTracksChildAdds(VBoxContainer content) + { + if (content.HasMeta(GeneralSettingsResizeHookMeta)) + return; + + content.SetMeta(GeneralSettingsResizeHookMeta, true); + content.ChildEnteredTree += OnGeneralSettingsContentChildEntered; + } + + private static void OnGeneralSettingsContentChildEntered(Node child) + { + var content = child.GetParentOrNull(); + // ReSharper disable once UseNullPropagation + if (content is null) + return; + + var panel = content.GetParentOrNull(); + if (panel is null) + return; + + ScheduleRefreshGeneralSettingsPanelSize(panel); + if (content.GetNodeOrNull("RitsuLibModSettings") != null) + GeneralSettingsModEntryFocusWire.ScheduleTryWire(content); + } + + private static void ScheduleRefreshGeneralSettingsPanelSize(NSettingsPanel panel) + { + Callable.From(() => RefreshPanelSize(panel)).CallDeferred(); + } + + private static void RefreshState(MarginContainer line) + { + line.Visible = true; + + if (line.GetNodeOrNull("ContentRow/Label") is { } label) + label.SetTextAutoSize(ModSettingsLocalization.Get("entry.title", "Mod Settings (RitsuLib)")); + + if (line.GetNodeOrNull("ContentRow/RitsuLibModSettingsButton") is { } button) + button.Enable(); + } + + /// + /// Mirrors 's private refresh: when content exceeds the viewport (plus padding), panel + /// height becomes contentMinY + parentHeight * 0.4f for bottom scroll slack (game default). + /// + private static void RefreshPanelSize(NSettingsPanel panel) + { + try + { + var content = panel.Content; + content.QueueSort(); + + var parent = panel.GetParent(); + if (parent is null) + return; + + var parentSize = parent.Size; + var minimumSize = content.GetMinimumSize(); + var stackedMinY = ComputeVBoxContentMinHeight(content); + var needHeightY = Mathf.Max(minimumSize.Y, stackedMinY); + const float minPadding = 50f; + var width = content.Size.X > 1f ? content.Size.X : parentSize.X; + panel.Size = needHeightY + minPadding >= parentSize.Y + ? new(width, needHeightY + parentSize.Y * 0.4f) + : new Vector2(width, needHeightY); + } + catch (Exception ex) + { + RitsuLibFramework.Logger.Warn($"[Settings] Failed to refresh settings panel size: {ex.Message}"); + } + } + + /// + /// Sum of visible direct children's and VBox separation; + /// fallback when on the root VBox is temporarily too small. + /// + private static float ComputeVBoxContentMinHeight(VBoxContainer box) + { + var sep = box.GetThemeConstant("separation"); + var y = 0f; + var first = true; + foreach (var node in box.GetChildren()) + { + if (node is not Control { Visible: true } c) + continue; + + if (!first) + y += sep; + first = false; + y += c.GetCombinedMinimumSize().Y; + } + + return y; + } + } + + /// + /// Rebuilds the General tab vertical focus chain the same way does in + /// _Ready, after our row is injected (vanilla never sees the new controls). + /// + internal static class GeneralSettingsModEntryFocusWire + { + internal static void ScheduleTryWire(VBoxContainer content) + { + Callable.From(() => + { + TryRebuildEntireGeneralFocusChain(content); + Callable.From(() => TryRebuildEntireGeneralFocusChain(content)).CallDeferred(); + }).CallDeferred(); + } + + internal static void TryRebuildEntireGeneralFocusChain(VBoxContainer content) + { + if (content.GetNodeOrNull("RitsuLibModSettings") == null) + return; + + var list = new List(); + GetSettingsOptionsRecursive(content, list); + if (list.Count == 0) + return; + + for (var i = 0; i < list.Count; i++) + { + var current = list[i]; + current.FocusNeighborLeft = current.GetPath(); + current.FocusNeighborRight = current.GetPath(); + current.FocusNeighborTop = i > 0 ? list[i - 1].GetPath() : current.GetPath(); + current.FocusNeighborBottom = i < list.Count - 1 ? list[i + 1].GetPath() : current.GetPath(); + } + } + + private static void GetSettingsOptionsRecursive(Control parent, List ancestors) + { + foreach (var child in parent.GetChildren()) + { + if (child is not Control item) + continue; + + if (!IsVanillaGeneralSettingsFocusTarget(item)) + GetSettingsOptionsRecursive(item, ancestors); + else if (item.GetParent() is { } itemParent && + itemParent.IsVisible() && + item.FocusMode == Control.FocusModeEnum.All) + ancestors.Add(item); + } + } + + private static bool IsVanillaGeneralSettingsFocusTarget(Control c) + { + if (c is NButton nButton) + return nButton.IsEnabled; + + return c is NPaginator or NTickbox or NButton or NDropdownPositioner or NSettingsSlider; + } + } +}