Last active
March 8, 2026 00:33
-
-
Save Ooseykins/244c9ba60ddbbad6760d5aed62311f39 to your computer and use it in GitHub Desktop.
CloverPit performance patches for very high deadlines
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System.Collections.Generic; | |
| using System.IO; | |
| using System.Linq; | |
| using System.Numerics; | |
| using System.Reflection; | |
| using System.Reflection.Emit; | |
| using BepInEx; | |
| using Cysharp.Threading.Tasks; | |
| using HarmonyLib; | |
| using Panik; | |
| using UnityEngine; | |
| namespace AethaPerformancePatches | |
| { | |
| // Tested on CloverPit Build ID: 21194948 | |
| // c# 8.0 build for .net 4.7.2 | |
| [BepInPlugin("Aetha.CloverPerformance", "CloverPerformance", "1.0.0")] | |
| public class CloverPerformance : BaseUnityPlugin | |
| { | |
| private const string ConfigFilename = "CloverPerformanceConfig.json"; | |
| private static string AssemblyDirectory => Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); | |
| private static string ConfigPath => AssemblyDirectory + "/" + ConfigFilename; | |
| public static PerformancePatchConfig config = new PerformancePatchConfig(); | |
| public class PerformancePatchConfig | |
| { | |
| public bool SkipRerollSaves = true; // Skip saving the game on common events: store restocks and phone rerolls | |
| public bool CacheStrings = true; // Cache sanitized strings until a major game state update | |
| public bool CacheBigIntegerMath = true; // Patch some methods to cache their return values for one frame so they are not called multiple times per frame | |
| public bool PatchStringsSanitize = true; // Skip certain string.Replace calls in Strings.Sanitize which would otherwise perform very heavy calculations | |
| } | |
| private void Awake() | |
| { | |
| if (Directory.Exists(AssemblyDirectory)) | |
| { | |
| if (File.Exists(ConfigPath)) | |
| { | |
| config = JsonUtility.FromJson<PerformancePatchConfig>(File.ReadAllText(ConfigPath)); | |
| } | |
| else | |
| { | |
| File.WriteAllText(ConfigPath, JsonUtility.ToJson(config)); | |
| } | |
| } | |
| new Harmony("Aetha.PerformancePatches").PatchAll(); | |
| } | |
| } | |
| #region SkipRerollSaves | |
| [HarmonyPatch(typeof(Data))] | |
| static class DataPatches | |
| { | |
| [HarmonyPrepare] | |
| static bool Prepare() | |
| { | |
| return CloverPerformance.config.SkipRerollSaves; | |
| } | |
| // Stop saving so damn often, just lie and said it went OK | |
| [HarmonyPrefix] | |
| [HarmonyPatch("SaveGame")] | |
| static bool SaveGamePrefix(Data.GameSavingReason reasonForSaving, ref UniTask<bool> __result) | |
| { | |
| switch(reasonForSaving) | |
| { | |
| case Data.GameSavingReason.storeBuyOrReroll: | |
| case Data.GameSavingReason.phoneReroll: | |
| __result = AsyncTrue(); | |
| return false; | |
| } | |
| return true; | |
| } | |
| // Do-nothing function | |
| static async UniTask<bool> AsyncTrue() | |
| { | |
| return true; | |
| } | |
| } | |
| #endregion | |
| #region CacheStrings | |
| [HarmonyPatch(typeof(Strings))] | |
| static class StringsCachePatches | |
| { | |
| [HarmonyPrepare] | |
| static bool Prepare() | |
| { | |
| return CloverPerformance.config.CacheStrings; | |
| } | |
| private static readonly Dictionary<string, (string value, int frame)> Cache = new Dictionary<string, (string value, int frame)>(); | |
| private static int _latestFrame = -1; | |
| private static readonly HashSet<string> NeverCacheStrings = new HashSet<string>(); | |
| // Don't ever cache inputs with these keys cause they change without the game data being dirty | |
| private static readonly string[] DoNotCache = | |
| { | |
| "[START_PROMPT]", | |
| "[INPUTS_MOVE]", | |
| "[CHARGE_BAR]", | |
| }; | |
| // Last time we modified game data | |
| // All data is marked dirty at once cause otherwise I would be rewriting everything | |
| public static void MarkDirty() | |
| { | |
| _latestFrame = Time.frameCount; | |
| } | |
| // If we've got a cached value, get it | |
| [HarmonyPrefix] | |
| [HarmonyPatch(nameof(Strings.Sanitize))] | |
| static bool SanitizePrefix(Strings.SantizationKind santizationKind, string input, Strings.SanitizationSubKind subKind, ref string __result) | |
| { | |
| if (Cache.TryGetValue(input, out var kvp) && kvp.frame > _latestFrame) | |
| { | |
| __result = Cache[input].value; | |
| return false; | |
| } | |
| return true; | |
| } | |
| // Cache a value if it makes sense to | |
| [HarmonyPostfix] | |
| [HarmonyPatch(nameof(Strings.Sanitize))] | |
| static void SanitizePostfix(Strings.SantizationKind santizationKind, string input, Strings.SanitizationSubKind subKind, ref string __result) | |
| { | |
| if (Cache.TryGetValue(input, out var kvp)) | |
| { | |
| // Preserve the timestamp if it's in the future | |
| Cache[input] = (__result, Mathf.Max(kvp.frame, Time.frameCount)); | |
| return; | |
| } | |
| // If it has no tagged keys [key] then we should always cache it and it should remain valid in cache forever | |
| if (!input.Contains("[") && !input.Contains("]")) | |
| { | |
| Cache[input] = (__result, int.MaxValue); | |
| return; | |
| } | |
| if (NeverCacheStrings.Contains(input)) | |
| { | |
| return; | |
| } | |
| foreach (var key in DoNotCache) | |
| { | |
| if (input.Contains(key)) | |
| { | |
| NeverCacheStrings.Add(input); | |
| return; | |
| } | |
| } | |
| Cache[input] = (__result, Time.frameCount); | |
| } | |
| } | |
| // Patches to mark setter methods in GameplayData, works together with the above Strings Sanitize patches | |
| [HarmonyPatch(typeof(GameplayData))] | |
| static class GameplayDataCachePatches | |
| { | |
| [HarmonyPrepare] | |
| static bool Prepare() | |
| { | |
| return CloverPerformance.config.CacheStrings; | |
| } | |
| // Mark the strings as dirty since game data has changed | |
| [HarmonyPostfix] | |
| [HarmonyPatch] | |
| static void MarkDirtyPostfix() | |
| { | |
| StringsCachePatches.MarkDirty(); | |
| } | |
| // Just patch anything ending with "Set" | |
| [HarmonyTargetMethods] | |
| static IEnumerable<MethodBase> CalculateMethods() | |
| { | |
| return AccessTools.GetDeclaredMethods(typeof(GameplayData)).Where(x => x.Name.EndsWith("Set")); | |
| } | |
| } | |
| #endregion | |
| #region CacheBigIntegerMath | |
| // These patches should reduce the amount of very complicated math when it would be called multiple times per frame | |
| [HarmonyPatch(typeof(GameplayData))] | |
| static class BigIntegerCachePatches | |
| { | |
| [HarmonyPrepare] | |
| static bool Prepare() | |
| { | |
| return CloverPerformance.config.CacheBigIntegerMath; | |
| } | |
| // The values and the last frames we had cached values from | |
| private static readonly Dictionary<string, int> CacheTimestamps = new Dictionary<string, int>(); | |
| private static readonly Dictionary<string, object> CachedValues = new Dictionary<string, object>(); | |
| // Which methods to cache values of | |
| [HarmonyTargetMethods] | |
| static IEnumerable<MethodBase> CalculateMethods() | |
| { | |
| yield return AccessTools.Method(typeof(GameplayData), nameof(GameplayData.InterestEarnedHypotetically)); | |
| yield return AccessTools.Method(typeof(GameplayData), nameof(GameplayData.GetHypotehticalMaxSpinsBuyable)); | |
| yield return AccessTools.Method(typeof(GameplayData), nameof(GameplayData.GetHypotehticalMidSpinsBuyable)); | |
| yield return AccessTools.Method(typeof(GameplayData), nameof(GameplayData.DebtGetExt)); | |
| yield return AccessTools.Method(typeof(GameplayData), nameof(GameplayData.SpinCostGet_Single)); | |
| yield return AccessTools.Method(typeof(GameplayData), nameof(GameplayData.DebtGet)); | |
| yield return AccessTools.Method(typeof(GameplayData), nameof(GameplayData.SpinCostMid_Get)); | |
| yield return AccessTools.Method(typeof(GameplayData), nameof(GameplayData.SpinCostMax_Get)); | |
| yield return AccessTools.Method(typeof(GameplayData), nameof(GameplayData.InterestRateGet)); | |
| yield return AccessTools.Method(typeof(GameplayData), nameof(GameplayData.DebtIndexGet)); | |
| yield return AccessTools.Method(typeof(GameplayData), nameof(GameplayData.DeadlineReward_CoinsGet)); | |
| } | |
| // All methods above are patched with this prefix to try and fetch a cached value | |
| [HarmonyPatch] | |
| [HarmonyPrefix] | |
| static bool FrameCachedValue(MethodBase __originalMethod, ref object __result) | |
| { | |
| if (TryGetCachedValue(__originalMethod.Name, out var cachedResult)) | |
| { | |
| __result = cachedResult; | |
| return false; | |
| } | |
| return true; | |
| } | |
| // All methods above are patched with this postfix to try and cache the value if original was called during this frame | |
| [HarmonyPatch] | |
| [HarmonyPostfix] | |
| static void CacheCurrentResult(MethodBase __originalMethod, ref object __result) | |
| { | |
| if (CacheTimestamps.TryGetValue(__originalMethod.Name, out var time) && Time.frameCount == time) | |
| { | |
| return; | |
| } | |
| CacheTimestamps[__originalMethod.Name] = Time.frameCount; | |
| CachedValues[__originalMethod.Name] = __result; | |
| } | |
| static bool TryGetCachedValue(string key, out object value) | |
| { | |
| if (CacheTimestamps.TryGetValue(key, out var time)) | |
| { | |
| if (Time.frameCount != time) | |
| { | |
| value = BigInteger.Zero; | |
| return false; | |
| } | |
| if (CachedValues.TryGetValue(key, out var cachedValue)) | |
| { | |
| value = cachedValue; | |
| return true; | |
| } | |
| } | |
| value = BigInteger.Zero; | |
| return false; | |
| } | |
| } | |
| #endregion | |
| #region PatchStringsSanitize | |
| // Transpiler patch to try and fix Sanitize being called too frequently | |
| // Very confusing, it could be kinda broken | |
| [HarmonyPatch(typeof(Strings))] | |
| [HarmonyPatch("Sanitize")] | |
| static class StringsPatches | |
| { | |
| [HarmonyPrepare] | |
| static bool Prepare() | |
| { | |
| return CloverPerformance.config.PatchStringsSanitize; | |
| } | |
| // This patches out specific calls to string.replace when they would otherwise not need to be called | |
| static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions, ILGenerator ilGenerator) | |
| { | |
| var codes = instructions.ToList(); | |
| var endLabel = new Label(); | |
| var startIndex = -1; | |
| var endIndex = -1; | |
| int i = 0; | |
| while (i < codes.Count) | |
| { | |
| // Did we hit one of our keys? | |
| if (codes[i].opcode == OpCodes.Ldstr && | |
| codes[i].operand is string && | |
| KeyReplacements.Contains((string)codes[i].operand)) | |
| { | |
| startIndex = i-1; | |
| endIndex = -1; | |
| } | |
| // Did we hit the end of the method? | |
| if (codes[i].opcode == OpCodes.Pop && startIndex >= 0) | |
| { | |
| endLabel = ilGenerator.DefineLabel(); | |
| codes[i + 1].labels.Add(endLabel); | |
| endIndex = i; | |
| } | |
| // We hit both, do the patch | |
| if (startIndex >= 0 && endIndex >= 0) | |
| { | |
| // Take the first 2 lines and move them after our patch | |
| var orig1 = codes[startIndex]; | |
| codes.RemoveAt(startIndex); | |
| var orig2 = codes[startIndex]; | |
| codes.RemoveAt(startIndex); | |
| // Load arg1, the input string | |
| codes.Insert(startIndex++, new CodeInstruction(OpCodes.Ldarg_1)); | |
| // Put the same thing on the stack as the original call, usually our key | |
| codes.Insert(startIndex++, orig2); | |
| // Call string.Contains on our stack | |
| codes.Insert(startIndex++, CodeInstruction.Call(typeof(string), "Contains", new[] { typeof(string) })); | |
| // If false: jump to after the original, at endLabel | |
| codes.Insert(startIndex++, new CodeInstruction(OpCodes.Brfalse_S, endLabel)); | |
| // Else, fix our stack how it's supposed to be | |
| codes.Insert(startIndex++, orig1); | |
| codes.Insert(startIndex++, orig2); | |
| i = endIndex + 4; | |
| startIndex = -1; | |
| endIndex = -1; | |
| } | |
| i++; | |
| } | |
| return codes.AsEnumerable(); | |
| } | |
| // The list of string keys to look for to patch over | |
| // These ones are particularly slow for doing math on BigInteger | |
| private static readonly string[] KeyReplacements = | |
| { | |
| "[DEBT]", | |
| "[DEBT_NEXT]", | |
| "[DEBT_30%]", | |
| "[INTEREST_REV]", | |
| "[2X_INTEREST_REV]", | |
| "[3X_INTEREST_REV]", | |
| "[REWARD_DEBT_AMMOUNT]", | |
| }; | |
| } | |
| #endregion | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment