diff --git a/docs/release-notes.md b/docs/release-notes.md index f95a6192..4596a525 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -11,8 +11,9 @@ * Internal changes to improve performance and reliability. * For modders: - * Added `Multiplayer.PeerConnected` event. * Migrated to Harmony 2.0 (see [_migrate to Harmony 2.0_](https://stardewvalleywiki.com/Modding:Migrate_to_Harmony_2.0) for more info). + * Added `Multiplayer.PeerConnected` event. + * Added `harmony_summary` console command which lists all current Harmony patches, optionally with a search filter. * Harmony mods which use the `[HarmonyPatch(type)]` attribute now work crossplatform. Previously SMAPI couldn't rewrite types in custom attributes for compatibility. * Improved mod rewriting for compatibility: * Fixed rewriting types in custom attributes. diff --git a/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs b/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs new file mode 100644 index 00000000..bc8f4aa2 --- /dev/null +++ b/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using HarmonyLib; + +namespace StardewModdingAPI.Framework.Commands +{ + /// The 'harmony_summary' SMAPI console command. + internal class HarmonySummaryCommand : IInternalCommand + { + /********* + ** Accessors + *********/ + /// The command name, which the user must type to trigger it. + public string Name { get; } = "harmony_summary"; + + /// The human-readable documentation shown when the player runs the built-in 'help' command. + public string Description { get; } = "Harmony is a library which rewrites game code, used by SMAPI and some mods. This command lists current Harmony patches.\n\nUsage: harmony_summary\nList all Harmony patches.\n\nUsage: harmony_summary \n- search: one more more words to search. If any word matches a method name, the method and all its patchers will be listed; otherwise only matching patchers will be listed for the method."; + + + /********* + ** Public methods + *********/ + /// Handle the console command when it's entered by the user. + /// The command arguments. + /// Writes messages to the console. + public void HandleCommand(string[] args, IMonitor monitor) + { + SearchResult[] matches = this.FilterPatches(args).OrderBy(p => p.Method).ToArray(); + + StringBuilder result = new StringBuilder(); + + if (!matches.Any()) + result.AppendLine("No current patches match your search."); + else + { + result.AppendLine(args.Any() ? "Harmony patches which match your search terms:" : "Current Harmony patches:"); + result.AppendLine(); + foreach (var match in matches) + { + result.AppendLine($" {match.Method}"); + foreach (var ownerGroup in match.PatchTypesByOwner) + { + var sortedTypes = ownerGroup.Value + .OrderBy(p => p switch { PatchType.Prefix => 0, PatchType.Postfix => 1, PatchType.Finalizer => 2, PatchType.Transpiler => 3, _ => 4 }); + + result.AppendLine($" - {ownerGroup.Key} ({string.Join(", ", sortedTypes).ToLower()})"); + } + } + } + + monitor.Log(result.ToString(), LogLevel.Info); + } + + + /********* + ** Private methods + *********/ + /// Get all current Harmony patches matching any of the given search terms. + /// The search terms to match. + private IEnumerable FilterPatches(string[] searchTerms) + { + bool hasSearch = searchTerms.Any(); + bool IsMatch(string target) => searchTerms.Any(search => target != null && target.IndexOf(search, StringComparison.OrdinalIgnoreCase) > -1); + + foreach (var patch in this.GetAllPatches()) + { + if (!hasSearch) + yield return patch; + + // matches entire patch + if (IsMatch(patch.Method)) + { + yield return patch; + continue; + } + + // matches individual patchers + foreach (var pair in patch.PatchTypesByOwner.ToArray()) + { + if (!IsMatch(pair.Key) && !pair.Value.Any(type => IsMatch(type.ToString()))) + patch.PatchTypesByOwner.Remove(pair.Key); + } + + if (patch.PatchTypesByOwner.Any()) + yield return patch; + } + } + + /// Get all current Harmony patches. + private IEnumerable GetAllPatches() + { + foreach (MethodBase method in Harmony.GetAllPatchedMethods()) + { + // get metadata for method + string methodLabel = method.FullDescription(); + HarmonyLib.Patches patchInfo = Harmony.GetPatchInfo(method); + IDictionary> patchGroups = new Dictionary> + { + [PatchType.Prefix] = patchInfo.Prefixes, + [PatchType.Postfix] = patchInfo.Postfixes, + [PatchType.Finalizer] = patchInfo.Finalizers, + [PatchType.Transpiler] = patchInfo.Transpilers + }; + + // get patch types by owner + var typesByOwner = new Dictionary>(); + foreach (var group in patchGroups) + { + foreach (var patch in group.Value) + { + if (!typesByOwner.TryGetValue(patch.owner, out ISet patchTypes)) + typesByOwner[patch.owner] = patchTypes = new HashSet(); + patchTypes.Add(group.Key); + } + } + + // create search result + yield return new SearchResult(methodLabel, typesByOwner); + } + } + + /// A Harmony patch type. + private enum PatchType + { + /// A prefix patch. + Prefix, + + /// A postfix patch. + Postfix, + + /// A finalizer patch. + Finalizer, + + /// A transpiler patch. + Transpiler + } + + /// A patch search result for a method. + private class SearchResult + { + /********* + ** Accessors + *********/ + /// A detailed human-readable label for the patched method. + public string Method { get; } + + /// The patch types by the Harmony instance ID that added them. + public IDictionary> PatchTypesByOwner { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A detailed human-readable label for the patched method. + /// The patch types by the Harmony instance ID that added them. + public SearchResult(string method, IDictionary> patchTypesByOwner) + { + this.Method = method; + this.PatchTypesByOwner = patchTypesByOwner; + } + } + } +} diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index a89616a3..9d96bad1 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -511,6 +511,7 @@ namespace StardewModdingAPI.Framework this.Monitor.Log("Type 'help' for help, or 'help ' for a command's usage", LogLevel.Info); this.GameInstance.CommandManager .Add(new HelpCommand(this.GameInstance.CommandManager), this.Monitor) + .Add(new HarmonySummaryCommand(), this.Monitor) .Add(new ReloadI18nCommand(this.ReloadTranslations), this.Monitor); // start handling command line input