Skip to content

Instantly share code, notes, and snippets.

@Ooseykins
Last active March 8, 2026 00:33
Show Gist options
  • Select an option

  • Save Ooseykins/244c9ba60ddbbad6760d5aed62311f39 to your computer and use it in GitHub Desktop.

Select an option

Save Ooseykins/244c9ba60ddbbad6760d5aed62311f39 to your computer and use it in GitHub Desktop.
CloverPit performance patches for very high deadlines
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