diff --git a/1.6/Assemblies/0Harmony.dll b/1.6/Assemblies/0Harmony.dll new file mode 100644 index 0000000..f501506 Binary files /dev/null and b/1.6/Assemblies/0Harmony.dll differ diff --git a/1.6/Assemblies/0Harmony.pdb b/1.6/Assemblies/0Harmony.pdb new file mode 100644 index 0000000..fa33809 Binary files /dev/null and b/1.6/Assemblies/0Harmony.pdb differ diff --git a/1.6/Assemblies/Better Work Tab.dll b/1.6/Assemblies/Better Work Tab.dll index 31d436b..bdec6a0 100644 Binary files a/1.6/Assemblies/Better Work Tab.dll and b/1.6/Assemblies/Better Work Tab.dll differ diff --git a/1.6/Assemblies/Better Work Tab.pdb b/1.6/Assemblies/Better Work Tab.pdb new file mode 100644 index 0000000..bbf6bf7 Binary files /dev/null and b/1.6/Assemblies/Better Work Tab.pdb differ diff --git a/Languages/English/Keyed/RuleBuilder.xml b/Languages/English/Keyed/RuleBuilder.xml index b4203cd..dd4bdb2 100644 --- a/Languages/English/Keyed/RuleBuilder.xml +++ b/Languages/English/Keyed/RuleBuilder.xml @@ -33,6 +33,8 @@ High priority. This pawn will be assigned before normal priority pawns. Normal priority. Standard assignment. Low priority. This pawn will be assigned last. + Priority ({0}) + Extended priority {0}. Higher numbers are lower priority and run after smaller non-zero values. Skill-Based diff --git a/Source/API/PriorityApi.cs b/Source/API/PriorityApi.cs new file mode 100644 index 0000000..2fad8c9 --- /dev/null +++ b/Source/API/PriorityApi.cs @@ -0,0 +1,161 @@ +using System; +using Better_Work_Tab.Features.RaisedPriorityMaximum; + +namespace Better_Work_Tab.API +{ + /// + /// Immutable snapshot of Better Work Tab's current priority configuration. + /// Designed to be easy to consume over reflection so other mods do not need + /// a hard assembly reference just to read max-priority settings. + /// + public readonly struct PriorityApiSnapshot + { + public readonly int ApiVersion; + public readonly int MaxPriority; + public readonly int DefaultEnabledPriority; + + public PriorityApiSnapshot(int apiVersion, int maxPriority, int defaultEnabledPriority) + { + ApiVersion = apiVersion; + MaxPriority = maxPriority; + DefaultEnabledPriority = defaultEnabledPriority; + } + } + + /// + /// Public compatibility surface for Better Work Tab's extended manual priorities. + /// Other mods should prefer this API over hardcoding the vanilla max priority of 4. + /// + /// Reflection integration: + /// - Assembly: "Better Work Tab" + /// - Type: "Better_Work_Tab.API.PriorityApi" + /// - Read methods such as or + /// + /// Example reflection flow for mods that do not want a hard dependency: + /// 1. Find the loaded assembly named "Better Work Tab". + /// 2. Resolve type "Better_Work_Tab.API.PriorityApi". + /// 3. Invoke static method "GetMaxPriority" or "GetSnapshot". + /// + public static class PriorityApi + { + /// + /// Stable API version for reflection-based callers. + /// Increment only when the public contract changes incompatibly. + /// + public const int ApiVersion = 1; + + /// + /// Stable assembly name for reflection-based lookups. + /// + public const string AssemblyName = "Better Work Tab"; + + /// + /// Stable fully-qualified type name for reflection-based lookups. + /// + public const string TypeName = "Better_Work_Tab.API.PriorityApi"; + + /// + /// Returns the configured upper bound for manual priorities. + /// Guaranteed to return at least 1. + /// + public static int GetMaxPriority() + { + try + { + return MaxPriorityLogic.GetMaxPriority(); + } + catch + { + return Math.Max(1, DefaultSettings.maxPriority); + } + } + + /// + /// Returns the default priority used when enabling a work type outside manual-priority mode. + /// + public static int GetDefaultEnabledPriority() + { + try + { + return MaxPriorityLogic.GetDefaultEnabledPriority(); + } + catch + { + return Clamp(3, 1, GetMaxPriority()); + } + } + + /// + /// Maps an extended stored priority back into RimWorld's vanilla-style 1..4 display buckets. + /// Returns 0 for disabled priorities. + /// + public static int MapPriorityToVanillaDisplay(int priority) + { + try + { + return MaxPriorityLogic.MapPriorityToVanillaDisplay(priority); + } + catch + { + if (priority <= 0) + { + return 0; + } + + int maxPriority = GetMaxPriority(); + if (maxPriority <= 1) + { + return 1; + } + + float remapped = Spine.Utils.SpineUtils.Remap(priority, 1, maxPriority, 1, 4); + return Clamp((int)Math.Round(remapped), 1, 4); + } + } + + /// + /// True when the priority value represents "disabled" for a work type. + /// + public static bool IsDisabledPriority(int priority) + { + return priority <= 0; + } + + /// + /// Returns a small versioned snapshot for reflection-friendly integration. + /// This is the preferred call for mods that want one round-trip instead of multiple method calls. + /// + public static PriorityApiSnapshot GetSnapshot() + { + return new PriorityApiSnapshot( + ApiVersion, + GetMaxPriority(), + GetDefaultEnabledPriority()); + } + + /// + /// Safe reflection-friendly wrapper that never throws. + /// Returns false only if the API could not produce a valid snapshot. + /// + public static bool TryGetSnapshot(out PriorityApiSnapshot snapshot) + { + try + { + snapshot = GetSnapshot(); + return true; + } + catch + { + snapshot = new PriorityApiSnapshot(ApiVersion, Math.Max(1, DefaultSettings.maxPriority), 3); + return false; + } + } + + private static int Clamp(int value, int min, int max) + { + if (value < min) return min; + if (value > max) return max; + return value; + } + } +} diff --git a/Source/API/PriorityApi.md b/Source/API/PriorityApi.md new file mode 100644 index 0000000..d62cf46 --- /dev/null +++ b/Source/API/PriorityApi.md @@ -0,0 +1,45 @@ +# Better Work Tab Priority API + +This API exists so other mods can read Better Work Tab's extended priority settings without hardcoding the vanilla max priority of `4`. + +## Goals + +- No hard dependency required +- Stable reflection target +- Small read-only surface +- Versioned for future extension + +## Reflection Contract + +- Assembly name: `Better Work Tab` +- Type name: `Better_Work_Tab.API.PriorityApi` +- API version field: `ApiVersion` + +## Public Methods + +- `GetMaxPriority()` +- `GetDefaultEnabledPriority()` +- `MapPriorityToVanillaDisplay(int priority)` +- `IsDisabledPriority(int priority)` +- `GetSnapshot()` +- `TryGetSnapshot(out PriorityApiSnapshot snapshot)` + +## Recommended Integration + +If you do not want a compile-time reference to Better Work Tab: + +1. Find the loaded assembly named `Better Work Tab` +2. Resolve `Better_Work_Tab.API.PriorityApi` +3. Prefer calling `GetSnapshot()` or `TryGetSnapshot(...)` + +`GetSnapshot()` returns a versioned struct with: + +- `ApiVersion` +- `MaxPriority` +- `DefaultEnabledPriority` + +## Notes + +- `0` means disabled +- Higher numbers are lower priority +- `MapPriorityToVanillaDisplay(...)` intentionally compresses extended priorities back into vanilla `1..4` display buckets for compatibility-oriented UI diff --git a/Source/Better Work Tab.csproj b/Source/Better Work Tab.csproj index 2c0f28b..bc0aad6 100644 --- a/Source/Better Work Tab.csproj +++ b/Source/Better Work Tab.csproj @@ -49,6 +49,7 @@ + @@ -110,7 +111,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -204,6 +225,7 @@ + diff --git a/Source/BetterWorkTabSettings.cs b/Source/BetterWorkTabSettings.cs index 018a772..12c5899 100644 --- a/Source/BetterWorkTabSettings.cs +++ b/Source/BetterWorkTabSettings.cs @@ -204,11 +204,11 @@ public enum ColorScheme { RimWorldDefault, Colorblind_Deuteranopia, Colorblind_P new WorkAssignmentParameters("Always Assigns", 3, isNaturalAlwaysAssign: true), }, resetBeforeApplying: true, isDefault: true), - new WorkAssignmentRuleset("Vanilla New Pawn", new List() - { - new WorkAssignmentParameters("Top 6", 3, isTopXSkill: 6), - new WorkAssignmentParameters("Always Assigns", 3, isNaturalAlwaysAssign: true), - }, resetBeforeApplying: false, isDefault: true), + //new WorkAssignmentRuleset("Vanilla New Pawn", new List() + //{ + // new WorkAssignmentParameters("Top 6", 3, isTopXSkill: 6), + // new WorkAssignmentParameters("Always Assigns", 3, isNaturalAlwaysAssign: true), + //}, resetBeforeApplying: false, isDefault: true), new WorkAssignmentRuleset("BWT Default", new List() { @@ -239,6 +239,11 @@ public enum ColorScheme { RimWorldDefault, Colorblind_Deuteranopia, Colorblind_P // UI mode settings for work tab visibility public static BetterWorkTabSettings.ShowUIMode ShowUIMode_ShowSmallSkillNumbers = BetterWorkTabSettings.ShowUIMode.Unshifted; public static BetterWorkTabSettings.ShowUIMode ShowUIMode_ShowPawnForSkillSquare = BetterWorkTabSettings.ShowUIMode.Shifted; + + public static int maxPriority = 9; + public static int priorityColorPercentage_Green = 10; + public static int priorityColorPercentage_Yellow = 50; + public static int priorityColorPercentage_Tan = 75; } // Contains all configurable settings for Better Work Tab mod @@ -416,11 +421,20 @@ public Color Color_SimilarWorktypeMouseOver public bool mpSyncRulesets = true; public enum MpConflictMode { PlayerPriority, HostPriority, AskPlayer } public MpConflictMode mpConflictMode = MpConflictMode.PlayerPriority; + + // Max Priority Int Settings + public const int MAX_PRIORITY_HARD_LIMIT = 99; + + public int maxPriorityInt = DefaultSettings.maxPriority; + public int priorityColorPercentage_Green = 10; + public int priorityColorPercentage_Yellow = 50; + public int priorityColorPercentage_Tan = 75; + public bool mpShowOtherPlayersHover = DefaultSettings.mpShowOtherPlayersHover; public bool mpAllowOthersToRequestLayout = DefaultSettings.mpAllowOthersToRequestLayout; public bool mpAllowPresenceBroadcast = DefaultSettings.mpAllowPresenceBroadcast; public bool mpShowLinkedIndicator = DefaultSettings.mpShowLinkedIndicator; - + /// /// Unique identifier for this BWT installation in multiplayer /// Auto-generated but user-configurable @@ -652,9 +666,69 @@ public override void ExposeData() Scribe_Values.Look(ref cjkVerticalKerning, "cjkVerticalKerning", 0.75f); Scribe_Values.Look(ref angledHeaderColor, "angledHeaderColor", DefaultSettings.Color_AngledHeaderText); Scribe_Values.Look(ref autoEnableManualPriorities, "autoEnableManualPriorities", DefaultSettings.autoEnableManualPriorities); + Scribe_Values.Look(ref maxPriorityInt, "maxPriorityInt", DefaultSettings.maxPriority); + Scribe_Values.Look(ref priorityColorPercentage_Green, "priorityColorPercentage_Green", DefaultSettings.priorityColorPercentage_Green); + Scribe_Values.Look(ref priorityColorPercentage_Yellow , "priorityColorPercentage_Yellow", DefaultSettings.priorityColorPercentage_Yellow ); + Scribe_Values.Look(ref priorityColorPercentage_Tan , "priorityColorPercentage_Tan", DefaultSettings.priorityColorPercentage_Tan ); if (hiddenWorktypes == null) hiddenWorktypes = new List(); + // Colors + Scribe_Values.Look(ref Color_CursorHighlight, "Color_CursorHighlight", DefaultSettings.Color_CursorHighlight); + Scribe_Values.Look(ref Color_FloatMenuHighlight, "Color_FloatMenuHighlight", DefaultSettings.Color_FloatMenuHighlight); + Scribe_Values.Look(ref Color_CustomMouseHighlight, "Color_CustomMouseHighlight", DefaultSettings.Color_CustomMouseHighlight); + Scribe_Values.Look(ref Color_CustomSimilarWorktypeHighlight, "Color_CustomSimilarWorktypeHighlight", DefaultSettings.Color_CustomSimilarWorktypeHighlight); + Scribe_Values.Look(ref Color_IncapableBecauseOfCapacities, "Color_IncapableBecauseOfCapacities", DefaultSettings.Color_IncapableBecauseOfCapacities); + Scribe_Values.Look(ref Color_BestPawnForSkillSquare, "Color_BestPawnForSkillSquare", DefaultSettings.Color_BestPawnForSkillSquare); + Scribe_Values.Look(ref Color_VeryLowSkill, "Color_VeryLowSkill", DefaultSettings.Color_VeryLowSkill); + Scribe_Values.Look(ref Color_LowSkill, "Color_LowSkill", DefaultSettings.Color_LowSkill); + Scribe_Values.Look(ref Color_GoodLowSkill, "Color_GoodLowSkill", DefaultSettings.Color_GoodLowSkill); + Scribe_Values.Look(ref Color_ExcellentSkill, "Color_ExcellentSkill", DefaultSettings.Color_ExcellentSkill); + Scribe_Values.Look(ref Color_RowHoverHighlight, "Color_RowHoverHighlight", DefaultSettings.Color_RowHoverHighlight); + Scribe_Values.Look(ref Color_ColumnHoverHighlight, "Color_ColumnHoverHighlight", DefaultSettings.Color_ColumnHoverHighlight); + Scribe_Values.Look(ref Color_SelectedPawnHighlight, "Color_SelectedPawnHighlight", DefaultSettings.Color_SelectedPawnHighlight); + Scribe_Values.Look(ref Color_HeaderText, "Color_HeaderText", DefaultSettings.Color_HeaderText); + Scribe_Values.Look(ref Color_DividerText, "Color_DividerText", DefaultSettings.Color_DividerText); + Scribe_Values.Look(ref Color_Borders, "Color_Borders", DefaultSettings.Color_Borders); + + // UI modes + Scribe_Values.Look(ref ShowUIMode_ShowSmallSkillNumbers, "ShowUIMode_ShowSmallSkillNumbers", DefaultSettings.ShowUIMode_ShowSmallSkillNumbers); + Scribe_Values.Look(ref ShowUIMode_ShowPawnForSkillSquare, "ShowUIMode_ShowPawnForSkillSquare", DefaultSettings.ShowUIMode_ShowPawnForSkillSquare); + Scribe_Values.Look(ref hoverEffectScope, "hoverEffectScope", DefaultSettings.hoverEffectScope); + + // Future behavior templates + Scribe_Values.Look(ref confirmRulesetApplication, "confirmRulesetApplication", DefaultSettings.confirmRulesetApplication); + Scribe_Values.Look(ref dragStartThreshold, "dragStartThreshold", DefaultSettings.dragStartThreshold); + Scribe_Values.Look(ref scrollSpeed, "scrollSpeed", DefaultSettings.scrollSpeed); + Scribe_Values.Look(ref showAutoAssignConfirmation, "showAutoAssignConfirmation", DefaultSettings.showAutoAssignConfirmation); + Scribe_Values.Look(ref resetWorkBeforeAutoAssign, "resetWorkBeforeAutoAssign", DefaultSettings.resetWorkBeforeAutoAssign); + Scribe_Values.Look(ref showAutoAssignVisualFeedback, "showAutoAssignVisualFeedback", DefaultSettings.showAutoAssignVisualFeedback); + Scribe_Values.Look(ref defaultAutoAssignRuleset, "defaultAutoAssignRuleset", "BWT Default"); + Scribe_Values.Look(ref showWorkloadButtonFooter, "showWorkloadButtonFooter", DefaultSettings.showWorkloadButtonFooter); + Scribe_Values.Look(ref enableWorkloadSaving, "enableWorkloadSaving", DefaultSettings.enableWorkloadSaving); + Scribe_Values.Look(ref enableWorkloadLoading, "enableWorkloadLoading", DefaultSettings.enableWorkloadLoading); + Scribe_Values.Look(ref persistDividersInWorkloads, "persistDividersInWorkloads", DefaultSettings.persistDividersInWorkloads); + Scribe_Values.Look(ref alwaysShowConditionEditors, "alwaysShowConditionEditors", DefaultSettings.alwaysShowConditionEditors); + Scribe_Values.Look(ref cacheBedCounts, "cacheBedCounts", true); + Scribe_Values.Look(ref cacheSkillLevels, "cacheSkillLevels", true); + Scribe_Values.Look(ref cacheRowDescriptors, "cacheRowDescriptors", true); + Scribe_Values.Look(ref cacheIncapabilityChecks, "cacheIncapabilityChecks", true); + Scribe_Values.Look(ref useElementPooling, "useElementPooling", true); + Scribe_Values.Look(ref viewportCulling, "viewportCulling", true); + Scribe_Values.Look(ref enableProfiler, "enableProfiler", false); + Scribe_Values.Look(ref logDebugToFile, "logDebugToFile", false); + Scribe_Values.Look(ref mpSyncColumnOrder, "mpSyncColumnOrder", true); + Scribe_Values.Look(ref mpSyncWorkloads, "mpSyncWorkloads", true); + Scribe_Values.Look(ref mpSyncRulesets, "mpSyncRulesets", true); + Scribe_Values.Look(ref mpConflictMode, "mpConflictMode", MpConflictMode.PlayerPriority); + Scribe_Values.Look(ref rulesetViewMode, "rulesetViewMode", RulesetViewMode.Regular); + + //Scribe_Values.Look(ref maxPriorityInt, "maxPriorityInt", 4); + + // Divider settings + Scribe_Values.Look(ref dividerHeight, "dividerHeight", DefaultSettings.dividerHeight); + + // Load rulesets from save file Scribe_Collections.Look(ref SavedRulesets, "SavedRulesets", LookMode.Deep); // Reinitialize rulesets after load (restores defaults if missing) diff --git a/Source/Features/Patches/CustomWorkBoxDrawer.cs b/Source/Features/Patches/CustomWorkBoxDrawer.cs index e9a8668..b36b759 100644 --- a/Source/Features/Patches/CustomWorkBoxDrawer.cs +++ b/Source/Features/Patches/CustomWorkBoxDrawer.cs @@ -1,3 +1,4 @@ +using Better_Work_Tab.Features.RaisedPriorityMaximum; using RimWorld; using UnityEngine; using Verse; @@ -8,6 +9,12 @@ namespace Better_Work_Tab.Patches // Custom work box drawer that preserves all vanilla visuals except priority number. public static class CustomWorkBoxDrawer { + private const float CompactPriorityPaddingX = 1f; + private const float CompactPriorityPaddingY = -1f; + private const float CompactPriorityHeight = 12f; + private const float CompactPriorityBaseWidth = 14f; + private const float CompactPriorityExtraWidthPerDigit = 6f; + /// /// Draws a work box with vanilla visuals (background, passion flames, incapable tint) /// but WITHOUT the priority number or click handling. @@ -48,5 +55,42 @@ public static void DrawWorkBoxForSkillOverlay(float x, float y, Pawn p, WorkType } } + + /// + /// Draws a compact priority label for custom work cell views. + /// + public static void DrawCompactPriority(Rect cellRect, int priority) + { + if (priority <= 0) + { + return; + } + + string label = priority.ToString(); + float width = CompactPriorityBaseWidth + Mathf.Max(0, label.Length - 1) * CompactPriorityExtraWidthPerDigit; + Rect labelRect = new Rect( + cellRect.x + CompactPriorityPaddingX, + cellRect.y + CompactPriorityPaddingY, + width, + CompactPriorityHeight); + + DrawPriorityLabel(labelRect, label, priority, GameFont.Tiny, TextAnchor.MiddleCenter); + } + + private static void DrawPriorityLabel(Rect labelRect, string label, int priority, GameFont font, TextAnchor anchor) + { + var oldFont = Text.Font; + var oldAnchor = Text.Anchor; + var oldColor = GUI.color; + + Text.Font = font; + Text.Anchor = anchor; + GUI.color = MaxPriorityLogic.GetPriorityColor(priority); + Widgets.Label(labelRect, label); + + GUI.color = oldColor; + Text.Font = oldFont; + Text.Anchor = oldAnchor; + } } -} \ No newline at end of file +} diff --git a/Source/Features/Patches/MaxPriorityPatches.cs b/Source/Features/Patches/MaxPriorityPatches.cs new file mode 100644 index 0000000..8d35af8 --- /dev/null +++ b/Source/Features/Patches/MaxPriorityPatches.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; +using ModAPI.Harmony; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Better_Work_Tab.Features.RaisedPriorityMaximum +{ + /// + /// Shared method handles used by the max-priority patches. + /// + internal static class PriorityIl + { + internal static readonly MethodInfo GetPriority = AccessTools.Method( + typeof(Pawn_WorkSettings), + nameof(Pawn_WorkSettings.GetPriority), + new[] { typeof(WorkTypeDef) }); + + internal static readonly MethodInfo GetTooltipPriority = AccessTools.Method( + typeof(MaxPriorityLogic), + nameof(MaxPriorityLogic.GetTooltipPriority), + new[] { typeof(Pawn_WorkSettings), typeof(WorkTypeDef) }); + + internal static readonly MethodInfo GetMaxPriority = AccessTools.Method( + typeof(MaxPriorityLogic), + nameof(MaxPriorityLogic.GetMaxPriority)); + } + + /// + /// Centralized rules for extended manual priorities. + /// + internal static class MaxPriorityLogic + { + /// + /// Returns the configured upper bound for manual priorities. + /// + internal static int GetMaxPriority() + { + return Math.Max(1, BetterWorkTabMod.Settings.maxPriorityInt); + } + + /// + /// Returns the default priority used when enabling a work type outside manual priorities. + /// Uses vanilla's normal priority as a baseline, clamped to the configured range. + /// + internal static int GetDefaultEnabledPriority() + { + return Mathf.Clamp(3, 1, GetMaxPriority()); + } + + /// + /// Maps extended priorities back into RimWorld's tooltip display range. + /// + internal static int MapPriorityToVanillaDisplay(int priority) + { + if (priority <= 0) + { + return 0; + } + + int maxPriority = GetMaxPriority(); + if (maxPriority <= 1) + { + return 1; + } + + return Mathf.Clamp((int)Math.Round(Spine.Utils.SpineUtils.Remap(priority, 1, maxPriority, 1, 4)), 1, 4); + } + + /// + /// Supplies the tooltip priority after remapping it into the vanilla display range. + /// + internal static int GetTooltipPriority(Pawn_WorkSettings workSettings, WorkTypeDef workType) + { + if (workSettings == null || workType == null) + { + return 0; + } + + return MapPriorityToVanillaDisplay(workSettings.GetPriority(workType)); + } + + /// + /// Returns the color used for a manual priority value. + /// + internal static Color GetPriorityColor(int priority) + { + if (priority <= 0) + { + return Color.grey; + } + + int percentage = (int)(((float)priority / GetMaxPriority()) * 100f); + + if (percentage < BetterWorkTabMod.Settings.priorityColorPercentage_Green) + { + return new Color(0f, 1f, 0f); + } + + if (percentage < BetterWorkTabMod.Settings.priorityColorPercentage_Yellow) + { + return new Color(1f, 0.9f, 0.5f); + } + + if (percentage < BetterWorkTabMod.Settings.priorityColorPercentage_Tan) + { + return new Color(0.8f, 0.7f, 0.5f); + } + + return new Color(0.74f, 0.74f, 0.74f); + } + } + + /// + /// Locates the specific IL instructions that enforce RimWorld's vanilla max priority. + /// + internal static class PriorityTranspilerPatterns + { + /// + /// Finds the constant used by RimWorld's decrement wraparound path. + /// The matched sequence is: + /// GetPriority, subtract 1, store to a local, test that local against 0, and if it is negative load 4. + /// The returned index is the final 4 load, which is the instruction replaced with GetMaxPriority(). + /// + internal static int FindPriorityWrapUnderflowIndex(List codes, int startIndex) + { + return FindPattern( + codes, + startIndex, + (list, i) => i + 7 < list.Count && + list[i].Calls(PriorityIl.GetPriority) && + list[i + 1].LoadsConstant(1) && + list[i + 2].opcode == OpCodes.Sub && + IsStoreLocal(list[i + 3]) && + IsLoadLocal(list[i + 4]) && + list[i + 5].LoadsConstant(0) && + IsBranch(list[i + 6], OpCodes.Bge, OpCodes.Bge_S) && + list[i + 7].LoadsConstant(4), + i => i + 7); + } + + /// + /// Finds the constant used by RimWorld's increment wraparound path. + /// The matched sequence is: + /// GetPriority, add 1, store to a local, compare that local against 4, and if it exceeds the limit load 0. + /// The returned index is the comparison's 4 load, which is the instruction replaced with GetMaxPriority(). + /// + internal static int FindPriorityWrapOverflowIndex(List codes, int startIndex) + { + return FindPattern( + codes, + startIndex, + (list, i) => i + 7 < list.Count && + list[i].Calls(PriorityIl.GetPriority) && + list[i + 1].LoadsConstant(1) && + list[i + 2].opcode == OpCodes.Add && + IsStoreLocal(list[i + 3]) && + IsLoadLocal(list[i + 4]) && + list[i + 5].LoadsConstant(4) && + IsBranch(list[i + 6], OpCodes.Ble, OpCodes.Ble_S) && + list[i + 7].LoadsConstant(0), + i => i + 5); + } + + /// + /// Finds the upper-bound check inside Pawn_WorkSettings.SetPriority. + /// + internal static int FindSetPriorityUpperBoundIndex(List codes) + { + return FindPattern( + codes, + 0, + (list, i) => i + 5 < list.Count && + IsLoadArgument(list[i], 2) && + list[i + 1].LoadsConstant(0) && + IsBranch(list[i + 2], OpCodes.Blt, OpCodes.Blt_S) && + IsLoadArgument(list[i + 3], 2) && + list[i + 4].LoadsConstant(4) && + IsBranch(list[i + 5], OpCodes.Ble, OpCodes.Ble_S), + i => i + 4); + } + + /// + /// Replaces a vanilla constant load with a call that returns the configured max priority. + /// + internal static void ReplaceWithMaxPriorityCall(List codes, int index) + { + codes[index] = new CodeInstruction(OpCodes.Call, PriorityIl.GetMaxPriority); + } + + private static int FindPattern(List codes, int startIndex, Func, int, bool> predicate, Func resultSelector) + { + for (int i = Math.Max(0, startIndex); i < codes.Count; i++) + { + if (predicate(codes, i)) + { + return resultSelector(i); + } + } + + return -1; + } + + private static bool IsBranch(CodeInstruction instruction, OpCode longForm, OpCode shortForm) + { + return instruction.opcode == longForm || instruction.opcode == shortForm; + } + + private static bool IsLoadLocal(CodeInstruction instruction) + { + return instruction.opcode.Name.StartsWith("ldloc", StringComparison.Ordinal); + } + + private static bool IsStoreLocal(CodeInstruction instruction) + { + return instruction.opcode.Name.StartsWith("stloc", StringComparison.Ordinal); + } + + private static bool IsLoadArgument(CodeInstruction instruction, int argumentIndex) + { + return argumentIndex switch + { + 0 => instruction.opcode == OpCodes.Ldarg_0, + 1 => instruction.opcode == OpCodes.Ldarg_1, + 2 => instruction.opcode == OpCodes.Ldarg_2 || + (instruction.opcode == OpCodes.Ldarg_S && + ((instruction.operand is byte byteIndex && byteIndex == 2) || + (instruction.operand is ushort shortIndex && shortIndex == 2))), + 3 => instruction.opcode == OpCodes.Ldarg_3, + _ => false + }; + } + } + + [HarmonyPatch(typeof(WidgetsWork), nameof(WidgetsWork.ColorOfPriority))] + internal static class Patch_WidgetsWork_ColorOfPriority + { + /// + /// Extends RimWorld's priority color calculation beyond the vanilla 1..4 range. + /// + [HarmonyPostfix] + private static void Postfix(ref Color __result, int prio) + { + __result = MaxPriorityLogic.GetPriorityColor(prio); + } + } + + [HarmonyPatch(typeof(WidgetsWork), nameof(WidgetsWork.TipForPawnWorker))] + internal static class Patch_WidgetsWork_TipForPawnWorker + { + /// + /// Redirects the tooltip priority lookup so tooltips keep using RimWorld's translated + /// 1..4 labels even when the stored priority exceeds 4. + /// + [HarmonyTranspiler] + private static IEnumerable Transpiler(IEnumerable instructions, MethodBase original) + { + return FluentTranspiler.Execute(instructions, original, null, t => + { + t.MatchCall(PriorityIl.GetPriority) + .AssertValid() + .ReplaceWithCall(typeof(MaxPriorityLogic), nameof(MaxPriorityLogic.GetTooltipPriority), new[] { typeof(Pawn_WorkSettings), typeof(WorkTypeDef) }); + }); + } + } + + [HarmonyPatch(typeof(WidgetsWork), nameof(WidgetsWork.DrawWorkBoxFor))] + internal static class Patch_WidgetsWork_DrawWorkBoxFor + { + /// + /// Replaces the two wraparound constants used by the work-cell click handlers. + /// + [HarmonyTranspiler] + private static IEnumerable Transpiler(IEnumerable instructions, MethodBase original) + { + var codes = new List(instructions); + int leftWrapIndex = PriorityTranspilerPatterns.FindPriorityWrapUnderflowIndex(codes, 0); + int rightWrapIndex = PriorityTranspilerPatterns.FindPriorityWrapOverflowIndex(codes, leftWrapIndex + 1); + + if (leftWrapIndex < 0 || rightWrapIndex < 0) + { + throw new InvalidOperationException($"Unable to locate work-box priority wrap checks in {original?.DeclaringType?.Name}.{original?.Name}."); + } + + PriorityTranspilerPatterns.ReplaceWithMaxPriorityCall(codes, leftWrapIndex); + PriorityTranspilerPatterns.ReplaceWithMaxPriorityCall(codes, rightWrapIndex); + return codes; + } + } + + [HarmonyPatch(typeof(PawnColumnWorker_WorkPriority), nameof(PawnColumnWorker_WorkPriority.HeaderClicked))] + internal static class Patch_PawnColumnWorker_WorkPriority_HeaderClicked + { + /// + /// Replaces the two wraparound constants used by the header bulk-edit controls. + /// + [HarmonyTranspiler] + private static IEnumerable Transpiler(IEnumerable instructions, MethodBase original) + { + var codes = new List(instructions); + int leftWrapIndex = PriorityTranspilerPatterns.FindPriorityWrapUnderflowIndex(codes, 0); + int rightWrapIndex = PriorityTranspilerPatterns.FindPriorityWrapOverflowIndex(codes, leftWrapIndex + 1); + + if (leftWrapIndex < 0 || rightWrapIndex < 0) + { + throw new InvalidOperationException($"Unable to locate header priority wrap checks in {original?.DeclaringType?.Name}.{original?.Name}."); + } + + PriorityTranspilerPatterns.ReplaceWithMaxPriorityCall(codes, leftWrapIndex); + PriorityTranspilerPatterns.ReplaceWithMaxPriorityCall(codes, rightWrapIndex); + return codes; + } + } + + [HarmonyPatch(typeof(Pawn_WorkSettings), nameof(Pawn_WorkSettings.SetPriority))] + internal static class Patch_Pawn_WorkSettings_SetPriority + { + /// + /// Replaces RimWorld's validation ceiling so values above 4 remain valid. + /// + [HarmonyTranspiler] + private static IEnumerable Transpiler(IEnumerable instructions, MethodBase original) + { + var codes = new List(instructions); + int upperBoundIndex = PriorityTranspilerPatterns.FindSetPriorityUpperBoundIndex(codes); + + if (upperBoundIndex < 0) + { + throw new InvalidOperationException($"Unable to locate the SetPriority upper-bound check in {original?.DeclaringType?.Name}.{original?.Name}."); + } + + PriorityTranspilerPatterns.ReplaceWithMaxPriorityCall(codes, upperBoundIndex); + return codes; + } + } +} diff --git a/Source/Features/Patches/Patch_Pawn_WorkSettings_SetPriority.cs b/Source/Features/Patches/Patch_Pawn_WorkSettings_SetPriority.cs index 73aba61..2234656 100644 --- a/Source/Features/Patches/Patch_Pawn_WorkSettings_SetPriority.cs +++ b/Source/Features/Patches/Patch_Pawn_WorkSettings_SetPriority.cs @@ -1,6 +1,5 @@ using HarmonyLib; using RimWorld; -using Verse; namespace Better_Work_Tab.Features.Patches { @@ -14,13 +13,11 @@ public static class Patch_Pawn_WorkSettings_SetPriority [HarmonyPrefix] public static bool Prefix() { - // Block priority changes if local player is currently dragging a header if (BetterWorkTabLocalState.IsHeaderDragging) { - return false; // Skip the original method + return false; } - - return true; // Allow the original method to run + return true; } } } diff --git a/Source/Features/Patches/Patch_WorkPriority_DoCell_Unified.cs b/Source/Features/Patches/Patch_WorkPriority_DoCell_Unified.cs index cdbdad7..5606f5a 100644 --- a/Source/Features/Patches/Patch_WorkPriority_DoCell_Unified.cs +++ b/Source/Features/Patches/Patch_WorkPriority_DoCell_Unified.cs @@ -9,6 +9,7 @@ using System.Linq; using UnityEngine; using Verse; +using Verse.Sound; namespace Better_Work_Tab.Patches { @@ -78,7 +79,7 @@ private static Rect GetLabelRect(PawnColumnWorker_WorkPriority worker, Rect head [HarmonyPatch(typeof(PawnColumnWorker_WorkPriority), nameof(PawnColumnWorker_WorkPriority.DoCell))] public static class Patch_WorkPriority_DoCell_Unified { - // === FRAME-LEVEL STATE === + // Frame state private static int _lastCachedFrame = -1; private static bool _cachedShiftHeld = false; private static bool _cachedFeatureEnabled = false; @@ -89,7 +90,7 @@ public static class Patch_WorkPriority_DoCell_Unified private static WorkTypeDef _columnHoveredWorkType; private static int _columnHoveredFrame = -1; - // === CACHES === + // Cached per-frame and per-worktype values private static readonly Dictionary _skillCache = new Dictionary(1024); private static readonly Dictionary _skillCacheTimestamps = new Dictionary(1024); private static readonly Dictionary _incapableCache = new Dictionary(1024); @@ -98,7 +99,7 @@ public static class Patch_WorkPriority_DoCell_Unified private static readonly Dictionary _bestPawnCacheTimestamps = new Dictionary(64); private static readonly Dictionary _colorCache = new Dictionary(21); - // Constants + // Layout and cache settings private const int SkillCacheFrameValidity = 60; private const int IncapableCacheFrameValidity = 120; private const int BestPawnCacheFrameValidity = 60; @@ -142,7 +143,8 @@ public static bool Prefix( return true; WorkTypeDef workType = __instance.def.workType; - if (workType == null) return true; + if (workType == null) + return true; UpdateFrameCache(); @@ -150,38 +152,35 @@ public static bool Prefix( if (BetterWorkTabMod.Settings.enableScrollWheelPriority && Event.current.type == EventType.ScrollWheel && Mouse.IsOver(rect)) { int currentPriority = pawn.workSettings.GetPriority(workType); - int delta = Event.current.delta.y > 0 ? -1 : 1; // Scroll up = increase priority (closer to 1), Scroll down = decrease - // TODO: Will need to update this when custom priorities are added + int delta = Event.current.delta.y > 0 ? -1 : 1; if (Find.PlaySettings.useWorkPriorities) { - // Manual priorities: 1-4, 0 is disabled. - // Note: RimWorld priorities are 1 (Highest) to 4 (Lowest). 0 is Off. - // Scroll Up (delta +1): 0 -> 4 -> 3 -> 2 -> 1 - // Scroll Down (delta -1): 1 -> 2 -> 3 -> 4 -> 0 int nextPriority = currentPriority; - if (delta > 0) // Increase (1 is high, 4 is low) + if (delta > 0) { - if (currentPriority == 0) nextPriority = 4; + if (currentPriority == 0) nextPriority = BetterWorkTabMod.Settings.maxPriorityInt; else if (currentPriority > 1) nextPriority = currentPriority - 1; } - else // Decrease + else { - if (currentPriority == 4) nextPriority = 0; + if (currentPriority == BetterWorkTabMod.Settings.maxPriorityInt) nextPriority = 0; else if (currentPriority > 0) nextPriority = currentPriority + 1; } if (nextPriority != currentPriority) { pawn.workSettings.SetPriority(workType, nextPriority); + SoundDefOf.DragSlider.PlayOneShotOnCamera(); } } else { - // Checkbox mode: 0 or 3 int nextPriority = (currentPriority > 0) ? 0 : 3; if (nextPriority != currentPriority) { pawn.workSettings.SetPriority(workType, nextPriority); + SoundDefOf.DragSlider.PlayOneShotOnCamera(); + } } Event.current.Use(); @@ -201,7 +200,7 @@ public static bool Prefix( return true; if (workType.relevantSkills == null || workType.relevantSkills.Count == 0) - return false; // Skip vanilla drawing if no relevant skills, to draw nothing or custom + return true; // Track column hover state only if hover cell overlay is enabled bool hoveringCell = Mouse.IsOver(rect); @@ -233,7 +232,6 @@ public static bool Prefix( if (columnHovered) { - // For column-wide hover, allow vanilla draw in Standard so priority numbers render across the column. if (_cachedHoverMode == BetterWorkTabSettings.SkillViewHoverMode.Standard) { return true; @@ -241,8 +239,6 @@ public static bool Prefix( return false; } - // If NOT hovering, return FALSE to skip Vanilla. - // Postfix will draw the Big Skill Number and static background. return false; } @@ -267,21 +263,20 @@ public static void Postfix( if (GetIsIncapable(pawn, workType)) return; - if (workType.relevantSkills == null || workType.relevantSkills.Count == 0) - return; - // Draw best pawn outline - this uses the ShowUIMode setting to determine when to show // (can be Always, Shifted, Unshifted, or Never) DrawBestPawnOutlineIfNeeded(__instance, rect, pawn, table, workType); - // === Skill Overlay Section (only when shift is held) === - // Basic feature check: if skill overlay is disabled or shift not held, skip skill overlay if (!_cachedFeatureEnabled || !_cachedShiftHeld) return; if (Patch_WorkPriority_DoHeader_HoverTracker.HoveredHeaderWorkType == workType) return; + if (workType.relevantSkills == null || workType.relevantSkills.Count == 0) + return; + + int priority = pawn.workSettings.GetPriority(workType); int skillLevel = GetSkillLevel(pawn, workType); bool hoveringCell = Mouse.IsOver(rect); @@ -299,35 +294,32 @@ public static void Postfix( bool drawBigSkill = true; bool drawSmallSkill = false; - bool drawSmallPriority = false; bool showTinySkillNumbers = (BetterWorkTabMod.Settings?.enableSkillOverlayFeature ?? false) && ShouldShowUI(BetterWorkTabMod.Settings.ShowUIMode_ShowSmallSkillNumbers, _cachedUiState); - // Apply hover-based transformations only if hover overlay is enabled if (_cachedHoverCellOverlayEnabled && hovering) { if (_cachedHoverMode == BetterWorkTabSettings.SkillViewHoverMode.Standard) { - // Standard mode: hide big skill, show small skill on hover drawBigSkill = false; drawSmallSkill = true; } - else if (_cachedHoverMode == BetterWorkTabSettings.SkillViewHoverMode.SkillFocused) - { - // SkillFocused mode: keep big skill visible and show priority in the small-number slot - drawSmallPriority = true; - } } - // Always show tiny skill numbers if configured (regardless of hover state) - if (showTinySkillNumbers && !drawSmallPriority) + if (showTinySkillNumbers) { drawSmallSkill = true; } + CustomWorkBoxDrawer.DrawWorkBoxForSkillOverlay(boxXSkill, boxYSkill, pawn, workType, false); + + if (priority > 0) + { + CustomWorkBoxDrawer.DrawCompactPriority(rect, priority); + } + if (drawBigSkill) { - CustomWorkBoxDrawer.DrawWorkBoxForSkillOverlay(boxXSkill, boxYSkill, pawn, workType, false); DrawBigSkillNumber(boxRect, skillLevel); } @@ -335,22 +327,9 @@ public static void Postfix( { DrawSmallSkillNumbers(rect, skillLevel); } - - if (drawSmallPriority) - { - int priority = pawn.workSettings.GetPriority(workType); - if (priority > 0) - { - DrawSmallPriorityNumber(rect, priority); - } - } } - // ... [Rest of the caching and drawing helper methods remain exactly the same] ... - - // ========================================================== - // OPTIMIZED CACHING LOGIC - // ========================================================== + // Caching helpers private static bool GetIsIncapable(Pawn p, WorkTypeDef work) { @@ -527,33 +506,6 @@ private static void DrawSmallSkillNumbers(Rect rect, int level) Text.Anchor = oldAnchor; } - private static void DrawSmallPriorityNumber(Rect rect, int priority) - { - string prioStr = priority.ToString(); - float rightPadding = prioStr.Length >= 2 ? 0f : -3f; - Rect prioRect = new Rect( - rect.xMax - SkillBoxSize - rightPadding, - rect.y + SmallSkillOffsetY, - SkillBoxSize, - SkillBoxSize); - - var oldFont = Text.Font; - var oldAnchor = Text.Anchor; - var oldColor = GUI.color; - - Text.Font = GameFont.Tiny; - Text.Anchor = TextAnchor.MiddleCenter; - - // Use vanilla priority number color (white/gray like vanilla) - GUI.color = new Color(0.9f, 0.9f, 0.9f); - - Widgets.Label(prioRect, priority.ToString()); - - GUI.color = oldColor; - Text.Font = oldFont; - Text.Anchor = oldAnchor; - } - /// /// Draws the best pawn outline if the settings allow it based on ShowUIMode. /// This is called BEFORE the shift check so it can work with Always/Shifted/Unshifted modes. diff --git a/Source/Features/Rules/WorkAssignmentRule.cs b/Source/Features/Rules/WorkAssignmentRule.cs index b5ba360..a004828 100644 --- a/Source/Features/Rules/WorkAssignmentRule.cs +++ b/Source/Features/Rules/WorkAssignmentRule.cs @@ -96,7 +96,7 @@ public bool Apply( // All checks passed – assign the priority pawn.workSettings.SetPriority( assigningWorktype, - Mathf.Clamp(Parameters.Priority, 0, 4) + Mathf.Clamp(Parameters.Priority, 0, Mathf.Max(1, BetterWorkTabMod.Settings?.maxPriorityInt ?? 4)) ); return validationResult.ShouldSkipRemainingPawns; diff --git a/Source/Features/Rules/WorkAssignmentRuleset.cs b/Source/Features/Rules/WorkAssignmentRuleset.cs index fb40abc..0c42669 100644 --- a/Source/Features/Rules/WorkAssignmentRuleset.cs +++ b/Source/Features/Rules/WorkAssignmentRuleset.cs @@ -152,7 +152,11 @@ public void ApplyAutoAssignments() if (eligiblePawns.Count > 0) { - eligiblePawns.RandomElement().workSettings.SetPriority(worktype, rule.Parameters.Priority); + int maxPriority = Mathf.Max(1, BetterWorkTabMod.Settings?.maxPriorityInt ?? 4); + eligiblePawns.RandomElement().workSettings.SetPriority( + worktype, + Mathf.Clamp(rule.Parameters.Priority, 0, maxPriority) + ); } } @@ -195,6 +199,8 @@ public WorkAssignmentRuleset Copy() /// public void EnsurePriorityOrder(int maxPriority = 4) { + maxPriority = Mathf.Max(1, maxPriority <= 0 ? BetterWorkTabMod.Settings?.maxPriorityInt ?? 4 : maxPriority); + if (PriorityOrder == null || PriorityOrder.Count == 0) { PriorityOrder = Enumerable.Range(1, maxPriority).ToList(); diff --git a/Source/Spine/Utils/SpineUtils.cs b/Source/Spine/Utils/SpineUtils.cs new file mode 100644 index 0000000..7f4a815 --- /dev/null +++ b/Source/Spine/Utils/SpineUtils.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Unity.Mathematics; +using UnityEngine; + +namespace Spine.Utils +{ + internal class SpineUtils + { + /// + /// Given a value in range [from1, to1], remap it to the corresponding value in range [from2, to2]. Example: Remap(5, 0, 10, 0, 100) returns 50. + /// + /// + /// + /// + /// + /// + /// + public static float Remap( + float value, + float from1, + float to1, + float from2, + float to2) + { + //https://stackoverflow.com/questions/3451553/value-remapping + return from2 + (value - from1) * (to2 - from2) / (to1 - from1); //math.remap(from1, to1, from2, to2, value); + } + } +} diff --git a/Source/Spine/harmony/HarmonyBootstrap.cs b/Source/Spine/harmony/HarmonyBootstrap.cs new file mode 100644 index 0000000..b523f99 --- /dev/null +++ b/Source/Spine/harmony/HarmonyBootstrap.cs @@ -0,0 +1,304 @@ +using System; +using System.Reflection; +using UnityEngine; +using ModAPI.Core; +using HarmonyLib; +using Verse; + +namespace ModAPI.Harmony +{ + /// + /// Responsible for initializing Harmony and applying patches. + /// Redirects 0Harmony log to MMLog. + /// + [StaticConstructorOnStartup] + public static class HarmonyBootstrap + { + private static bool _installed = false; + private static GameObject _runnerGo; + + static HarmonyBootstrap() + { + // Redirect Harmony's internal trace logs if needed + } + + /// + /// Entry point for mod loader to start patching. + /// If Harmony DLL is not yet loaded by BepInEx or Doorstop, + /// starts a runner to periodically retry. + /// + public static void EnsurePatched() + { + if (_installed) return; + + try + { + // Verify Harmony is available in current domain + var type = typeof(HarmonyLib.Harmony); + if (type == null) + { + MMLog.Write("HarmonyLib not found. Starting retry runner..."); + StartRetryRunner(); + return; + } + + TryPatch(); + } + catch (Exception ex) + { + MMLog.Write("Initial patch check failed: " + ex.Message); + StartRetryRunner(); + } + } + + private static void TryPatch() + { + if (_installed) return; + try + { + var asm = Assembly.GetExecutingAssembly(); + var harmony = new HarmonyLib.Harmony("ShelteredModManager.ModAPI"); + + var opts = new ModAPI.Harmony.HarmonyUtil.PatchOptions + { + AllowDebugPatches = ReadManagerBool("EnableDebugPatches", false), + AllowDangerousPatches = ReadManagerBool("AllowDangerousPatches", false), + AllowStructReturns = ReadManagerBool("AllowStructReturns", false), + OnResult = (obj, reason) => + { + if (reason != null) + { + var mb = obj as MemberInfo; + var name = mb != null ? (mb.DeclaringType != null ? mb.DeclaringType.Name + "." + mb.Name : mb.Name) : (obj?.ToString() ?? ""); + MMLog.WriteDebug($"{name} -> {reason}"); + } + } + }; + + var registryOptions = PatchRegistry.CreateManagerOptions( + opts, + asm.GetName().Name, + key => ReadManagerString(key, null)); + PatchRegistry.ApplyAssembly(harmony, asm, registryOptions); + + // Backward compatibility: patch ShelteredAPI too so Sheltered-specific + // implementations and adapters are activated alongside core ModAPI hooks. + var shelteredAssembly = ResolveShelteredApiAssembly(); + if (shelteredAssembly != null && shelteredAssembly != asm) + { + string location = ""; + try { location = shelteredAssembly.Location; } catch { } + MMLog.WriteInfo("HarmonyBootstrap: applying ShelteredAPI patches from " + + shelteredAssembly.GetName().Name + " v" + shelteredAssembly.GetName().Version + + " @" + location); + var shelteredRegistryOptions = PatchRegistry.CreateManagerOptions( + opts, + shelteredAssembly.GetName().Name, + key => ReadManagerString(key, null)); + PatchRegistry.ApplyAssembly(harmony, shelteredAssembly, shelteredRegistryOptions); + TryInitializeShelteredApiCore(shelteredAssembly); + } + else + { + MMLog.WriteInfo("HarmonyBootstrap: ShelteredAPI assembly not found for patching (or same as ModAPI)."); + } + + LogPatchStatus("SettingsPCPanel.OnControlsButtonPressed", "SettingsPCPanel", "OnControlsButtonPressed"); + LogPatchStatus("SettingsPCPanel.OnControlsButtonPressed_PAD", "SettingsPCPanel", "OnControlsButtonPressed_PAD"); + LogPatchStatus("UIPanelManager.PushPanel(BasePanel)", "UIPanelManager", "PushPanel", "BasePanel"); + LogPatchStatus("PlatformInput_PC.GetButtonDown(InputButton)", "PlatformInput_PC", "GetButtonDown", "PlatformInput+InputButton"); + LogPatchStatus("PlatformInput_PC.GetButtonDown(MenuInputButton)", "PlatformInput_PC", "GetButtonDown", "PlatformInput+MenuInputButton"); + + // Explicitly verify UIPatches was discovered and patched + var uiPatches = asm.GetType("ModAPI.UI.UIPatches"); + if (uiPatches != null) + { + MMLog.WriteDebug("Discovered UIPatches for verification."); + } + + _installed = true; + + MMLog.WriteDebug("ModAPI hooks patched"); + if (_runnerGo != null) UnityEngine.Object.Destroy(_runnerGo); + } + catch (Exception ex) + { + MMLog.Write("patch attempt failed: " + ex.Message); + } + } + + public static bool ReadManagerBool(string key, bool fallback) + { + string s = ReadManagerString(key, null); + if (s == null) return fallback; + + bool b; + if (bool.TryParse(s, out b)) return b; + + var lower = s.ToLowerInvariant(); + if (lower == "1" || lower == "yes" || lower == "y" || lower == "on" || lower == "true") return true; + if (lower == "0" || lower == "no" || lower == "n" || lower == "off" || lower == "false") return false; + + return fallback; + } + + public static string ReadManagerString(string key, string fallback) + { + try + { + string gameRoot = System.IO.Path.GetFullPath(System.IO.Path.Combine(Application.dataPath, "..")); + string smmDir = System.IO.Path.Combine(gameRoot, "SMM"); + string binDir = System.IO.Path.Combine(smmDir, "bin"); + var ini = System.IO.Path.Combine(binDir, "mod_manager.ini"); + if (!System.IO.File.Exists(ini)) return fallback; + foreach (var raw in System.IO.File.ReadAllLines(ini)) + { + if (string.IsNullOrEmpty(raw)) continue; + var line = raw.Trim(); + if (line.StartsWith("#") || line.StartsWith(";") || line.StartsWith("[")) continue; + var idx = line.IndexOf('='); if (idx <= 0) continue; + var k = line.Substring(0, idx).Trim(); + var v = line.Substring(idx + 1).Trim(); + if (k.Equals(key, StringComparison.OrdinalIgnoreCase)) return v; + } + } + catch { } + return fallback; + } + + public static int ReadManagerInt(string key, int fallback) + { + string s = ReadManagerString(key, null); + if (s != null && int.TryParse(s, out int val)) return val; + return fallback; + } + + private static Assembly ResolveShelteredApiAssembly() + { + // Prefer type-based resolve first so we bind to the already-loaded instance. + // This avoids duplicate loads and preserves compatibility for mods referencing both APIs. + try + { + var shelteredType = Type.GetType("ShelteredAPI.Core.ShelteredModManagerBase, ShelteredAPI", false); + if (shelteredType != null) + return shelteredType.Assembly; + } + catch { } + + try + { + return Assembly.Load("ShelteredAPI"); + } + catch + { + return null; + } + } + + private static void StartRetryRunner() + { + if (_runnerGo != null) return; + _runnerGo = new GameObject("HarmonyRetryRunner"); + UnityEngine.Object.DontDestroyOnLoad(_runnerGo); + _runnerGo.AddComponent(); + } + + private static void TryInitializeShelteredApiCore(Assembly shelteredAssembly) + { + if (shelteredAssembly == null) return; + + try + { + var bootstrapType = shelteredAssembly.GetType("ShelteredAPI.Core.ShelteredApiRuntimeBootstrap", false); + if (bootstrapType == null) + { + MMLog.WriteInfo("HarmonyBootstrap: ShelteredAPI runtime bootstrap type not found."); + return; + } + + var init = bootstrapType.GetMethod("Initialize", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + if (init == null) + { + MMLog.WriteInfo("HarmonyBootstrap: ShelteredAPI runtime bootstrap Initialize method not found."); + return; + } + + init.Invoke(null, null); + MMLog.WriteInfo("HarmonyBootstrap: ShelteredAPI runtime bootstrap initialized."); + } + catch (Exception ex) + { + MMLog.WriteWarning("HarmonyBootstrap: ShelteredAPI runtime bootstrap failed: " + ex.Message); + } + } + + private static void LogPatchStatus(string label, string typeName, string methodName, string parameterTypeName = null) + { + try + { + Type targetType = AccessTools.TypeByName(typeName); + if (targetType == null) + { + MMLog.WriteInfo("HarmonyBootstrap: patch verify target type missing: " + label); + return; + } + + MethodBase target = null; + if (string.IsNullOrEmpty(parameterTypeName)) + { + target = AccessTools.Method(targetType, methodName); + } + else + { + Type parameterType = AccessTools.TypeByName(parameterTypeName); + if (parameterType == null) + { + MMLog.WriteInfo("HarmonyBootstrap: patch verify parameter type missing for " + label + ": " + parameterTypeName); + return; + } + target = AccessTools.Method(targetType, methodName, new[] { parameterType }); + } + + if (target == null) + { + MMLog.WriteInfo("HarmonyBootstrap: patch verify target method missing: " + label); + return; + } + + var info = HarmonyLib.Harmony.GetPatchInfo(target); + bool patched = info != null && ( + (info.Prefixes != null && info.Prefixes.Count > 0) || + (info.Postfixes != null && info.Postfixes.Count > 0) || + (info.Transpilers != null && info.Transpilers.Count > 0)); + + MMLog.WriteInfo("HarmonyBootstrap: patch verify " + label + " => " + (patched ? "patched" : "not patched")); + } + catch (Exception ex) + { + MMLog.WriteWarning("HarmonyBootstrap: patch verify failed for " + label + ": " + ex.Message); + } + } + } + + internal class HarmonyRetryRunner : MonoBehaviour + { + private float _timer; + private int _attempts; + private const int MaxAttempts = 60; + + private void Update() + { + _timer += Time.unscaledDeltaTime; + if (_timer < 0.5f) return; + _timer = 0f; + _attempts++; + MMLog.WriteDebug($"attempt {_attempts}"); + HarmonyBootstrap.EnsurePatched(); + if (_attempts >= MaxAttempts) + { + MMLog.Write("giving up waiting for 0Harmony"); + Destroy(this.gameObject); + } + } + } +} diff --git a/Source/Spine/harmony/HarmonyCompat.cs b/Source/Spine/harmony/HarmonyCompat.cs new file mode 100644 index 0000000..8af4e83 --- /dev/null +++ b/Source/Spine/harmony/HarmonyCompat.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; +using Verse; + +namespace ModAPI.Core +{ + /// + /// RimWorld-backed logging shim for the imported Harmony utilities. + /// Preserves the old MMLog surface so the port stays localized. + /// + public static class MMLog + { + private const string Prefix = "[Spine.Harmony] "; + private static readonly HashSet WarnedKeys = new HashSet(StringComparer.Ordinal); + + public static void Write(string message) + { + Log.Message(Prefix + (message ?? string.Empty)); + } + + public static void WriteInfo(string message) + { + Log.Message(Prefix + (message ?? string.Empty)); + } + + public static void WriteWarning(string message) + { + Log.Warning(Prefix + (message ?? string.Empty)); + } + + public static void WriteError(string message) + { + Log.Error(Prefix + (message ?? string.Empty)); + } + + public static void WriteDebug(string message) + { + if (!ShouldLogDebug()) + { + return; + } + + Log.Message(Prefix + "[Debug] " + (message ?? string.Empty)); + } + + public static void WarnOnce(string key, string message) + { + if (string.IsNullOrEmpty(key)) + { + WriteWarning(message); + return; + } + + lock (WarnedKeys) + { + if (!WarnedKeys.Add(key)) + { + return; + } + } + + Log.Warning(Prefix + (message ?? string.Empty)); + } + + private static bool ShouldLogDebug() + { + if (Prefs.DevMode) + { + return true; + } + + try + { + return Better_Work_Tab.BetterWorkTabMod.Settings?.enableDebugLogging ?? false; + } + catch + { + return false; + } + } + } + + /// + /// Compatibility preference surface for the imported transpiler framework. + /// Defaults are conservative and can later be wired to real settings. + /// + public static class ModPrefs + { + public static bool DebugTranspilers => Prefs.DevMode || TryGetBwtDebug(); + public static bool TranspilerSafeMode => true; + public static bool TranspilerForcePreserveInstructionCount => false; + public static bool TranspilerFailFastCritical => false; + public static bool TranspilerCooperativeStrictBuild => false; + public static bool TranspilerQuarantineOnFailure => false; + public static bool TranspilerLogValidationWarnings => false; + public static bool TranspilerWarnOnVirtualCallMismatch => false; + public static bool TranspilerWarnOnExceptionHandlerMethods => false; + + private static bool TryGetBwtDebug() + { + return TryGetBwtSetting(s => s.enableDebugLogging, false); + } + + private static T TryGetBwtSetting(Func selector, T fallback) + { + if (selector == null) + { + return fallback; + } + + try + { + var settings = Better_Work_Tab.BetterWorkTabMod.Settings; + return settings != null ? selector(settings) : fallback; + } + catch + { + return fallback; + } + } + } +} + +namespace ModAPI.Reflection +{ + /// + /// Minimal reflection helper shim used by HarmonyHelper.SafeInvoke. + /// + public static class Safe + { + public static bool TryCall(object instance, string methodName, out T result, params object[] args) + { + result = default(T); + + if (instance == null || string.IsNullOrEmpty(methodName)) + { + return false; + } + + try + { + var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + var method = instance.GetType().GetMethod(methodName, flags); + if (method == null) + { + return false; + } + + object value = method.Invoke(instance, args); + if (value is T typed) + { + result = typed; + return true; + } + + if (value == null) + { + result = default(T); + return true; + } + + result = (T)value; + return true; + } + catch + { + return false; + } + } + } +} + +namespace ModAPI.Spine +{ + public interface IPluginSettings + { + bool GetBool(string key, bool fallback); + } + + public interface IPluginLog + { + void Info(string message); + } + + public interface IPluginContext + { + IPluginSettings Settings { get; } + IPluginLog Log { get; } + } + + /// + /// Placeholder save contract for imported helpers. RimWorld does not use the Sheltered save manager. + /// + public interface ISaveable + { + } + + /// + /// Minimal compatibility stub so imported generic helpers still compile. + /// + public sealed class SaveManager : MonoBehaviour + { + public static SaveManager instance => null; + + public bool HasBeenLoaded(ISaveable saveable) + { + return true; + } + } +} diff --git a/Source/Spine/harmony/HarmonyHelper.cs b/Source/Spine/harmony/HarmonyHelper.cs new file mode 100644 index 0000000..4598c65 --- /dev/null +++ b/Source/Spine/harmony/HarmonyHelper.cs @@ -0,0 +1,216 @@ +using System; +using System.Reflection; +using HarmonyLib; +using ModAPI.Core; +using ModAPI.Reflection; +using System.Linq; +using System.Collections.Generic; + +namespace ModAPI.Harmony +{ + /// + /// Provides safe, high-level utilities for Harmony patching and reflection. + /// Reduces boilerplate and ensures mods don't crash the game due to missing methods. + /// + public static class HarmonyHelper + { + private static readonly HarmonyLib.Harmony _harmony = new HarmonyLib.Harmony("com.modapi.shared"); + + /// + /// Adds a Prefix patch to a method. + /// + /// The target class containing the method to patch. + /// The name of the method to patch. + /// The class containing the patch method. + /// The name of the prefix method. + public static void AddPrefix(string methodName, Type patchType, string patchMethodName) + { + var prefix = new HarmonyMethod(AccessTools.Method(patchType, patchMethodName)); + TryPatchMethod(typeof(T), methodName, prefix: prefix); + } + + /// + /// Adds a Postfix patch to a method. + /// + /// The target class containing the method to patch. + /// The name of the method to patch. + /// The class containing the patch method. + /// The name of the postfix method. + public static void AddPostfix(string methodName, Type patchType, string patchMethodName) + { + var postfix = new HarmonyMethod(AccessTools.Method(patchType, patchMethodName)); + TryPatchMethod(typeof(T), methodName, postfix: postfix); + } + + /// + /// Attempts to patch a method with provided Harmony methods. + /// Logs errors if the target method is not found. + /// + public static bool TryPatchMethod(Type targetType, string methodName, + HarmonyMethod prefix = null, HarmonyMethod postfix = null, + HarmonyMethod transpiler = null) + { + try + { + if (targetType == null) + { + MMLog.WriteError("[HarmonyHelper] Target type is null."); + return false; + } + + var original = AccessTools.Method(targetType, methodName); + if (original == null) + { + MMLog.WriteError($"[HarmonyHelper] Target method '{methodName}' not found on type '{targetType.FullName}'."); + return false; + } + + _harmony.Patch(original, prefix, postfix, transpiler); + + string patchType = prefix != null ? "Prefix" : (postfix != null ? "Postfix" : "Transpiler"); + MMLog.Write($"[HarmonyHelper] Success: Applied {patchType} to {targetType.Name}.{methodName}"); + return true; + } + catch (Exception ex) + { + MMLog.WriteError($"[HarmonyHelper] Critical error patching {targetType?.Name}.{methodName}: {ex.Message}"); + return false; + } + } + + /// + /// Safely invokes a method via reflection, returning the result or default(T) on failure. + /// Wraps ModAPI.Reflection.Safe.TryCall. + /// + public static T SafeInvoke(object instance, string methodName, params object[] args) + { + T result; + if (Safe.TryCall(instance, methodName, out result, args)) + { + return result; + } + return default(T); + } + + /// + /// Patch ALL overloads of a method by name. + /// Useful for methods like Foo(), Foo(int), Foo(string), etc. + /// + /// The Harmony instance to use for patching. + /// Target type containing the method. + /// Name of the method to patch. + /// Optional filter: ONLY patch overloads matching these param types. Use null for no filter (all overloads). + /// If true, skip generic methods. Default: false. + /// Prefix patch. + /// Postfix patch. + /// Transpiler patch. + /// + /// Only patches methods declared on the specified type. + /// Does NOT patch inherited methods or base class overloads. + /// + public static void PatchAllOverloads( + HarmonyLib.Harmony harmony, + Type type, + string methodName, + Type[] parameterTypes = null, + bool ignoreGenerics = false, + HarmonyMethod prefix = null, + HarmonyMethod postfix = null, + HarmonyMethod transpiler = null) + { + if (harmony == null || type == null || string.IsNullOrEmpty(methodName)) + throw new ArgumentNullException(); + + if (prefix == null && postfix == null && transpiler == null) + { + MMLog.WriteWarning("[HarmonyHelper.PatchAllOverloads] No patches provided"); + return; + } + + var methods = type.GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static); + + var matches = methods.Where(m => + m.Name == methodName && + (!ignoreGenerics || !m.IsGenericMethodDefinition)) + .ToList(); + + // Optional parameter type filtering + if (parameterTypes != null) + { + matches = matches.Where(m => + { + var mparams = m.GetParameters().Select(p => p.ParameterType).ToArray(); + if (mparams.Length != parameterTypes.Length) return false; + + for (int i = 0; i < parameterTypes.Length; i++) + { + if (parameterTypes[i] != mparams[i]) return false; + } + return true; + }).ToList(); + } + + if (matches.Count == 0) + { + MMLog.WriteWarning( + $"[HarmonyHelper.PatchAllOverloads] No overloads found for " + + $"{type.FullName}.{methodName}"); + return; + } + + int successCount = 0; + var failedSignatures = new List(); + + foreach (var method in matches) + { + var sig = method.GetParameters().Length == 0 + ? "()" + : $"({string.Join(", ", method.GetParameters().Select(p => p.ParameterType.Name).ToArray())})"; + + try + { + harmony.Patch(method, prefix, postfix, transpiler); + successCount++; + + MMLog.WriteDebug( + $"[HarmonyHelper] Patched overload {type.Name}.{methodName}{sig}"); + } + catch (Exception ex) + { + failedSignatures.Add($"{methodName}{sig}"); + MMLog.WriteError( + $"[HarmonyHelper] Failed to patch {type.Name}.{methodName}{sig}: " + + $"{ex.Message}"); + } + } + + if (failedSignatures.Count > 0) + { + MMLog.WriteWarning( + $"[HarmonyHelper] {failedSignatures.Count} overload(s) failed: " + + string.Join(", ", failedSignatures.ToArray())); + } + + MMLog.Write( + $"[HarmonyHelper] PatchAllOverloads completed: {successCount}/{matches.Count} " + + $"overloads patched for {type.Name}.{methodName}"); + } + + /// + /// Patch all parameterless overloads (no arguments). + /// Convenience wrapper for PatchAllOverloads with Type.EmptyTypes. + /// + public static void PatchAllParameterlessOverloads( + HarmonyLib.Harmony harmony, + Type type, + string methodName, + HarmonyMethod prefix = null, + HarmonyMethod postfix = null, + HarmonyMethod transpiler = null) + { + PatchAllOverloads(harmony, type, methodName, Type.EmptyTypes, false, prefix, postfix, transpiler); + } + } +} diff --git a/Source/Spine/harmony/HarmonyUtil.cs b/Source/Spine/harmony/HarmonyUtil.cs new file mode 100644 index 0000000..b2f3014 --- /dev/null +++ b/Source/Spine/harmony/HarmonyUtil.cs @@ -0,0 +1,389 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using UnityEngine; +using ModAPI.Core; +using ModAPI.Spine; + +namespace ModAPI.Harmony +{ + public static class HarmonyUtil + { + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + public sealed class DebugPatchAttribute : Attribute + { + public string Key; + public DebugPatchAttribute() { } + public DebugPatchAttribute(string key) { Key = key; } + } + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + public sealed class DangerousAttribute : Attribute + { + public string Reason; + public DangerousAttribute() { } + public DangerousAttribute(string reason) { Reason = reason; } + } + + public sealed class PatchOptions + { + public bool AllowDebugPatches; + public bool AllowDangerousPatches; + public bool AllowStructReturns; + public string[] Before; + public string[] After; + public int? Priority; + public Action OnResult; + } + + private static readonly HashSet SensitiveDeny = new HashSet(StringComparer.Ordinal) + { + "ExplorationParty.PopState", + "FamilyMember.OnDestroy" + }; + + public static void PatchAll(HarmonyLib.Harmony h, Assembly asm, PatchOptions options) + { + if (h == null || asm == null) return; + if (options == null) options = new PatchOptions(); + + foreach (var type in SafeTypes(asm)) + { + PatchType(h, type, options); + } + } + + public static IList PatchType(HarmonyLib.Harmony h, Type type, PatchOptions options) + { + if (h == null || type == null) return new MethodBase[0]; + if (options == null) options = new PatchOptions(); + + try + { + if (!HasHarmonyPatchAttributes(type)) return new MethodBase[0]; + + if (!options.AllowDebugPatches && HasDebugAttribute(type)) + { + options.OnResult?.Invoke((object)type, "skipped: DebugPatch not enabled"); + return new MethodBase[0]; + } + + if (!options.AllowDangerousPatches && HasDangerousAttribute(type)) + { + options.OnResult?.Invoke((object)type, "skipped: Dangerous not enabled"); + return new MethodBase[0]; + } + + var targets = GetPatchTargets(type); + if (targets != null) + { + foreach (var m in targets) + { + var key = TargetKey(m); + if (!options.AllowDangerousPatches && SensitiveDeny.Contains(key) && !HasDangerousAttribute(type)) + { + options.OnResult?.Invoke((object)m, "skipped: sensitive target requires [Dangerous]"); + return new MethodBase[0]; + } + if (!options.AllowStructReturns && IsStructReturn(m) && !HasDangerousAttribute(type)) + { + options.OnResult?.Invoke((object)m, "skipped: struct-return target not allowed"); + return new MethodBase[0]; + } + } + } + + var proc = new PatchClassProcessor(h, type); + var patched = proc.Patch(); + + if (patched != null && patched.Count > 0) + { + foreach (var m in patched) + options.OnResult?.Invoke((object)m, "patched"); + return patched.Cast().ToList(); + } + + options.OnResult?.Invoke((object)type, "no methods patched"); + return new MethodBase[0]; + } + catch (Exception ex) + { + options.OnResult?.Invoke((object)type, "error: " + ex.Message); + return new MethodBase[0]; + } + } + + public static void PatchAll(HarmonyLib.Harmony h, Assembly asm, IPluginContext ctx) + { + var opts = new PatchOptions(); + try + { + if (ctx != null && ctx.Settings != null) + { + opts.AllowDebugPatches = ctx.Settings.GetBool("enableDebugPatches", false); + opts.AllowDangerousPatches = ctx.Settings.GetBool("dangerousPatches", false); + opts.AllowStructReturns = ctx.Settings.GetBool("allowStructReturns", false); + } + } + catch (Exception ex) { MMLog.WarnOnce("HarmonyUtil.PatchAll.Settings", "Error reading settings: " + ex.Message); } + opts.OnResult = (mb, reason) => + { + try + { + // DynamicMethod can have null DeclaringType; guard to avoid noisy warnings. + var method = mb as MethodBase; + var declaring = method != null ? (method.DeclaringType != null ? method.DeclaringType.FullName : "") : null; + var who = method != null ? declaring + "." + method.Name : (mb != null ? mb.ToString() : ""); + ctx?.Log?.Info("Patch: " + who + " -> " + reason); + } + catch (Exception ex) + { + // Suppress warning noise; log once to debug channel only. + MMLog.WriteDebug("HarmonyUtil.OnResult logging skipped: " + ex.Message); + } + }; + PatchAll(h, asm, opts); + } + + public static bool IsStructReturn(MethodBase m) + { + var mi = m as MethodInfo; + if (mi == null) return false; + try { return mi.ReturnType != null && mi.ReturnType.IsValueType && mi.ReturnType != typeof(void); } + catch (Exception ex) { MMLog.WarnOnce("HarmonyUtil.IsStructReturn", "Error checking for struct return: " + ex.Message); return false; } + } + + private static string TargetKey(MethodBase mb) + { + try { return mb.DeclaringType.FullName + "." + mb.Name; } + catch (Exception ex) { MMLog.WarnOnce("HarmonyUtil.TargetKey", "Error getting target key: " + ex.Message); return ""; } + } + + public static IEnumerable SafeTypes(Assembly asm) + { + try { return asm.GetTypes(); } + catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null); } + catch (Exception ex) { MMLog.WarnOnce("HarmonyUtil.SafeTypes", "Error getting types from assembly: " + ex.Message); return Enumerable.Empty(); } + } + + public static bool HasHarmonyPatchAttributes(Type t) + { + try + { + if (t == null) + return false; + + if (CustomAttributeData.GetCustomAttributes(t).Any(a => HasHarmonyAttributeName(GetAttributeTypeName(a)))) + return true; + + var methods = t.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + return methods.Any(m => CustomAttributeData.GetCustomAttributes(m).Any(a => HasHarmonyAttributeName(GetAttributeTypeName(a)))); + } + catch (Exception ex) + { + if (!(ex is ReflectionTypeLoadException) && !(ex is TypeLoadException) && !(ex is FileNotFoundException)) + MMLog.WarnOnce("HarmonyUtil.HasHarmonyPatchAttributes", "Error checking for Harmony attributes: " + ex.Message); + return false; + } + } + + private static bool HasHarmonyAttributeName(string fullName) + { + return !string.IsNullOrEmpty(fullName) && fullName.StartsWith("HarmonyLib.Harmony", StringComparison.Ordinal); + } + + private static string GetAttributeTypeName(CustomAttributeData attribute) + { + try + { + if (attribute == null) + return null; + + // .NET 3.5 does not expose CustomAttributeData.AttributeType. + // Constructor.DeclaringType is the compatible path for this target. + if (attribute.Constructor != null && attribute.Constructor.DeclaringType != null) + return attribute.Constructor.DeclaringType.FullName; + } + catch + { + } + + return null; + } + + public static bool HasDebugAttribute(Type t) + { + return HasAttribute(t); + } + + public static bool HasDangerousAttribute(Type t) + { + return HasAttribute(t); + } + + private static bool HasAttribute(Type t) where T : Attribute + { + try { return t.GetCustomAttributes(typeof(T), false).FirstOrDefault() != null; } + catch (Exception ex) { MMLog.WarnOnce("HarmonyUtil.HasAttribute", "Error checking for attribute: " + ex.Message); return false; } + } + + public static IEnumerable GetPatchTargets(Type patchClass) + { + try + { + var tm = patchClass.GetMethod("TargetMethods", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, null, Type.EmptyTypes, null); + if (tm != null) + { + var e = tm.Invoke(null, null) as System.Collections.IEnumerable; + if (e != null) + { + var list = new List(); + foreach (var it in e) { var mb = it as MethodBase; if (mb != null) list.Add(mb); } + if (list.Count > 0) return list; + } + } + } + catch (Exception ex) { MMLog.WarnOnce("HarmonyUtil.TryGetTargets.TargetMethods", "Error invoking TargetMethods: " + ex.Message); } + + try + { + var tm = patchClass.GetMethod("TargetMethod", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, null, Type.EmptyTypes, null); + if (tm != null) + { + var mb = tm.Invoke(null, null) as MethodBase; + if (mb != null) return new[] { mb }; + } + } + catch (Exception ex) { MMLog.WarnOnce("HarmonyUtil.TryGetTargets.TargetMethod", "Error invoking TargetMethod: " + ex.Message); } + + try + { + var attrs = patchClass.GetCustomAttributes(true); + var list = new List(); + foreach (var a in attrs) + { + var at = a.GetType(); + if (!string.Equals(at.FullName, "HarmonyLib.HarmonyPatch", StringComparison.Ordinal)) + continue; + + var typeProp = at.GetProperty("type") ?? (MemberInfo)at.GetField("type"); + var nameProp = at.GetProperty("methodName") ?? (MemberInfo)at.GetField("methodName"); + Type targetType = typeProp is PropertyInfo tp + ? tp.GetValue(a, null) as Type + : (typeProp is FieldInfo tf ? tf.GetValue(a) as Type : null); + string methodName = nameProp is PropertyInfo np + ? np.GetValue(a, null) as string + : (nameProp is FieldInfo nf ? nf.GetValue(a) as string : null); + if (targetType == null || string.IsNullOrEmpty(methodName)) continue; + + try + { + var mb = targetType.GetMethod(methodName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static); + if (mb != null) list.Add(mb); + } + catch (Exception ex) { MMLog.WarnOnce("HarmonyUtil.TryGetTargets.GetMethod", "Error getting method: " + ex.Message); } + } + if (list.Count > 0) return list; + } + catch (Exception ex) { MMLog.WarnOnce("HarmonyUtil.TryGetTargets.Attributes", "Error reading HarmonyPatch attributes: " + ex.Message); } + + return null; + } + + public static bool IsLoaded() where TManager : UnityEngine.Object + { + try + { + var mgr = GetSingletonInstance(typeof(TManager)) as UnityEngine.Object; + if (mgr == null) return false; + if (SaveManager.instance == null) return false; + var saveable = mgr as ISaveable; + return saveable != null ? SaveManager.instance.HasBeenLoaded(saveable) : true; + } + catch (Exception ex) { MMLog.WarnOnce("HarmonyUtil.IsLoaded", "Error checking if manager is loaded: " + ex.Message); return false; } + } + + public static void PatchWhenLoaded(HarmonyLib.Harmony h, Action applyPatches) where TManager : UnityEngine.Object + { + if (h == null || applyPatches == null) return; + if (IsLoaded()) { SafeInvoke(applyPatches); return; } + LoadGateRunner.Ensure().Enqueue(() => IsLoaded(), applyPatches); + } + + public static void WaitUntilLoaded(Action action) where TManager : UnityEngine.Object + { + if (action == null) return; + if (IsLoaded()) { SafeInvoke(action); return; } + LoadGateRunner.Ensure().Enqueue(() => IsLoaded(), action); + } + + private static object GetSingletonInstance(Type t) + { + try + { + var p = t.GetProperty("Instance", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + if (p != null) return p.GetValue(null, null); + } + catch (Exception ex) { MMLog.WarnOnce("HarmonyUtil.GetSingletonInstance.Instance", "Error getting singleton instance (Instance): " + ex.Message); } + try + { + var p = t.GetProperty("instance", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + if (p != null) return p.GetValue(null, null); + } + catch (Exception ex) { MMLog.WarnOnce("HarmonyUtil.GetSingletonInstance.instance", "Error getting singleton instance (instance): " + ex.Message); } + return null; + } + + private static void SafeInvoke(Action a) { try { a(); } catch (Exception ex) { MMLog.Write("PatchWhenLoaded action failed: " + ex.Message); } } + + private class LoadGateRunner : MonoBehaviour + { + private static LoadGateRunner _inst; + private readonly Queue _queue = new Queue(); + + public static LoadGateRunner Ensure() + { + if (_inst != null) return _inst; + var go = new GameObject("ModAPI_LoadGateRunner"); + DontDestroyOnLoad(go); + _inst = go.AddComponent(); + return _inst; + } + + public void Enqueue(Func condition, Action action) + { + _queue.Enqueue(new Item { Condition = condition, Action = action, Deadline = Time.realtimeSinceStartup + 60f }); + } + + private void Update() + { + int count = _queue.Count; + if (count <= 0) return; + var it = _queue.Peek(); + bool ready = false; + try { ready = it.Condition(); } + catch (Exception ex) { MMLog.WarnOnce("LoadGateRunner.Update", "Condition check failed: " + ex.Message); ready = false; } + if (ready) + { + _queue.Dequeue(); + SafeInvoke(it.Action); + } + else if (Time.realtimeSinceStartup > it.Deadline) + { + _queue.Dequeue(); + MMLog.WriteDebug("LoadGateRunner: condition timed out"); + } + } + + private struct Item + { + public Func Condition; + public Action Action; + public float Deadline; + } + } + } +} diff --git a/Source/Spine/harmony/PatchRegistry.cs b/Source/Spine/harmony/PatchRegistry.cs new file mode 100644 index 0000000..312ee5b --- /dev/null +++ b/Source/Spine/harmony/PatchRegistry.cs @@ -0,0 +1,426 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using ModAPI.Core; + +namespace ModAPI.Harmony +{ + /// + /// Logical ownership domains for runtime Harmony patches. + /// + public enum PatchDomain + { + Unknown, + Bootstrap, + SaveFlow, + UI, + Input, + Content, + Diagnostics, + Events, + Interactions, + Characters, + World + } + + /// + /// Declares governance metadata for a Harmony patch host. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + public sealed class PatchPolicyAttribute : Attribute + { + /// + /// Creates a patch policy for the specified domain and owning feature. + /// + public PatchPolicyAttribute(PatchDomain domain, string feature) + { + Domain = domain; + Feature = feature ?? string.Empty; + TargetBehavior = string.Empty; + FailureMode = string.Empty; + RollbackStrategy = string.Empty; + } + + /// The domain that owns the patch. + public PatchDomain Domain { get; private set; } + /// The owning feature or subsystem name. + public string Feature { get; private set; } + /// The game/runtime behavior this patch changes. + public string TargetBehavior { get; set; } + /// The expected impact if the patch fails or is missing. + public string FailureMode { get; set; } + /// The recommended disable/remove strategy if the patch must be rolled back. + public string RollbackStrategy { get; set; } + /// True when the patch is optional and may be disabled without breaking core runtime. + public bool IsOptional { get; set; } + /// True when the patch is intended only for developer/debug scenarios. + public bool DeveloperOnly { get; set; } + } + + /// + /// Options controlling registry-driven patch discovery and application. + /// + public sealed class PatchRegistryOptions + { + /// + /// Creates default registry options. + /// + public PatchRegistryOptions() + { + PatchOptions = new HarmonyUtil.PatchOptions(); + DisabledDomains = new HashSet(); + IncludeOptionalPatches = true; + SourceName = string.Empty; + } + + /// Harmony safety/configuration options applied to discovered patches. + public HarmonyUtil.PatchOptions PatchOptions { get; set; } + /// Domains that should be skipped entirely during patch application. + public HashSet DisabledDomains { get; private set; } + /// Whether optional patches should be included. + public bool IncludeOptionalPatches { get; set; } + /// Human-readable source label used in patch registry logging. + public string SourceName { get; set; } + } + + /// + /// Describes one discovered patch host. + /// + public sealed class PatchRecord + { + /// The patch host type. + public Type PatchType; + /// The domain owning the patch. + public PatchDomain Domain; + /// The owning feature name. + public string Feature; + /// The runtime behavior targeted by the patch. + public string TargetBehavior; + /// The declared or inferred failure mode. + public string FailureMode; + /// The declared or inferred rollback strategy. + public string RollbackStrategy; + /// Whether the patch is optional. + public bool IsOptional; + /// Whether the patch is intended only for developer/debug scenarios. + public bool DeveloperOnly; + /// Whether the patch is marked dangerous. + public bool IsDangerous; + /// Whether governance metadata was explicitly declared. + public bool HasExplicitPolicy; + /// The resolved Harmony target methods for the patch host. + public List Targets; + } + + /// + /// Result of applying registry-driven patch discovery to an assembly. + /// + public sealed class PatchApplyReport + { + /// All discovered Harmony patch hosts. + public readonly List Discovered = new List(); + /// Patch hosts that were successfully applied. + public readonly List Applied = new List(); + /// Patch hosts that were skipped or produced no patch operations. + public readonly List Skipped = new List(); + /// Patch hosts missing explicit governance metadata. + public readonly List MissingPolicy = new List(); + } + + /// + /// Central registry for patch discovery, governance, and activation. + /// + public static class PatchRegistry + { + /// + /// Discovers and applies Harmony patch hosts from the provided assembly. + /// + public static PatchApplyReport ApplyAssembly(HarmonyLib.Harmony harmony, Assembly assembly, PatchRegistryOptions options) + { + var report = new PatchApplyReport(); + if (harmony == null || assembly == null) return report; + if (options == null) options = new PatchRegistryOptions(); + + foreach (var type in SafeTypes(assembly)) + { + if (type == null || !HarmonyUtil.HasHarmonyPatchAttributes(type)) continue; + + var record = CreateRecord(type); + report.Discovered.Add(record); + + if (!record.HasExplicitPolicy) + { + report.MissingPolicy.Add(record); + } + + if (!ShouldApply(record, options)) + { + report.Skipped.Add(record); + LogSkip(record, options); + continue; + } + + var patched = HarmonyUtil.PatchType(harmony, type, options.PatchOptions); + if (patched != null && patched.Count > 0) + { + report.Applied.Add(record); + } + else + { + report.Skipped.Add(record); + } + } + + LogSummary(report, options); + return report; + } + + /// + /// Applies a manually registered patch module through the same governance checks as discovered patches. + /// + public static bool ApplyManualModule(HarmonyLib.Harmony harmony, Type moduleType, Action applyAction, PatchRegistryOptions options) + { + if (harmony == null || moduleType == null || applyAction == null) return false; + if (options == null) options = new PatchRegistryOptions(); + + var record = CreateRecord(moduleType); + if (!ShouldApply(record, options)) + { + LogSkip(record, options); + return false; + } + + try + { + applyAction(); + LogManualApply(record, options); + return true; + } + catch (Exception ex) + { + MMLog.WriteWarning("[PatchRegistry] Manual patch module failed for " + + DescribeType(moduleType) + ": " + ex.Message); + return false; + } + } + + /// + /// Creates registry options from manager/runtime configuration. + /// + public static PatchRegistryOptions CreateManagerOptions(HarmonyUtil.PatchOptions patchOptions, string sourceName, Func readString) + { + var options = new PatchRegistryOptions(); + options.PatchOptions = patchOptions ?? new HarmonyUtil.PatchOptions(); + options.SourceName = sourceName ?? string.Empty; + options.IncludeOptionalPatches = readString == null || !string.Equals(readString("EnableOptionalPatches"), "false", StringComparison.OrdinalIgnoreCase); + + string disabledDomains = readString != null ? readString("DisabledPatchDomains") : null; + ApplyDisabledDomains(options.DisabledDomains, disabledDomains); + return options; + } + + /// + /// Parses and applies disabled patch domains from a configuration string. + /// + public static void ApplyDisabledDomains(HashSet domains, string raw) + { + if (domains == null || string.IsNullOrEmpty(raw)) return; + + string[] parts = raw.Split(new[] { ',', ';', '|' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < parts.Length; i++) + { + PatchDomain parsed; + if (TryParseDomain(parts[i].Trim(), out parsed)) + { + domains.Add(parsed); + } + } + } + + private static bool ShouldApply(PatchRecord record, PatchRegistryOptions options) + { + if (record == null) return false; + if (options != null && options.DisabledDomains != null && options.DisabledDomains.Contains(record.Domain)) + return false; + + if (record.IsOptional && options != null && !options.IncludeOptionalPatches) + return false; + + if (record.DeveloperOnly && (options == null || options.PatchOptions == null || !options.PatchOptions.AllowDebugPatches)) + return false; + + return true; + } + + private static PatchRecord CreateRecord(Type type) + { + var policy = FindPolicy(type); + var targets = new List(); + var discoveredTargets = HarmonyUtil.GetPatchTargets(type); + if (discoveredTargets != null) + targets.AddRange(discoveredTargets); + + var record = new PatchRecord(); + record.PatchType = type; + record.Domain = policy != null ? policy.Domain : InferDomain(type); + record.Feature = policy != null && !string.IsNullOrEmpty(policy.Feature) ? policy.Feature : InferFeature(type); + record.TargetBehavior = policy != null && !string.IsNullOrEmpty(policy.TargetBehavior) + ? policy.TargetBehavior + : BuildTargetBehavior(targets); + record.FailureMode = policy != null && !string.IsNullOrEmpty(policy.FailureMode) + ? policy.FailureMode + : "Runtime behavior falls back to vanilla or feature-specific behavior may be incomplete."; + record.RollbackStrategy = policy != null && !string.IsNullOrEmpty(policy.RollbackStrategy) + ? policy.RollbackStrategy + : "Disable the owning patch domain or remove the patch class from registry-driven bootstrap."; + record.IsOptional = policy != null && policy.IsOptional; + record.DeveloperOnly = (policy != null && policy.DeveloperOnly) || HarmonyUtil.HasDebugAttribute(type); + record.IsDangerous = HarmonyUtil.HasDangerousAttribute(type); + record.HasExplicitPolicy = policy != null; + record.Targets = targets; + return record; + } + + private static PatchPolicyAttribute FindPolicy(Type type) + { + for (Type cursor = type; cursor != null; cursor = cursor.DeclaringType) + { + object[] attrs = cursor.GetCustomAttributes(typeof(PatchPolicyAttribute), false); + if (attrs != null && attrs.Length > 0) + return attrs[0] as PatchPolicyAttribute; + } + return null; + } + + private static IEnumerable SafeTypes(Assembly assembly) + { + try { return assembly.GetTypes(); } + catch (ReflectionTypeLoadException ex) { return ex.Types.Where(t => t != null); } + catch { return Enumerable.Empty(); } + } + + private static PatchDomain InferDomain(Type type) + { + string fullName = type != null ? (type.FullName ?? string.Empty) : string.Empty; + string lower = fullName.ToLowerInvariant(); + + if (lower.Contains("custom saves") || lower.Contains(".save") || lower.Contains("platformsave") || lower.Contains("slotselection")) + return PatchDomain.SaveFlow; + if (lower.Contains(".ui") || lower.Contains("mainmenu") || lower.Contains("panel")) + return PatchDomain.UI; + if (lower.Contains(".input")) + return PatchDomain.Input; + if (lower.Contains(".content") || lower.Contains("inventoryintegration") || lower.Contains("localization")) + return PatchDomain.Content; + if (lower.Contains(".debug") || lower.Contains("diagnostic")) + return PatchDomain.Diagnostics; + if (lower.Contains(".events")) + return PatchDomain.Events; + if (lower.Contains(".interactions")) + return PatchDomain.Interactions; + if (lower.Contains(".characters")) + return PatchDomain.Characters; + if (lower.Contains(".harmony")) + return PatchDomain.Bootstrap; + + return PatchDomain.Unknown; + } + + private static string InferFeature(Type type) + { + if (type == null) return "Unknown"; + + Type root = type; + while (root.DeclaringType != null) + root = root.DeclaringType; + + return root.Name; + } + + private static string BuildTargetBehavior(List targets) + { + if (targets == null || targets.Count == 0) + return "Multiple or dynamically resolved patch targets."; + + var parts = new List(); + for (int i = 0; i < targets.Count && i < 3; i++) + { + MethodBase target = targets[i]; + if (target == null) continue; + string typeName = target.DeclaringType != null ? target.DeclaringType.Name : ""; + parts.Add(typeName + "." + target.Name); + } + + if (targets.Count > 3) + parts.Add("..."); + + return string.Join(", ", parts.ToArray()); + } + + private static void LogSummary(PatchApplyReport report, PatchRegistryOptions options) + { + string source = !string.IsNullOrEmpty(options.SourceName) ? options.SourceName : "runtime"; + MMLog.WriteInfo(source + + " discovered=" + report.Discovered.Count + + ", applied=" + report.Applied.Count + + ", skipped=" + report.Skipped.Count + + ", missingPolicy=" + report.MissingPolicy.Count + "."); + + if (report.MissingPolicy.Count > 0) + { + int max = Math.Min(8, report.MissingPolicy.Count); + for (int i = 0; i < max; i++) + { + var record = report.MissingPolicy[i]; + MMLog.WriteDebug("Missing policy: " + DescribeRecord(record)); + } + } + } + + private static void LogSkip(PatchRecord record, PatchRegistryOptions options) + { + if (record == null) return; + MMLog.WriteDebug("skipped " + DescribeRecord(record) + + " source=" + (options != null ? options.SourceName : string.Empty)); + } + + private static void LogManualApply(PatchRecord record, PatchRegistryOptions options) + { + if (record == null) return; + MMLog.WriteInfo("manual apply " + DescribeRecord(record) + + " source=" + (options != null ? options.SourceName : string.Empty)); + } + + private static string DescribeRecord(PatchRecord record) + { + if (record == null) return ""; + return DescribeType(record.PatchType) + + " domain=" + record.Domain + + " feature=" + (record.Feature ?? string.Empty) + + " target=" + (record.TargetBehavior ?? string.Empty); + } + + private static string DescribeType(Type type) + { + return type != null ? (type.FullName ?? type.Name) : ""; + } + + private static bool TryParseDomain(string raw, out PatchDomain domain) + { + domain = PatchDomain.Unknown; + if (string.IsNullOrEmpty(raw)) return false; + + try + { + domain = (PatchDomain)Enum.Parse(typeof(PatchDomain), raw, true); + return true; + } + catch + { + return false; + } + } + } +} diff --git a/Source/Spine/harmony/Transpilers/AdvancedExtensions.cs b/Source/Spine/harmony/Transpilers/AdvancedExtensions.cs new file mode 100644 index 0000000..a3e0df0 --- /dev/null +++ b/Source/Spine/harmony/Transpilers/AdvancedExtensions.cs @@ -0,0 +1,104 @@ +using System; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; +using UnityEngine; +using ModAPI.Core; + +namespace ModAPI.Harmony +{ + /// + /// Advanced and rarely used transpiler tools. + /// Kept separate to reduce clutter for standard mod development. + /// + public partial class FluentTranspiler + { + #region Branch Matching (Advanced) + + /// + /// Match a forward branch instruction (br, brtrue, brfalse, etc.). + /// Useful for finding loop boundaries and conditional jumps. + /// + public FluentTranspiler FindNextBranch(SearchMode mode = SearchMode.Next) + { + if (mode == SearchMode.Start) _matcher.Start(); + else if (mode == SearchMode.Next) _matcher.Advance(1); + + _matcher.MatchStartForward(new CodeMatch(instr => + instr.opcode.FlowControl == FlowControl.Branch || + instr.opcode.FlowControl == FlowControl.Cond_Branch)); + + if (!_matcher.IsValid) + AddSoftFailure("No branch instruction found"); + + return this; + } + + /// Match a forward branch instruction (alias for FindNextBranch). + public FluentTranspiler MatchNextBranch() + { + return FindNextBranch(SearchMode.Next); + } + + /// + /// Match the instruction with a specific label (branch target). + /// Advances to the target label of a branch instruction. + /// + public FluentTranspiler FindBranchTarget(Label targetLabel, SearchMode mode = SearchMode.Start) + { + if (mode == SearchMode.Start) _matcher.Start(); + else if (mode == SearchMode.Next) _matcher.Advance(1); + + _matcher.MatchStartForward(new CodeMatch(instr => instr.labels.Contains(targetLabel))); + + if (!_matcher.IsValid) + { + AddSoftFailure("Branch target label not found in method"); + } + + return this; + } + + /// Match the instruction with a specific label (branch target). + public FluentTranspiler MatchBranchTarget(Label targetLabel) + { + return FindBranchTarget(targetLabel, SearchMode.Start); + } + + #endregion + } + + /// + /// Rarely used pattern matching extensions. + /// + public static class AdvancedPatterns + { + #region DontDestroyOnLoad Nuking + + /// + /// Remove a call to DontDestroyOnLoad. + /// Includes matching - do NOT pre-match. + /// + public static FluentTranspiler NukeDontDestroyOnLoad(this FluentTranspiler t, SearchMode mode = SearchMode.Start) + { + return t + .FindCall(typeof(UnityEngine.Object), "DontDestroyOnLoad", mode) + .ReplaceWith(OpCodes.Pop); + } + + /// + /// Remove ALL calls to DontDestroyOnLoad in the method. + /// + public static FluentTranspiler NukeAllDontDestroyOnLoad(this FluentTranspiler t) + { + return t.ReplaceAllPatterns( + new Func[] { instr => + (instr.opcode == OpCodes.Call || instr.opcode == OpCodes.Callvirt) && + instr.operand is MethodBase mb && mb.DeclaringType == typeof(UnityEngine.Object) && mb.Name == "DontDestroyOnLoad" }, + new[] { new CodeInstruction(OpCodes.Pop) }, + preserveInstructionCount: true); + } + + #endregion + } +} diff --git a/Source/Spine/harmony/Transpilers/CartographerExtensions.cs b/Source/Spine/harmony/Transpilers/CartographerExtensions.cs new file mode 100644 index 0000000..001fc5b --- /dev/null +++ b/Source/Spine/harmony/Transpilers/CartographerExtensions.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; +using ModAPI.Core; + +namespace ModAPI.Harmony +{ + public class Anchor + { + public int Index; + public CodeInstruction Instruction; + public float UniquenessScore; + + public Anchor(int index, CodeInstruction instruction, float uniquenessScore) + { + Index = index; + Instruction = instruction; + UniquenessScore = uniquenessScore; + } + } + + public class AnchorReport + { + public List SafeAnchors = new List(); + public List Suggestions = new List(); + + public string ToSummary() + { + var sb = new StringBuilder(); + sb.AppendLine( + $"=== Anchor Report ({SafeAnchors.Count} anchors) ==="); + foreach (var a in SafeAnchors.OrderByDescending( + x => x.UniquenessScore)) + { + sb.AppendLine( + $" [{a.Index:D4}] Score:{a.UniquenessScore:F1}" + + $" | {a.Instruction}"); + } + if (Suggestions.Count > 0) + { + sb.AppendLine("Suggestions:"); + foreach (var s in Suggestions) sb.AppendLine(" * " + s); + } + return sb.ToString(); + } + } + + public static class CartographerExtensions + { + /// + /// Analyzes method for unique anchors without modifying. + /// + public static AnchorReport MapAnchors(this FluentTranspiler t, float threshold = 1.2f) + { + var instructions = t.Instructions().ToList(); + var report = new AnchorReport(); + + // Frequency analysis + var opcodeFrequency = instructions.GroupBy(i => i.opcode) + .ToDictionary(g => g.Key, g => g.Count()); + + // String frequency analysis + var stringFrequency = instructions + .Where(i => i.operand is string) + .GroupBy(i => (string)i.operand) + .ToDictionary(g => g.Key, g => g.Count()); + + // "Green Zone" detection: Rare opcodes + specific operands + for (int i = 0; i < instructions.Count; i++) + { + var instr = instructions[i]; + float uniquenessScore = 0; + + // Low-frequency opcodes score higher + if (opcodeFrequency[instr.opcode] < 3) uniquenessScore += 0.5f; + + // Specific string/method references score highest + if (instr.operand is string s) + { + int freq = stringFrequency.ContainsKey(s) ? stringFrequency[s] : 1; + uniquenessScore += freq == 1 ? 1.5f : 1.0f / freq; + } + + if (instr.operand is MethodInfo mi && mi.DeclaringType != typeof(object)) + uniquenessScore += 0.8f; + + if (uniquenessScore >= threshold) + report.SafeAnchors.Add(new Anchor(i, instr, uniquenessScore)); + } + + // Context scoring: unique pairs + for (int i = 0; i < instructions.Count - 1; i++) + { + var first = report.SafeAnchors.FirstOrDefault(a => a.Index == i); + var second = report.SafeAnchors.FirstOrDefault(a => a.Index == i + 1); + + if (first != null && second != null) + { + // Boost both anchors + first.UniquenessScore += 0.5f; + second.UniquenessScore += 0.5f; + } + } + + return report; + } + + /// + /// Analyzes method for anchors and logs the summary to MMLog. + /// Useful during development to find robust jump targets. + /// + public static FluentTranspiler ExportAnchors(this FluentTranspiler t, float threshold = 1.2f) + { + var report = t.MapAnchors(threshold); + MMLog.WriteInfo(report.ToSummary()); + return t; + } + + /// + /// Finds the next instruction with a high uniqueness score and moves the matcher to it. + /// + public static FluentTranspiler FindNextAnchor(this FluentTranspiler t, float minUniqueness = 1.0f) + { + // We use the full map for context scoring + var report = t.MapAnchors(minUniqueness); + var nextAnchor = report.SafeAnchors + .OrderBy(a => a.Index) + .FirstOrDefault(a => a.Index > t.CurrentIndex); + + if (nextAnchor != null) + { + t.MoveTo(nextAnchor.Index); + } + else + { + t.AddSoftFailure(TranspilerDiagnosticCategory.Match, + $"FindNextAnchor: No anchor with score >= {minUniqueness} found after index {t.CurrentIndex}"); + } + return t; + } + + /// + /// Inserts code safely relative to the current anchor. + /// - If anchor is a Return/Throw, inserts BEFORE. + /// - Otherwise inserts AFTER, ensuring we don't accidentally split a block if the next instruction is a jump target? + /// Actually, Harmony handles label shifting on InsertBefore/After automatically. + /// This method mainly ensures we don't insert dead code after a Return. + /// + public static FluentTranspiler SafeInsert(this FluentTranspiler t, params CodeInstruction[] instructions) + { + if (!t.HasMatch) return t; + + bool isTerminator = t.Current.opcode == OpCodes.Ret || t.Current.opcode == OpCodes.Throw; + + if (isTerminator) + { + t.InsertBefore(instructions); + } + else + { + t.InsertAfter(instructions); + } + return t; + } + + /// + /// Attempts to find a 'fuzzy' match for a failed search by looking for instructions + /// with similar opcodes or the same operand. + /// + public static void SuggestFuzzyMatches(this FluentTranspiler t, OpCode opcode, object operand) + { + var instructions = t.Instructions().ToList(); + var suggestions = new List(); + + for (int i = 0; i < instructions.Count; i++) + { + var instr = instructions[i]; + bool opcodeMatch = instr.opcode == opcode; + bool operandMatch = operand != null && Equals(instr.operand, operand); + + if (opcodeMatch || operandMatch) + { + suggestions.Add(i); + } + } + + if (suggestions.Count > 0) + { + string msg = $"Fuzzy match suggestions for {opcode} {operand}: Lines " + + string.Join(", ", suggestions.Select(s => s.ToString()).ToArray()); + t.AddNote(TranspilerDiagnosticCategory.Trace, msg); + MMLog.WriteWarning("[Cartographer] " + msg); + } + } + + /// + /// Returns indices of instructions that appear to be semantic boundaries + /// (Method entry, branch targets with many inputs, or terminal instructions). + /// + public static List GetSemanticAnchors(this FluentTranspiler t) + { + var instructions = t.Instructions().ToList(); + var labels = instructions.SelectMany(i => i.labels).Distinct().ToList(); + var anchors = new List(); + + for (int i = 0; i < instructions.Count; i++) + { + var instr = instructions[i]; + // Branched-to instructions are good semantic anchors + if (instr.labels.Count > 0) anchors.Add(i); + // Terminators are good anchors + if (instr.opcode == OpCodes.Ret || instr.opcode == OpCodes.Throw) anchors.Add(i); + } + + return anchors.Distinct().OrderBy(x => x).ToList(); + } + } +} diff --git a/Source/Spine/harmony/Transpilers/ControlFlowExtensions.cs b/Source/Spine/harmony/Transpilers/ControlFlowExtensions.cs new file mode 100644 index 0000000..b9a4ff3 --- /dev/null +++ b/Source/Spine/harmony/Transpilers/ControlFlowExtensions.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; +using ModAPI.Core; + +namespace ModAPI.Harmony +{ + public enum LoopType + { + For, + While, + ForEach + } + + /// + /// Semantic navigation extensions for FluentTranspiler. + /// Allows finding high-level code structures like loops and if-statements. + /// + public static class ControlFlowExtensions + { + /// + /// Attempts to find a loop header based on backward jumps. + /// + public static FluentTranspiler FindLoop(this FluentTranspiler t, LoopType type, string iteratorName = null) + { + if (!t.HasMatch) return t; + + var instructions = t.Instructions().ToList(); + int currentPos = t.CurrentIndex; + + // Pattern: Find a branch that jumps to an earlier instruction + for (int i = currentPos; i < instructions.Count; i++) + { + if (t.IsBackwardBranch(instructions[i], i, out int targetIndex)) + { + // For a 'for' loop, we usually want to be at the jump target (start of body or check) + return t.MoveTo(targetIndex); + } + } + + t.AddSoftFailure(TranspilerDiagnosticCategory.Match, + $"FindLoop: No backward jumps found for loop type {type} starting from {currentPos}."); + return t; + } + + /// + /// Positions the matcher at the first instruction inside the currently matched loop. + /// + public static FluentTranspiler AtLoopHeader(this FluentTranspiler t) + { + // Usually we are already at the header if FindLoop succeeded + return t; + } + + /// + /// Attempts to find an if-statement based on a condition predicate. + /// + public static FluentTranspiler FindIfStatement(this FluentTranspiler t, Func condition) + { + if (!t.HasMatch) return t; + + // 1. Find the condition instructions + t.FindOpCode(OpCodes.Nop, SearchMode.Current); // Placeholder logic + + // Real logic: Find instructions matching 'condition', then find the following branch + var instructions = t.Instructions().ToList(); + for (int i = t.CurrentIndex; i < instructions.Count; i++) + { + if (condition(instructions[i])) + { + // Found the condition. Now look for the branch instruction that handles the 'if' + for (int j = i + 1; j < Math.Min(i + 5, instructions.Count); j++) + { + if (IsConditionalBranch(instructions[j].opcode)) + { + return t.MoveTo(j); + } + } + } + } + + return t; + } + + /// + /// Positions the matcher at the start of the 'then' block of the currently matched if-statement. + /// + public static FluentTranspiler AtThenBlockStart(this FluentTranspiler t) + { + if (!t.HasMatch) return t; + + var instr = t.Current; + if (IsConditionalBranch(instr.opcode)) + { + // If it's a 'jump if false', the 'then' block is next. + if (opIsJumpIfFalse(instr.opcode)) + return t.Next(); + + // If it's a 'jump if true', the 'then' block is at the target. + if (instr.operand is Label label) + { + int target = t.LabelToIndex(label); + if (target != -1) return t.MoveTo(target); + } + } + + t.AddSoftFailure(TranspilerDiagnosticCategory.Match, + "AtThenBlockStart: Current instruction is not a conditional branch."); + return t; + } + + private static bool IsBackwardBranch(this FluentTranspiler t, CodeInstruction instr, int currentIndex, out int targetIndex) + { + targetIndex = -1; + if (IsBranch(instr.opcode) && instr.operand is Label label) + { + targetIndex = t.LabelToIndex(label); + return targetIndex != -1 && targetIndex < currentIndex; + } + return false; + } + + private static bool IsBranch(OpCode op) => op.FlowControl == FlowControl.Branch || op.FlowControl == FlowControl.Cond_Branch; + private static bool IsConditionalBranch(OpCode op) => op.FlowControl == FlowControl.Cond_Branch; + + private static bool opIsJumpIfFalse(OpCode op) => op == OpCodes.Brfalse || op == OpCodes.Brfalse_S || op == OpCodes.Beq || op == OpCodes.Beq_S; + + } +} diff --git a/Source/Spine/harmony/Transpilers/CooperativePatcher.cs b/Source/Spine/harmony/Transpilers/CooperativePatcher.cs new file mode 100644 index 0000000..59e41f9 --- /dev/null +++ b/Source/Spine/harmony/Transpilers/CooperativePatcher.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; +using ModAPI.Core; + +namespace ModAPI.Harmony +{ + public enum PatchPriority + { + First = 0, + VeryHigh = 100, + High = 200, + Normal = 300, + Low = 400, + VeryLow = 500, + Last = 600 + } + + /// + /// Orchestrates multiple transpilers on the same method to ensure compatibility. + /// Replaces the "wild west" of conflicting Harmony patches with a managed pipeline. + /// + public static class CooperativePatcher + { + private class PatcherRegistration + { + public string AnchorId; + public PatchPriority Priority; + public Func PatchLogic; + public string OwnerMod; + public string[] DependsOn; // AnchorIds that must run first + public string[] ConflictsWith; // AnchorIds that cannot coexist + } + + private static readonly Dictionary> _registrations = + new Dictionary>(); + private static readonly object _lock = new object(); + private static readonly object _quarantineLock = new object(); + private static readonly HashSet _quarantinedOwners = new HashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Registers a cooperative transpiler. + /// NOTE: This does not apply the patch immediately. You must call Apply() or ensure ModAPI's master patcher is running. + /// + public static void RegisterTranspiler( + MethodBase target, + string anchorId, + PatchPriority priority, + Func patchLogic, + string[] dependsOn = null, + string[] conflictsWith = null) + { + lock (_lock) + { + if (!_registrations.ContainsKey(target)) + _registrations[target] = new List(); + + var registration = new PatcherRegistration + { + AnchorId = anchorId, + Priority = priority, + PatchLogic = patchLogic, + OwnerMod = Assembly.GetCallingAssembly().GetName().Name, + DependsOn = dependsOn ?? new string[0], + ConflictsWith = conflictsWith ?? new string[0] + }; + + // Deduplication: Remove existing patch with same AnchorId from same mod + _registrations[target].RemoveAll(r => r.AnchorId == anchorId && r.OwnerMod == registration.OwnerMod); + + _registrations[target].Add(registration); + + MMLog.WriteDebug($"[CooperativePatcher] Registered patch for {target.Name} from {registration.OwnerMod} (Priority: {priority}, Anchor: {anchorId})"); + } + } + + public static bool UnregisterTranspiler(MethodBase target, string anchorId, string ownerMod = null) + { + lock (_lock) + { + if (!_registrations.ContainsKey(target)) return false; + + string mod = ownerMod ?? Assembly.GetCallingAssembly().GetName().Name; + return _registrations[target].RemoveAll(r => r.AnchorId == anchorId && r.OwnerMod == mod) > 0; + } + } + + public static void UnregisterAll(string ownerMod = null) + { + string mod = ownerMod ?? Assembly.GetCallingAssembly().GetName().Name; + lock (_lock) + { + foreach (var list in _registrations.Values) + { + list.RemoveAll(r => r.OwnerMod == mod); + } + } + lock (_quarantineLock) { _quarantinedOwners.Remove(mod); } + } + + /// + /// manual trigger to run all registered patches on the target. + /// Currently, this must be called by the "Main" patcher or a bootstrap. + /// + public static IEnumerable RunPipeline(MethodBase original, IEnumerable instructions) + { + return RunPipeline(original, instructions, null); + } + + /// + /// Runs the cooperative pipeline with an optional ILGenerator so registrations + /// can declare locals and labels just like a normal Harmony transpiler. + /// + public static IEnumerable RunPipeline(MethodBase original, IEnumerable instructions, ILGenerator generator) + { + List sortedPatches; + lock (_lock) + { + if (!_registrations.ContainsKey(original)) + return instructions; + + sortedPatches = OrderRegistrationsForExecution(_registrations[original]); + } + + var currentInstructions = instructions.ToList(); + + MMLog.WriteDebug($"[CooperativePatcher] Running pipeline for {original.Name} ({sortedPatches.Count} patches)"); + + var appliedAnchors = new HashSet(); + + foreach (var patch in sortedPatches) + { + if (IsOwnerQuarantined(patch.OwnerMod)) + { + MMLog.WriteWarning($"[CooperativePatcher] Skipping {patch.OwnerMod}:{patch.AnchorId} - owner is quarantined due to prior critical patch failure."); + continue; + } + + // Dependency Check + if (patch.DependsOn.Length > 0) + { + var missing = patch.DependsOn.Where(d => !appliedAnchors.Contains(d)).ToList(); + if (missing.Any()) + { + MMLog.WriteWarning($"[CooperativePatcher] Skipping {patch.OwnerMod}:{patch.AnchorId} - missing dependencies: {string.Join(", ", missing.ToArray())}"); + continue; + } + } + + // Conflict Check + if (patch.ConflictsWith.Length > 0) + { + var conflicts = patch.ConflictsWith.Where(c => appliedAnchors.Contains(c)).ToList(); + if (conflicts.Any()) + { + MMLog.WriteWarning($"[CooperativePatcher] Skipping {patch.OwnerMod}:{patch.AnchorId} - conflicts with applied patches: {string.Join(", ", conflicts.ToArray())}"); + continue; + } + } + + try + { + MMLog.WriteDebug($"[CooperativePatcher] Applying {patch.OwnerMod} : {patch.AnchorId}"); + + var beforeInstructions = currentInstructions.ToList(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + // Create transpiler wrapper on the CURRENT instructions + // We use valid COPY of the instructions to ensure isolation + var t = FluentTranspiler.For(currentInstructions, original, generator); + + // Run logic + t = patch.PatchLogic(t); + if (t == null) + { + throw new InvalidOperationException($"Patch logic returned null for {patch.OwnerMod}:{patch.AnchorId}"); + } + + // Build strictness is policy-driven so safer defaults can be enforced globally. + var nextInstructions = t.Build(TranspilerSafetyPolicy.DefaultCooperativeProfile); + if (t.Diagnostics.Any(TranspilerSafetyPolicy.IsCriticalDiagnostic)) + { + throw new InvalidOperationException( + $"Critical transpiler warnings for {patch.OwnerMod}:{patch.AnchorId}: " + + string.Join("; ", t.Diagnostics.Where(TranspilerSafetyPolicy.IsCriticalDiagnostic).Select(d => d.Message).ToArray())); + } + + if (t.Warnings.Count > 0) + { + MMLog.WriteWarning( + $"[CooperativePatcher] {patch.OwnerMod}:{patch.AnchorId} resulted in warnings: " + + string.Join("; ", t.Warnings.ToArray())); + } + + // If successful, update current instructions and mark anchored + // This atomic swap prevents partial corruption if PatchLogic throws or Build fails + currentInstructions = nextInstructions.ToList(); + appliedAnchors.Add(patch.AnchorId); + + sw.Stop(); + var origin = "CooperativePatcher|" + patch.OwnerMod + "|" + patch.AnchorId + "|Priority:" + patch.Priority; + var stepName = original != null && original.DeclaringType != null + ? original.DeclaringType.FullName + "." + original.Name + : (original != null ? original.Name : "UnknownMethod"); + if (TranspilerSafetyPolicy.ShouldRecordDebugSnapshot(t.Warnings.Count, t.SoftFailures.Count, t.Notes.Count)) + { + TranspilerDebugger.RecordSnapshot( + patch.OwnerMod, + stepName, + beforeInstructions, + currentInstructions, + sw.Elapsed.TotalMilliseconds, + t.Warnings.Count, + original, + origin, + warnings: t.Diagnostics.Select(d => d.ToString())); + MMLog.WriteDebug("[CooperativePatcher] Snapshot recorded for patch origin: " + origin); + } + } + catch (Exception ex) + { + MMLog.WriteError($"[CooperativePatcher] Patch {patch.OwnerMod}:{patch.AnchorId} FAILED and was skipped. Error: {ex.Message}"); + QuarantineOwnerIfEnabled(patch.OwnerMod, patch.AnchorId); + // Continue with previous valid instructions - 'currentInstructions' remains untouched by this iteration + } + } + + return currentInstructions; + } + + private static List OrderRegistrationsForExecution(List registrations) + { + var ordered = new List(); + if (registrations == null || registrations.Count == 0) return ordered; + + var all = registrations.ToList(); + var canonicalProviders = all + .OrderBy(SortKey) + .GroupBy(r => r.AnchorId ?? string.Empty, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + + var indegree = new Dictionary(); + var outgoing = new Dictionary>(); + for (int i = 0; i < all.Count; i++) + { + indegree[all[i]] = 0; + outgoing[all[i]] = new List(); + } + + for (int i = 0; i < all.Count; i++) + { + var patch = all[i]; + var dependencies = patch.DependsOn ?? new string[0]; + for (int d = 0; d < dependencies.Length; d++) + { + var dependency = dependencies[d]; + if (string.IsNullOrEmpty(dependency)) continue; + if (!canonicalProviders.TryGetValue(dependency, out var provider)) continue; + if (ReferenceEquals(provider, patch)) continue; + + outgoing[provider].Add(patch); + indegree[patch]++; + } + } + + var ready = all.Where(p => indegree[p] == 0).OrderBy(SortKey).ToList(); + while (ready.Count > 0) + { + var next = ready[0]; + ready.RemoveAt(0); + ordered.Add(next); + + var dependents = outgoing[next]; + for (int i = 0; i < dependents.Count; i++) + { + var dependent = dependents[i]; + indegree[dependent]--; + if (indegree[dependent] == 0) + { + ready.Add(dependent); + } + } + + ready = ready.OrderBy(SortKey).ToList(); + } + + if (ordered.Count != all.Count) + { + var unresolved = all.Except(ordered).OrderBy(SortKey).ToList(); + MMLog.WriteWarning( + "[CooperativePatcher] Dependency cycle or ambiguous dependency ordering detected. " + + "Falling back to priority order for: " + + string.Join(", ", unresolved.Select(p => p.OwnerMod + ":" + p.AnchorId).ToArray())); + ordered.AddRange(unresolved); + } + + return ordered; + } + + private static string SortKey(PatcherRegistration registration) + { + if (registration == null) return string.Empty; + return ((int)registration.Priority).ToString("D4") + "|" + + (registration.AnchorId ?? string.Empty) + "|" + + (registration.OwnerMod ?? string.Empty); + } + + private static bool IsOwnerQuarantined(string ownerMod) + { + if (string.IsNullOrEmpty(ownerMod)) return false; + lock (_quarantineLock) + { + return _quarantinedOwners.Contains(ownerMod); + } + } + + private static void QuarantineOwnerIfEnabled(string ownerMod, string anchorId) + { + if (!TranspilerSafetyPolicy.QuarantineOwnerOnFailure) return; + if (string.IsNullOrEmpty(ownerMod)) return; + + lock (_quarantineLock) + { + _quarantinedOwners.Add(ownerMod); + } + MMLog.WriteWarning($"[CooperativePatcher] Quarantined owner '{ownerMod}' after failure in anchor '{anchorId}'. Disable with ModPrefs.TranspilerQuarantineOnFailure=false if needed."); + } + } +} diff --git a/Source/Spine/harmony/Transpilers/FluentTranspiler.cs b/Source/Spine/harmony/Transpilers/FluentTranspiler.cs new file mode 100644 index 0000000..1ab7675 --- /dev/null +++ b/Source/Spine/harmony/Transpilers/FluentTranspiler.cs @@ -0,0 +1,2363 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; +using ModAPI.Core; + +namespace ModAPI.Harmony +{ + public enum SearchMode + { + Start, // Resets to beginning before searching + Current, // Searches forward from current position (inclusive) + Next // Advances 1 then searches forward (for sequential matching) + } + + public enum TranspilerDiagnosticSeverity + { + Note, + SoftFailure, + Warning + } + + public enum TranspilerDiagnosticCategory + { + General, + Match, + Validation, + Safety, + Trace + } + + public sealed class TranspilerDiagnostic + { + public TranspilerDiagnosticSeverity Severity { get; set; } + public TranspilerDiagnosticCategory Category { get; set; } + public string Message { get; set; } + + public override string ToString() + { + return $"[{Severity}:{Category}] {Message}"; + } + } + + /// + /// Fluent wrapper around Harmony transpilers for RimWorld and mod patch code. + /// + /// + /// + /// The framework is built around a short lifecycle: open a session for the Harmony IL stream, + /// find a stable anchor in the target method, apply a focused edit, and finish through + /// so validation and diagnostics happen in one place. + /// + /// + /// Most patches should use because it wraps that lifecycle in the + /// normal Harmony transpiler shape. Use when a patch needs explicit + /// control over when runs or which validation mode is used. + /// + /// + /// The goal is to keep patch intent readable to developers who understand the target RimWorld + /// logic, without forcing every patch to manually manage labels, branch targets, and stack checks. + /// + /// + public partial class FluentTranspiler + { + public enum BuildProfile + { + Runtime, + Strict, + Debug + } + + private struct StackExpectation + { + public int index; + public int expectedDepth; + } + + private readonly CodeMatcher _matcher; + private readonly List _diagnostics = new List(); + private readonly List _stackExpectations = new List(); + private readonly MethodBase _originalMethod; + private readonly ILGenerator _generator; + private readonly string _callerMod; // For logging context + private readonly System.Diagnostics.Stopwatch _stopwatch; + private readonly List _initialInstructions; + private readonly List _patchEdits = new List(); + private readonly Dictionary _labelIndexCache = new Dictionary(); + private bool _labelIndexCacheDirty = true; + + private FluentTranspiler(IEnumerable instructions, MethodBase originalMethod = null, ILGenerator generator = null) + { + // Cache initial state for diff/timing. + // CRITICAL: We MUST buffer the enumerable here because it might be spent + // by the time the matcher is initialized if we use it directly. + var instructionsList = instructions as List ?? instructions.ToList(); + + _initialInstructions = instructionsList.Select(i => new CodeInstruction(i)).ToList(); // Deep copy initial state + _stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + _matcher = new CodeMatcher(instructionsList, generator); + _originalMethod = originalMethod; + _generator = generator; + _callerMod = ResolveCallingModName(); + } + + /// + /// Creates a fluent transpiler session for a Harmony instruction stream. + /// + /// + /// Use this entry point when a patch needs manual control over the session lifetime, such as + /// choosing strict validation rules or building in multiple stages. For ordinary RimWorld patches, + /// is the simpler entry point. + /// + /// The raw IL instructions provided by the Harmony transpiler delegate. + /// The method being patched. Providing this enables advanced validation. + /// The ILGenerator from the transpiler signature. Required if you intend to use DefineLabel or DeclareLocal. + /// A new instance focused on the provided method. + public static FluentTranspiler For(IEnumerable instructions, MethodBase originalMethod = null, ILGenerator generator = null) + { + return new FluentTranspiler(instructions, originalMethod, generator); + } + + /// + /// Standard object equality. + /// + /// + /// Note: This is a standard C# reference equality check. + /// It is NOT a transpiler matching command. To match an IL sequence, + /// use or . + /// + public new bool Equals(object obj) => base.Equals(obj); + + /// + /// Runs the standard fluent transpiler lifecycle for a Harmony patch. + /// + /// + /// + /// This is the normal entry point for Better Work Tab transpilers. It creates the session, runs the + /// caller's transform, and then finishes through with the framework's default + /// non-strict validation policy. + /// + /// + /// The callback should stay focused on IL intent: find a stable anchor in the target method, + /// assert the match, and apply the smallest safe edit that redirects or replaces that behavior. + /// + /// + /// Usage Example: + /// + /// [HarmonyTranspiler] + /// public static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions, MethodBase original) + /// { + /// return FluentTranspiler.Execute(instructions, original, null, t => { + /// t.MatchCall(typeof(Console), "WriteLine") + /// .ReplaceWith(OpCodes.Nop); + /// }); + /// } + /// + /// + /// + /// + /// The IL instruction stream supplied by Harmony for the method currently being patched. + /// This is the method body that the fluent session will search and rewrite. + /// + /// + /// The RimWorld or mod method that produced . + /// This is used for stack analysis, safety checks, diagnostics, and debug snapshots. + /// + /// + /// The passed into the Harmony transpiler signature. + /// Provide this when the patch needs to define labels or declare locals; otherwise null is valid. + /// + /// + /// The callback that receives the active session. + /// Place all matching, insertion, replacement, and validation calls inside this lambda. + /// + /// A modified instruction stream ready for Harmony consumption. + public static IEnumerable Execute( + IEnumerable instructions, + MethodBase original, + ILGenerator generator, + Action transformer) + { + var transpiler = For(instructions, original, generator); + transformer(transpiler); + return transpiler.Build(TranspilerSafetyPolicy.DefaultExecuteProfile); + } + /// + /// Power-search for a method call using a high-level API. + /// + /// The declaring type (class) of the method. + /// The name of the method. + /// Whether to start from the beginning of the method or continue from current position. + /// Optional types for overload resolution. + /// Optional types for generic methods. + /// If true, matches methods defined in base classes. + public FluentTranspiler FindCall(Type type, string methodName, SearchMode mode = SearchMode.Start, Type[] parameterTypes = null, Type[] genericArguments = null, bool includeInherited = true) + { + if (mode == SearchMode.Start) _matcher.Start(); + else if (mode == SearchMode.Next) _matcher.Advance(1); + + return MatchCallForward(type, methodName, parameterTypes, genericArguments, includeInherited); + } + + /// Match a method call by type and name. Supports overload and generic resolution. + public FluentTranspiler MatchCall(Type type, string methodName, Type[] parameterTypes = null, Type[] genericArguments = null, bool includeInherited = true) + { + return FindCall(type, methodName, SearchMode.Start, parameterTypes, genericArguments, includeInherited); + } + + /// + /// Match a property getter call. + /// Automatically handles both Call and Callvirt opcodes. + /// + /// Declaring type of the property. + /// Name of the property (without "get_" prefix). + public FluentTranspiler MatchPropertyGetter(Type type, string propertyName) + { + return MatchCall(type, "get_" + propertyName, includeInherited: true); + } + + /// Continue matching from current position (for sequential matches). + public FluentTranspiler MatchCallNext(Type type, string methodName, Type[] parameterTypes = null, Type[] genericArguments = null, bool includeInherited = true) + { + return FindCall(type, methodName, SearchMode.Next, parameterTypes, genericArguments, includeInherited); + } + + private FluentTranspiler MatchCallForward(Type type, string methodName, Type[] parameterTypes, Type[] genericArguments, bool includeInherited) + { + var predicate = BuildCallPredicate(type, methodName, parameterTypes, genericArguments, includeInherited); + int preMatch = _matcher.Pos; + _matcher.MatchStartForward(new CodeMatch(predicate)); + + if (!_matcher.IsValid) + { + string details = (genericArguments != null ? $"<{string.Join(", ", genericArguments.Select(t => t.Name).ToArray())}>" : "") + + (parameterTypes != null ? $"({string.Join(", ", parameterTypes.Select(t => t.Name).ToArray())})" : ""); + AddSoftFailure($"No match for call {type.Name}.{methodName}{details}"); + } + else + { + LogTrace($"[FluentTranspiler] MatchCall: Found {type.Name}.{methodName} at index {_matcher.Pos}"); + } + return this; + } + + private Func BuildCallPredicate(Type type, string methodName, Type[] parameterTypes = null, Type[] genericArguments = null, bool includeInherited = true) + { + return instr => + { + if (instr.opcode != OpCodes.Call && instr.opcode != OpCodes.Callvirt) + return false; + if (!(instr.operand is MethodInfo method)) + return false; + + bool typeMatch = includeInherited + ? method.DeclaringType == type + || type.IsAssignableFrom(method.DeclaringType) + || method.DeclaringType.FullName == type.FullName + : method.DeclaringType == type + || method.DeclaringType.FullName == type.FullName; + + if (!typeMatch || method.Name != methodName) return false; + + if (genericArguments != null) + { + if (!method.IsGenericMethod) return false; + var args = method.GetGenericArguments(); + if (args.Length != genericArguments.Length) return false; + for (int i = 0; i < args.Length; i++) + { + if (!args[i].Equals(genericArguments[i]) && + args[i].Name != genericArguments[i].Name && + !(args[i].IsGenericParameter && genericArguments[i].IsGenericParameter)) + return false; + } + } + + if (parameterTypes != null) + { + var ps = method.GetParameters(); + if (ps.Length != parameterTypes.Length) return false; + for (int i = 0; i < parameterTypes.Length; i++) + { + if (ps[i].ParameterType != parameterTypes[i] && + ps[i].ParameterType.FullName != parameterTypes[i].FullName) + return false; + } + } + return true; + }; + } + + /// Unified method to find an OpCode. + public FluentTranspiler FindOpCode(OpCode opcode, SearchMode mode = SearchMode.Start) + { + if (mode == SearchMode.Start) _matcher.Start(); + else if (mode == SearchMode.Next) _matcher.Advance(1); + + _matcher.MatchStartForward(new CodeMatch(opcode)); + + if (!_matcher.IsValid) + { + AddSoftFailure($"No match for opcode {opcode}"); + } + + return this; + } + + /// Match by OpCode. + public FluentTranspiler MatchOpCode(OpCode opcode) + { + return FindOpCode(opcode, SearchMode.Start); + } + + /// Match the return instruction (OpCodes.Ret). + public FluentTranspiler MatchReturn() + { + return MatchOpCode(OpCodes.Ret); + } + + /// Match a sequence of opcodes (pattern matching). + /// Unified method to find a sequence of opcodes. + public FluentTranspiler FindSequence(SearchMode mode, params OpCode[] opcodes) + { + if (mode == SearchMode.Start) _matcher.Start(); + else if (mode == SearchMode.Next) _matcher.Advance(1); + + var matches = opcodes.Select(op => new CodeMatch(op)).ToArray(); + _matcher.MatchStartForward(matches); + + if (!_matcher.IsValid) + { + AddSoftFailure($"Sequence not found: {string.Join(" -> ", opcodes.Select(o => o.Name).ToArray())}"); + } + + return this; + } + + /// Match a sequence of opcodes (pattern matching). + public FluentTranspiler MatchSequence(params OpCode[] opcodes) + { + return FindSequence(SearchMode.Start, opcodes); + } + + /// Match a field load (Ldfld or Ldsfld). + /// Unified method to find a field load. + public FluentTranspiler FindFieldLoad(Type type, string fieldName, SearchMode mode = SearchMode.Start) + { + if (mode == SearchMode.Start) _matcher.Start(); + else if (mode == SearchMode.Next) _matcher.Advance(1); + + Func predicate = instr => + (instr.opcode == OpCodes.Ldfld || instr.opcode == OpCodes.Ldsfld) && + instr.operand is FieldInfo f && + f.DeclaringType == type && + f.Name == fieldName; + + _matcher.MatchStartForward(new CodeMatch(predicate)); + if (!_matcher.IsValid) AddSoftFailure($"No match for field load {type.Name}.{fieldName}"); + return this; + } + + /// Match a field load (Ldfld or Ldsfld). + public FluentTranspiler MatchFieldLoad(Type type, string fieldName) + { + return FindFieldLoad(type, fieldName, SearchMode.Start); + } + + /// Match a field store (Stfld or Stsfld). + /// Unified method to find a field store. + public FluentTranspiler FindFieldStore(Type type, string fieldName, SearchMode mode = SearchMode.Start) + { + if (mode == SearchMode.Start) _matcher.Start(); + else if (mode == SearchMode.Next) _matcher.Advance(1); + + Func predicate = instr => + (instr.opcode == OpCodes.Stfld || instr.opcode == OpCodes.Stsfld) && + instr.operand is FieldInfo f && + f.DeclaringType == type && + f.Name == fieldName; + + _matcher.MatchStartForward(new CodeMatch(predicate)); + if (!_matcher.IsValid) + { + AddSoftFailure($"No match for field store {type.Name}.{fieldName}"); + } + else + { + LogTrace($"[FluentTranspiler] FindFieldStore: Found {type.Name}.{fieldName} at index {_matcher.Pos}"); + } + return this; + } + + /// Match a field store (Stfld or Stsfld). + public FluentTranspiler MatchFieldStore(Type type, string fieldName) + { + return FindFieldStore(type, fieldName, SearchMode.Start); + } + + /// Unified method to find a string load. + public FluentTranspiler FindString(string value, SearchMode mode = SearchMode.Start) + { + if (mode == SearchMode.Start) _matcher.Start(); + else if (mode == SearchMode.Next) _matcher.Advance(1); + + _matcher.MatchStartForward(new CodeMatch(OpCodes.Ldstr, value)); + if (!_matcher.IsValid) + AddSoftFailure($"No match for string \"{value}\""); + return this; + } + + /// + /// Search for a string constant (Ldstr). + /// + /// The exact string value to find. + public FluentTranspiler MatchString(string value) + { + return FindString(value, SearchMode.Start); + } + + /// + /// Search for an integer constant load. + /// Automatically handles Ldc_I4_0 through Ldc_I4_S/Inline. + /// + /// The integer value to find. + /// Whether to start from the beginning or continue. + public FluentTranspiler FindConstInt(int value, SearchMode mode = SearchMode.Start) + { + if (mode == SearchMode.Start) _matcher.Start(); + else if (mode == SearchMode.Next) _matcher.Advance(1); + + _matcher.MatchStartForward(new CodeMatch(instr => + instr.IsLdcI4(value))); + if (!_matcher.IsValid) + AddSoftFailure($"No match for int constant {value}"); + return this; + } + + /// Match a constant integer load. + public FluentTranspiler MatchConstInt(int value) + { + return FindConstInt(value, SearchMode.Start); + } + + /// Match a constant float load. + /// Unified method to find a float constant. + public FluentTranspiler FindConstFloat(float value, SearchMode mode = SearchMode.Start) + { + if (mode == SearchMode.Start) _matcher.Start(); + else if (mode == SearchMode.Next) _matcher.Advance(1); + + _matcher.MatchStartForward(new CodeMatch(instr => + instr.IsLdcR4(value))); + if (!_matcher.IsValid) + AddSoftFailure($"No match for float constant {value}"); + return this; + } + + /// Match a constant float load. + public FluentTranspiler MatchConstFloat(float value) + { + return FindConstFloat(value, SearchMode.Start); + } + + /// + /// Highly resilient helper to extract a local variable index from a previous match. + /// + /// + /// This uses Harmony's "Named Match" feature. If you matched an instruction using + /// .MatchStoreLocal("myVar"), you can call this to get the integer index + /// the compiler assigned to that variable. + /// + /// The name assigned to the match via the expressive API or CodeMatch. + /// The local variable index (0-N), or -1 if not found. + public int CaptureLocalIndex(string matchName) + { + // Use Harmony's NamedMatch feature (Line 699 in their source) + // to pull the instruction that was matched by name. + var match = _matcher.NamedMatch(matchName); + if (match == null) return -1; + + if (match.operand is int idx) return idx; + if (match.opcode.ToString().Contains(".")) + { + // Handles stloc.0, stloc.1 etc which have the index in the opcode name + var parts = match.opcode.ToString().Split('.'); + if (parts.Length > 1 && int.TryParse(parts[1], out int opcodeIdx)) return opcodeIdx; + } + return -1; + } + + #region Expressive Matching (English-like API) + + public FluentTranspiler MatchCall(MethodInfo method, string name = null) + { + var cm = CodeMatch.Calls(method); + cm.name = name; + _matcher.MatchStartForward(cm); + return this; + } + + public FluentTranspiler MatchLoadField(FieldInfo field, string name = null) + { + var cm = CodeMatch.LoadsField(field); + cm.name = name; + _matcher.MatchStartForward(cm); + return this; + } + + public FluentTranspiler MatchStoreField(FieldInfo field, string name = null) + { + var cm = CodeMatch.StoresField(field); + cm.name = name; + _matcher.MatchStartForward(cm); + return this; + } + + public FluentTranspiler MatchLoadLocal(string name = null) + { + _matcher.MatchStartForward(CodeMatch.LoadsLocal(false, name)); + return this; + } + + public FluentTranspiler MatchStoreLocal(string name = null) + { + _matcher.MatchStartForward(CodeMatch.StoresLocal(name)); + return this; + } + + public FluentTranspiler MatchLoadArgument(int? index = null, string name = null) + { + var cm = CodeMatch.IsLdarg(index); + cm.name = name; + _matcher.MatchStartForward(cm); + return this; + } + + public FluentTranspiler MatchBranch(string name = null) + { + _matcher.MatchStartForward(CodeMatch.Branches(name)); + return this; + } + + public FluentTranspiler Matches(params CodeMatch[] matches) + { + _matcher.MatchStartForward(matches); + return this; + } + + public FluentTranspiler MatchLoadConstant(string value, string name = null) + { + var cm = CodeMatch.LoadsConstant(value); + cm.name = name; + _matcher.MatchStartForward(cm); + return this; + } + + public FluentTranspiler MatchLoadConstant(long value, string name = null) + { + var cm = CodeMatch.LoadsConstant(value); + cm.name = name; + _matcher.MatchStartForward(cm); + return this; + } + + public FluentTranspiler MatchNewObject(ConstructorInfo ctor, string name = null) + { + _matcher.MatchStartForward(new CodeMatch(OpCodes.Newobj, ctor, name)); + return this; + } + + #endregion + + public FluentTranspiler MatchFuzzy(Func predicate, string name = null) + { + _matcher.MatchStartForward(new CodeMatch(predicate, name)); + return this; + } + + /// + /// Automatically backtracks and replaces an entire value assignment sequence. + /// + /// + /// Why use this? + /// + /// In standard IL, replacing `x = y + 1` is hard because you have to figure out exactly where the code + /// *started* pushing values for that line. + /// + /// + /// ReplaceAssignment uses the Stack Sentinel to do that work for you. It scans backwards + /// from your current position, finds the exact "root" of the expression, and swaps the + /// whole block. It saves you from having to manually count `ldarg` or `ldloc` instructions. + /// + /// + /// The instructions that should now generate and store the value. + public FluentTranspiler ReplaceAssignment(CodeInstruction[] newExpression) + { + // Backtrack to find the start of the expression that leads to the current stloc + // In Sheltered's IL, this is usually a ldarg.0 or a sequence starting with a load. + int endIdx = _matcher.Pos; + int startIdx = BacktrackToExpressionStart(endIdx); + int removeCount = endIdx - startIdx + 1; + + MoveTo(startIdx); + ReplaceSequence(removeCount, newExpression); + return this; + } + + private int BacktrackToExpressionStart(int currentPos) + { + // Robust stack analysis: We need to find the instruction where + // the value currently being stored was first pushed. + var instructions = _matcher.Instructions(); + var stackAnalysis = StackSentinel.Analyze(instructions, _originalMethod, out _); + + if (stackAnalysis == null || !stackAnalysis.TryGetValue(currentPos, out var targetStack)) + { + AddNote($"Backtrack failed: Could not analyze stack at index {currentPos}. Falling back to conservative match."); + return currentPos; + } + + // We are looking for the point where the stack depth was exactly + // targetStack.Count - 1 (i.e., the depth before the current value was pushed). + int targetDepth = Math.Max(0, targetStack.Count - 1); + + for (int i = currentPos - 1; i >= 0; i--) + { + if (stackAnalysis.TryGetValue(i, out var prevStack)) + { + // If we found a point where the stack was at our target depth, + // that's the start of the expression sequence. + if (prevStack.Count == targetDepth) return i; + } + } + return currentPos; + } + + /// Reset to beginning. + public FluentTranspiler Reset() + { + _matcher.Start(); + return this; + } + + #region Modification Methods + + /// + /// Replace current instruction with a new OpCode and operand. + /// Automatically preserves any labels attached to the original instruction. + /// + public FluentTranspiler ReplaceWith(OpCode opcode, object operand = null) + { + if (!_matcher.IsValid) + { + AddSoftFailure("ReplaceWith: No valid match."); + return this; + } + var beforeIndex = _matcher.Pos; + var oldInstr = _matcher.Instruction; + var newInstr = new CodeInstruction(opcode, operand); + SetInstructionSafe(newInstr); + RecordPatchEdit("ReplaceWith", beforeIndex, new[] { oldInstr }, beforeIndex, new[] { newInstr }, "Single instruction replacement", "exact"); + return this; + } + + /// + /// Replace current match with a call to a static replacement method. + /// Automatically handles label preservation and validates that the target is static. + /// + /// The class containing your static hook. + /// The name of the static method. + /// Optional parameter types for overload resolution. + /// + /// Replaces the current instruction with a call to a static hook. + /// Automatically handles label preservation and ensures the target method is compatible. + /// + /// + /// Warning: The target method MUST be static. If you are replacing an instance + /// method call, the target method should usually accept the 'this' instance as its first argument + /// to maintain stack balance. + /// + /// The mod class containing the static replacement method. + /// The name of the static method. + /// Optional parameter types for overload resolution. + public FluentTranspiler ReplaceWithCall(Type type, string methodName, Type[] parameterTypes = null) + { + if (!_matcher.IsValid) + { + AddSoftFailure("ReplaceWithCall: No valid match."); + return this; + } + + MethodInfo method; + if (parameterTypes != null) + { + method = type.GetMethod(methodName, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, + null, parameterTypes, null); + } + else + { + method = type.GetMethod(methodName, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + } + + if (method == null) + { + AddWarning($"Method {type.Name}.{methodName} not found"); + return this; + } + + // Transpiler replacements replace an instance or static call with a static call. + // Replacing an instance call with a non-static method would lead to an invalid + // stack state (missing 'this' pointer). + if (!method.IsStatic) + { + AddWarning($"Method {type.Name}.{methodName} must be static for transpiler replacement"); + return this; + } + + var beforeIndex = _matcher.Pos; + var oldInstr = _matcher.Instruction; + var newInstr = new CodeInstruction(OpCodes.Call, method); + SetInstructionSafe(newInstr); + RecordPatchEdit("ReplaceWithCall", beforeIndex, new[] { oldInstr }, beforeIndex, new[] { newInstr }, $"{type.Name}.{methodName}", "exact"); + return this; + } + + /// + /// Safely inserts instructions at the very beginning of the method. + /// Automatically handles label preservation from the original first instruction. + /// + public FluentTranspiler InsertAtStart(params CodeInstruction[] instructions) + { + return Reset().InsertBefore(instructions); + } + + /// + /// Safely inserts instructions before the final 'ret' instruction. + /// If multiple returns exist, it inserts before ALL of them. + /// + public FluentTranspiler InsertAtExit(params CodeInstruction[] instructions) + { + _matcher.Start(); + int count = 0; + while (_matcher.MatchStartForward(new CodeMatch(OpCodes.Ret)).IsValid) + { + InsertBefore(instructions); + _matcher.Advance(instructions.Length + 1); // Skip what we just added + the ret + count++; + } + if (count == 0) AddSoftFailure("InsertAtExit: No return instructions found."); + return this; + } + + /// Insert instruction before current position (handles branch fixups automatically). + public FluentTranspiler InsertBefore(OpCode opcode, object operand = null) + { + if (!_matcher.IsValid) + { + AddSoftFailure("InsertBefore: No valid match."); + return this; + } + + var newInstr = new CodeInstruction(opcode, operand); + + // Transfer labels so branches land on the new instruction + var existingLabels = _matcher.Instruction.labels; + if (existingLabels.Count > 0) + { + newInstr.labels.AddRange(existingLabels); + existingLabels.Clear(); + } + var existingBlocks = _matcher.Instruction.blocks; + if (existingBlocks.Count > 0) + { + newInstr.blocks.AddRange(existingBlocks); + existingBlocks.Clear(); + } + + var insertIndex = _matcher.Pos; + _matcher.Insert(newInstr); + InvalidateLabelIndexCache(); + RecordPatchEdit("InsertBefore", insertIndex, null, insertIndex, new[] { newInstr }, "Insert before current", "exact"); + return this; + } + + /// + /// Inserts a sequence of instructions BEFORE the current position. + /// Automatically transfers labels from the original instruction to the FIRST new instruction. + /// + /// + /// This is the safest way to inject logic at a branch target, as it ensures that jumps + /// intended for the original instruction now land on your injected logic. + /// + /// The array of instructions to insert. + public FluentTranspiler InsertBefore(params CodeInstruction[] instructions) + { + if (!_matcher.IsValid) + { + AddSoftFailure("InsertBefore: No valid match."); + return this; + } + if (instructions == null) + { + AddWarning("InsertBefore: instruction array cannot be null."); + return this; + } + + // Clone caller-provided instructions so labels/operands edits here do not leak back to caller state. + var toInsert = instructions.Select(i => new CodeInstruction(i)).ToArray(); + + // Transfer labels to first new instruction + var existingLabels = _matcher.Instruction.labels; + if (existingLabels.Count > 0 && toInsert.Length > 0) + { + toInsert[0].labels.AddRange(existingLabels); + existingLabels.Clear(); + } + var existingBlocks = _matcher.Instruction.blocks; + if (existingBlocks.Count > 0 && toInsert.Length > 0) + { + toInsert[0].blocks.AddRange(existingBlocks); + existingBlocks.Clear(); + } + + var insertIndex = _matcher.Pos; + for (int i = toInsert.Length - 1; i >= 0; i--) + { + _matcher.Insert(toInsert[i]); + } + InvalidateLabelIndexCache(); + RecordPatchEdit("InsertBefore", insertIndex, null, insertIndex, toInsert, "Insert sequence before current", "exact"); + return this; + } + + /// + /// Insert after current. Matcher stays on the ORIGINAL instruction. + /// + public FluentTranspiler InsertAfter(OpCode opcode, object operand = null) + { + if (!_matcher.IsValid) + { + AddSoftFailure("InsertAfter: No valid match."); + return this; + } + + var insertIndex = _matcher.Pos + 1; + _matcher.Advance(1); + var newInstr = new CodeInstruction(opcode, operand); + _matcher.Insert(newInstr); + _matcher.Advance(-1); // Return to original position + InvalidateLabelIndexCache(); + RecordPatchEdit("InsertAfter", insertIndex, null, insertIndex, new[] { newInstr }, "Insert after current", "exact"); + return this; + } + + /// + /// Inserts a sequence of instructions AFTER the current position. + /// The matcher remains on the ORIGINAL instruction. + /// + /// + /// This is useful for injecting logic that should execute immediately after a + /// prerequisite operation without moving the "cursor" of the transpiler. + /// + /// The array of instructions to insert. + public FluentTranspiler InsertAfter(params CodeInstruction[] instructions) + { + if (!_matcher.IsValid) + { + AddSoftFailure("InsertAfter: No valid match."); + return this; + } + if (instructions == null) + { + AddWarning("InsertAfter: instruction array cannot be null."); + return this; + } + + // Clone caller-provided instructions so insertion does not alias mutable instruction objects. + var toInsert = instructions.Select(i => new CodeInstruction(i)).ToArray(); + + var insertIndex = _matcher.Pos + 1; + _matcher.Advance(1); + foreach (var instr in toInsert) + { + _matcher.InsertAndAdvance(instr); + } + _matcher.Advance(-toInsert.Length - 1); // Restore to original position + InvalidateLabelIndexCache(); + RecordPatchEdit("InsertAfter", insertIndex, null, insertIndex, toInsert, "Insert sequence after current", "exact"); + return this; + } + + /// Remove current instruction. + public FluentTranspiler Remove() + { + if (!_matcher.IsValid) + { + AddSoftFailure("Remove: No valid match."); + return this; + } + + return ReplaceSequence(1, new CodeInstruction[0]); + } + + #endregion + + #region ILGenerator Wrappers + + /// + /// Declare a new local variable for use in the transpiler. + /// Throws if ILGenerator was not provided during construction. + /// + public FluentTranspiler DeclareLocal(out LocalBuilder local) + { + if (_generator == null) + { + throw new InvalidOperationException( + "ILGenerator was not provided. " + + "Pass it to For(instructions, originalMethod, generator). " + + "Transpiler delegate signature: " + + "IEnumerable Transpiler(IEnumerable instr, ILGenerator gen)"); + } + + local = _generator.DeclareLocal(typeof(T)); + AddNote($"DeclareLocal<{typeof(T).FullName}>() -> LocalIndex {local.LocalIndex}"); + return this; + } + + /// + /// Define a new label for branching. + /// Throws if ILGenerator was not provided during construction. + /// + public FluentTranspiler DefineLabel(out Label label) + { + if (_generator == null) + { + throw new InvalidOperationException( + "ILGenerator was not provided. " + + "Pass it to For(instructions, originalMethod, generator)."); + } + + label = _generator.DefineLabel(); + return this; + } + + #endregion + + #region Variable Capture + + /// + /// Automatically emits an Ldloc instruction for a local variable by index or name. + /// + /// The index (e.g. "0") or name (if symbols available) of the local variable. + public FluentTranspiler CaptureLocal(string localIndexOrName) + { + if (!_matcher.IsValid) return this; + + // 1. Try to parse as index + if (int.TryParse(localIndexOrName, out int index)) + { + _matcher.Insert(new CodeInstruction(GetLdlocOpCode(index), index > 3 ? (object)index : null)); + return this; + } + + // 2. Try to find by name via reflection (requires debug symbols on the original method) + if (_originalMethod != null) + { + try + { + // Note: Standard LocalVariableInfo doesn't have names. + // This is a placeholder for environments where names might be injected or available via metadata. + var locals = _originalMethod.GetMethodBody()?.LocalVariables; + if (locals != null) + { + foreach (var local in locals) + { + // In some contexts (like DynamicMethod or specific debug builds), + // we might be able to resolve names. For now, we log a warning if we can't find it. + } + } + } + catch { } + } + + AddNote($"CaptureLocal: Could not resolve variable '{localIndexOrName}' by name. Use numeric index instead."); + return this; + } + + private OpCode GetLdlocOpCode(int index) + { + switch (index) + { + case 0: return OpCodes.Ldloc_0; + case 1: return OpCodes.Ldloc_1; + case 2: return OpCodes.Ldloc_2; + case 3: return OpCodes.Ldloc_3; + default: return OpCodes.Ldloc_S; + } + } + + #endregion + + #region Control Flow Navigation + + /// Move forward or backward by N instructions. + public FluentTranspiler Advance(int count) + { + _matcher.Advance(count); + return this; + } + + #endregion + + #region Bulk Operations + + /// + /// Replace ALL occurrences of a specific method call throughout the entire instruction stream. + /// Handles labels correctly and uses resilient type matching (by Name/FullName). + /// + /// The class containing the method to replace. + /// The name of the method to replace. + /// Your class containing the replacement static method. + /// The name of your static replacement method. + /// Optional parameter types for target overload resolution. + public FluentTranspiler ReplaceAllCalls(Type sourceType, string sourceMethod, + Type targetType, string targetMethod, Type[] targetParams = null) + { + _matcher.Start(); + int replacements = 0; + + var targetMethodInfo = targetParams != null + ? targetType.GetMethod(targetMethod, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, null, targetParams, null) + : targetType.GetMethod(targetMethod, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + + if (targetMethodInfo == null) + { + AddWarning($"Target method {targetType.Name}.{targetMethod} not found"); + return this; + } + + if (!targetMethodInfo.IsStatic) + { + AddWarning($"Target method {targetType.Name}.{targetMethod} must be static"); + return this; + } + + while (_matcher.IsValid) + { + _matcher.MatchStartForward(new CodeMatch(instr => + (instr.opcode == OpCodes.Call || instr.opcode == OpCodes.Callvirt) && + instr.operand is MethodInfo m && + (m.DeclaringType == sourceType || sourceType.IsAssignableFrom(m.DeclaringType) || m.DeclaringType.FullName == sourceType.FullName) && + m.Name == sourceMethod)); + + if (_matcher.IsValid) + { + var beforeIndex = _matcher.Pos; + var oldInstr = _matcher.Instruction; + var newInstr = new CodeInstruction(OpCodes.Call, targetMethodInfo); + SetInstructionSafe(newInstr); + RecordPatchEdit("ReplaceAllCalls", beforeIndex, new[] { oldInstr }, beforeIndex, new[] { newInstr }, $"{sourceType.Name}.{sourceMethod} -> {targetType.Name}.{targetMethod}", "exact"); + _matcher.Advance(1); + replacements++; + } + } + + if (replacements == 0) + { + AddSoftFailure($"No instances of {sourceType.Name}.{sourceMethod} found"); + } + + return this; + } + + #endregion + + #region Navigation & Debugging + + /// Check if current position is valid. + public bool HasMatch => _matcher.IsValid; + + /// Get current instruction (or null). + public CodeInstruction Current => _matcher.IsValid ? _matcher.Instruction : null; + + /// Get current index. + public int CurrentIndex => _matcher.Pos; + + /// Move to next instruction. + public FluentTranspiler Next() + { + _matcher.Advance(1); + return this; + } + + /// Move to previous instruction. + public FluentTranspiler Previous() + { + _matcher.Advance(-1); + return this; + } + + /// Get all warnings that occurred. + public IList Warnings + { + get + { + return _diagnostics + .Where(d => d.Severity == TranspilerDiagnosticSeverity.Warning) + .Select(d => d.Message) + .ToList() + .AsReadOnly(); + } + } + + /// Non-fatal match failures and probe misses captured during patch construction. + public IList SoftFailures + { + get + { + return _diagnostics + .Where(d => d.Severity == TranspilerDiagnosticSeverity.SoftFailure) + .Select(d => d.Message) + .ToList() + .AsReadOnly(); + } + } + + /// Diagnostic notes collected during patch construction. + public IList Notes + { + get + { + return _diagnostics + .Where(d => d.Severity == TranspilerDiagnosticSeverity.Note) + .Select(d => d.Message) + .ToList() + .AsReadOnly(); + } + } + + /// Structured diagnostics for callers that need severity/category instead of raw strings. + public IList Diagnostics { get { return _diagnostics.AsReadOnly(); } } + + /// Add a warning to the transpiler state. + public void AddWarning(string message) + { + AddDiagnostic(TranspilerDiagnosticSeverity.Warning, ClassifyDiagnostic(message, TranspilerDiagnosticSeverity.Warning), message); + } + + /// Add a warning with an explicit category. + public void AddWarning(TranspilerDiagnosticCategory category, string message) + { + AddDiagnostic(TranspilerDiagnosticSeverity.Warning, category, message); + } + + /// Add a soft failure that should not be treated as a build warning by default. + public void AddSoftFailure(string message) + { + AddDiagnostic(TranspilerDiagnosticSeverity.SoftFailure, ClassifyDiagnostic(message, TranspilerDiagnosticSeverity.SoftFailure), message); + } + + /// Add a soft failure with an explicit category. + public void AddSoftFailure(TranspilerDiagnosticCategory category, string message) + { + AddDiagnostic(TranspilerDiagnosticSeverity.SoftFailure, category, message); + } + + /// Add a diagnostic note that should only surface in verbose/debug tooling. + public void AddNote(string message) + { + AddDiagnostic(TranspilerDiagnosticSeverity.Note, ClassifyDiagnostic(message, TranspilerDiagnosticSeverity.Note), message); + } + + /// Add a diagnostic note with an explicit category. + public void AddNote(TranspilerDiagnosticCategory category, string message) + { + AddDiagnostic(TranspilerDiagnosticSeverity.Note, category, message); + } + + /// Centralized typed diagnostic writer used by all helper entry points. + public void AddDiagnostic(TranspilerDiagnosticSeverity severity, TranspilerDiagnosticCategory category, string message) + { + if (string.IsNullOrEmpty(message)) return; + _diagnostics.Add(new TranspilerDiagnostic + { + Severity = severity, + Category = category, + Message = message + }); + } + + /// Throw an exception immediately if the current operation has no match. + public FluentTranspiler AssertValid() + { + if (!_matcher.IsValid) + { + string failure = SoftFailures.LastOrDefault() + ?? Warnings.LastOrDefault() + ?? "Unknown error"; + throw new InvalidOperationException($"[{_callerMod}] AssertValid failed: {failure} in method {_originalMethod?.DeclaringType.Name}.{_originalMethod?.Name}"); + } + return this; + } + + /// Log current state to console. + public FluentTranspiler Log(string label = "") + { + var warnings = Warnings; + var softFailures = SoftFailures; + LogTrace($"[FluentTranspiler:{_callerMod}] {label}"); + LogTrace($" Position: {_matcher.Pos}, Valid: {_matcher.IsValid}"); + if (_matcher.IsValid) + { + LogTrace($" Current: {_matcher.Instruction}"); + } + if (warnings.Count > 0) + { + LogTrace($" Warnings: {warnings.Count}"); + } + if (softFailures.Count > 0) + { + LogTrace($" SoftFailures: {softFailures.Count}"); + } + return this; + } + + /// Dump all instructions to log (expensive, debugging only). + public FluentTranspiler DumpAll(string label = "") + { + var instructions = _matcher.Instructions(); + LogTrace($"[FluentTranspiler:{_callerMod}] {label} ({instructions.Count} instructions):"); + for (int i = 0; i < instructions.Count; i++) + { + string marker = (i == _matcher.Pos) ? " >>>" : " "; + LogTrace($"{marker}{i:D3}: {instructions[i]}"); + } + return this; + } + + /// Get copy of current instructions. + public IEnumerable Instructions() + { + return _matcher.Instructions(); + } + + #endregion + + #region Pattern Matching & Safer Operations + + /// + /// Move backwards in the instruction stream. + /// Safer than Previous() for checking context before removals. + /// + /// Absolute index to move to. + public FluentTranspiler MoveTo(int absolutePosition) + { + var instructions = _matcher.Instructions().ToList(); + if (absolutePosition < 0 || absolutePosition >= instructions.Count) + { + AddSoftFailure($"MoveTo: Position {absolutePosition} out of range."); + return this; + } + + _matcher.Start().Advance(absolutePosition); + return this; + } + + /// + /// Replaces a range of instructions with a new sequence. + /// Optimized for replacing entire blocks of logic (e.g., an 'if' statement body). + /// + /// + /// This method includes a **Safety Analysis**: if a branch in the method body + /// targets an instruction INSIDE the range being removed, the transpiler will + /// throw a warning to prevent corruption. It also automatically preserves labels + /// from the first removed instruction. + /// + /// The number of original instructions to delete. + /// The instructions to insert in their place. + public FluentTranspiler ReplaceSequence(int removeCount, params CodeInstruction[] newInstructions) + { + return ReplaceSequence(removeCount, true, newInstructions); + } + + /// + /// Internal variant of that allows + /// callers to skip automatic patch-edit recording when they capture higher-level edits. + /// + internal FluentTranspiler ReplaceSequence(int removeCount, bool recordPatchEdit, params CodeInstruction[] newInstructions) + { + if (!_matcher.IsValid) + { + AddSoftFailure("ReplaceSequence: No valid match."); + return this; + } + if (removeCount < 0) + { + AddWarning("ReplaceSequence: removeCount cannot be negative."); + return this; + } + if (newInstructions == null) + { + AddWarning("ReplaceSequence: replacement instructions cannot be null."); + return this; + } + + var beforeIndex = _matcher.Pos; + var originalInstructions = _matcher.Instructions().ToList(); + var snapshot = originalInstructions.Select(i => new CodeInstruction(i)).ToList(); + int snapshotPos = _matcher.IsValid ? _matcher.Pos : 0; + if (beforeIndex < 0 || beforeIndex + removeCount > originalInstructions.Count) + { + AddWarning($"[CRITICAL SAFETY] ReplaceSequence range out of bounds (start={beforeIndex}, removeCount={removeCount}, methodLength={originalInstructions.Count}). Aborting."); + return this; + } + + // 1. Analyze: Capture labels and check for hazardous jumps + List public void EnsurePriorityOrder() { + MaxPriority = Mathf.Max(1, BetterWorkTabMod.Settings?.maxPriorityInt ?? MaxPriority); + if ((PriorityOrder == null || PriorityOrder.Count == 0) && SelectedRuleset?.PriorityOrder != null && SelectedRuleset.PriorityOrder.Count > 0) diff --git a/Source/UI/Settings/BWTSettingsRegistry.cs b/Source/UI/Settings/BWTSettingsRegistry.cs index 9ad4bd3..40d058d 100644 --- a/Source/UI/Settings/BWTSettingsRegistry.cs +++ b/Source/UI/Settings/BWTSettingsRegistry.cs @@ -203,6 +203,68 @@ private static void RegisterAllSettings() SortOrder = -43 }); + Register(new SettingDefinition + { + Id = UiMaxPriority, + ParentId = FeaturesUiElements, + FieldName = "maxPriorityInt", + Label = "Max priority", + Tooltip = "The maximum integer value pawns can be assigned in the work tab.", + Type = SettingType.Int, + DefaultValue = DefaultSettings.maxPriority, + MinValue = 4f, + MaxValue = BetterWorkTabSettings.MAX_PRIORITY_HARD_LIMIT, + ShowInSimpleView = true, + SortOrder = 42, + }); + + Register(new SettingDefinition + { + Id = UiPriorityColorPercentageGreen, + ParentId = FeaturesUiElements, + FieldName = "priorityColorPercentage_Green", + Label = "Color Percentage - Green", + Tooltip = "The percentage at which numbers will be green when displayed on the work tab.", + Type = SettingType.Int, + DefaultValue = DefaultSettings.priorityColorPercentage_Green, + MinValue = 1, + MaxValue = 100, + ShowInSimpleView = false, + ShowInAdvancedView = true, + SortOrder = 43, + }); + + Register(new SettingDefinition + { + Id = UiPriorityColorPercentageYellow, + ParentId = FeaturesUiElements, + FieldName = "priorityColorPercentage_Yellow", + Label = "Color Percentage - Yellow", + Tooltip = "The percentage at which numbers will be yellow when displayed on the work tab.", + Type = SettingType.Int, + DefaultValue = DefaultSettings.priorityColorPercentage_Yellow, + MinValue = 1, + MaxValue = 100, + ShowInSimpleView = false, + ShowInAdvancedView = true, + SortOrder = 44, + }); + + Register(new SettingDefinition + { + Id = UiPriorityColorPercentageTan, + ParentId = FeaturesUiElements, + FieldName = "priorityColorPercentage_Tan", + Label = "Color Percentage - Tan", + Tooltip = "The percentage at which numbers will be tan when displayed on the work tab.", + Type = SettingType.Int, + DefaultValue = DefaultSettings.priorityColorPercentage_Tan, + MinValue = 1, + MaxValue = 100, + ShowInSimpleView = false, + ShowInAdvancedView = true, + SortOrder = 45, + }); Register(new SettingDefinition { @@ -1410,7 +1472,7 @@ private static void RegisterAllSettings() } }, ShowInSimpleView = false, - ShowInAdvancedView = false, + ShowInAdvancedView = true, //false SortOrder = 501 }); @@ -1436,7 +1498,7 @@ private static void RegisterAllSettings() EnsureInitialized(); }, ShowInSimpleView = false, - ShowInAdvancedView = false, + ShowInAdvancedView = true, //false SortOrder = 502 }); } diff --git a/Source/UI/Settings/SettingIDs.cs b/Source/UI/Settings/SettingIDs.cs index aaab0bb..82350a1 100644 --- a/Source/UI/Settings/SettingIDs.cs +++ b/Source/UI/Settings/SettingIDs.cs @@ -91,6 +91,10 @@ public static class SettingIDs public const string UiDragInstructions = "ui.dragInstructions"; public const string UiManualPriorities = "ui.manualPriorities"; public const string UiPriorityLegend = "ui.priorityLegend"; + public const string UiMaxPriority = "ui.maxPriority"; + public const string UiPriorityColorPercentageGreen = "ui.priorityColorPercentageGreen"; + public const string UiPriorityColorPercentageYellow = "ui.priorityColorPercentageYellow"; + public const string UiPriorityColorPercentageTan = "ui.priorityColorPercentageTan"; public const string WorkloadsPersistDividers = "workloads.persistDividers"; public const string HighlightsDisableBestPawn = "highlights.disableBestPawn"; public const string HighlightsBestPawnBackground = "highlights.bestPawnBackground"; diff --git a/Source/UpgradeLog.htm b/Source/UpgradeLog.htm new file mode 100644 index 0000000..d9b3659 --- /dev/null +++ b/Source/UpgradeLog.htm @@ -0,0 +1,268 @@ + + + + Migration Report +

+ Migration Report -

\ No newline at end of file diff --git a/Source/UpgradeLog2.htm b/Source/UpgradeLog2.htm new file mode 100644 index 0000000..73bc210 --- /dev/null +++ b/Source/UpgradeLog2.htm @@ -0,0 +1,268 @@ + + + + Migration Report +

+ Migration Report -

\ No newline at end of file diff --git a/Source/UpgradeLog3.htm b/Source/UpgradeLog3.htm new file mode 100644 index 0000000..b4e95b5 --- /dev/null +++ b/Source/UpgradeLog3.htm @@ -0,0 +1,268 @@ + + + + Migration Report +

+ Migration Report -

\ No newline at end of file diff --git a/Source/UpgradeLog4.htm b/Source/UpgradeLog4.htm new file mode 100644 index 0000000..dcef740 --- /dev/null +++ b/Source/UpgradeLog4.htm @@ -0,0 +1,268 @@ + + + + Migration Report +

+ Migration Report -

\ No newline at end of file