namespace Aeshnidae.BuffBot;
///
/// ACE's tinkering chance, with the Town Tinker's skill in place of the player's and the
/// trained gate lifted.
///
/// WHY A COPY EXISTS AT ALL. The two things the tinker changes - which skill number the
/// formula uses, and whether an untrained player is allowed to tinker at all - both live
/// INSIDE the body of RecipeManager.GetTinkerChance, half way down. A prefix cannot reach
/// them, a postfix is too late (the untrained gate has already returned null and already
/// messaged the player), and the two candidates that would avoid a copy were both worse:
///
/// * Harmony ReversePatch alone cannot do it. A reverse patch hands you the ORIGINAL body,
/// which is exactly the body with the gate in it. Bending it would mean a transpiler
/// editing two sites in someone else's IL - a patch that silently matches nothing when
/// upstream moves a line is strictly harder to notice than a copy with a hash on it.
/// A reverse patch IS used here, as a FLOOR (see ): whenever the player
/// is trained we also ask stock ACE for its own answer and take the better of the two, so
/// anything our copy does not model - the foolproof salvage list, any future upstream
/// change that makes a tink easier - still reaches the player.
/// * Faking the skill object (patching CreatureSkill.Current / .AdvancementClass for the
/// duration of the call) would let stock run untouched, but those are two-line property
/// getters on one of the hottest classes in the server, and this repo has been bitten
/// twice by the JIT inlining a patched one-liner away (Healing's UpdateVitalDelta,
/// CombatWindow's CalculateDamage). Not worth it for a crafting click.
///
/// So: a faithful copy, cited, and guarded.
///
/// COPIED FROM Source/ACE.Server/Managers/RecipeManager.cs, lines 200-246 (the whole body of
/// `public static double? GetTinkerChance(Player player, WorldObject tool,
/// WorldObject target, Recipe recipe)` down to and including the foolproof line).
/// The region's sha256, whitespace-collapsed exactly the way
/// C:\Aeshnidae\ops\build-loot-ranges.py's region() does it, was
/// 4a4c844c5316fae5506731652b53777104454e4b9750ad3b5f63e801c2d25525
/// when this was written (2026-10-08). Re-check with:
/// python -c "import hashlib,re;t=open(r'Source/ACE.Server/Managers/RecipeManager.cs',encoding='utf-8-sig').read();a=t.index('public static double? GetTinkerChance');b=t.index('if (foolproofTinkers.Contains((WeenieClassName)tool.WeenieClassId))');print(hashlib.sha256(re.sub(r'\s+',' ',t[a:b+66]).strip().encode()).hexdigest())"
///
/// Nothing that ACE already exposes is copied: GetMaterialMod, TinkeringDifficulty,
/// SkillCheck.GetSkillChance, Recipe.IsImbuing and AugmentationBonusImbueChance are all
/// called, not reproduced. What is reproduced is the eight lines of arithmetic between them.
///
/// THE GUARD, at runtime, is : the sha256 of the OPCODE SEQUENCE of
/// the GetTinkerChance that the loaded ACE.Server actually contains - opcodes only, operands
/// skipped, so a rebuild that merely shuffles metadata tokens does not trip it, while any
/// real edit to the body does. It is compared with Settings.TinkerFormulaOpcodeSha256. On a
/// mismatch an error is logged and goes false: the tinker stops substituting
/// skills entirely and every tink is stock ACE's again. A stale copy of someone else's
/// formula is worse than no feature.
///
public static class Formula
{
/// Set once at load. False = the substitution is off and stock ACE decides every tink.
public static bool Ok { get; private set; }
/// The opcode sha256 of the loaded GetTinkerChance, as computed at load.
public static string Fingerprint { get; private set; } = "";
/// Why is false, or how it was satisfied. Printed by tinker-status.
public static string Status { get; private set; } = "not checked";
/// True when the reverse patch of stock's own body is callable.
public static bool StockAvailable { get; private set; }
private static HashSet? _foolproof;
public static MethodInfo? Target =>
AccessTools.Method(typeof(RecipeManager), nameof(RecipeManager.GetTinkerChance),
new[] { typeof(Player), typeof(WorldObject), typeof(WorldObject), typeof(Recipe) });
///
/// Checks the guard and builds the reverse patch. Called from Mod.Initialize, after the
/// ordinary patches, and never throws - a crafting feature must not be able to leave the
/// mod Inactive and take the buff bots down with it.
///
public static void Verify(Harmony harmony)
{
Ok = false;
StockAvailable = false;
Fingerprint = "";
try
{
var target = Target;
if (target is null)
{
Status = "RecipeManager.GetTinkerChance(Player, WorldObject, WorldObject, Recipe) was not found - " +
"upstream has changed its signature. The tinkers lend no skill.";
ModManager.Log($"[{Mod.Name}] {Status}", ModManager.LogLevel.Error);
return;
}
Fingerprint = OpcodeSha256(target);
if (string.IsNullOrEmpty(Fingerprint))
{
Status = "the IL of GetTinkerChance could not be read, so the formula cannot be guarded. The tinkers lend no skill.";
ModManager.Log($"[{Mod.Name}] {Status}", ModManager.LogLevel.Error);
return;
}
var expected = (Mod.Settings.TinkerFormulaOpcodeSha256 ?? "").Trim();
if (expected.Length == 0)
{
Status = $"NOT PINNED. GetTinkerChance opcode sha256 is {Fingerprint} - put that in " +
"Settings.TinkerFormulaOpcodeSha256 to pin it.";
ModManager.Log($"[{Mod.Name}] tinker formula {Status}", ModManager.LogLevel.Warn);
}
else if (!expected.Equals(Fingerprint, StringComparison.OrdinalIgnoreCase))
{
Status = $"MISMATCH. RecipeManager.GetTinkerChance is not the method this mod copied: expected " +
$"{expected}, found {Fingerprint}. Re-read " +
"Source/ACE.Server/Managers/RecipeManager.cs, bring Formula.Chance back into line with it, " +
"then pin the new hash. Until then the tinkers lend no skill and every tink is stock ACE's.";
ModManager.Log($"[{Mod.Name}] tinker formula {Status}", ModManager.LogLevel.Error);
return;
}
else
Status = $"pinned, matches ({Fingerprint[..12]}...)";
// The foolproof salvage list is private, and it is the one branch of stock that
// can beat anything we compute. Read it once; without it an UNTRAINED player
// using a foolproof salvage would get the computed chance instead of a certainty.
try
{
_foolproof = AccessTools.Field(typeof(RecipeManager), "foolproofTinkers")
?.GetValue(null) as HashSet;
}
catch (Exception ex)
{
_foolproof = null;
ModManager.Log($"[{Mod.Name}] could not read RecipeManager.foolproofTinkers ({ex.Message}); " +
"an untrained player with a foolproof salvage will get the computed chance, not a certainty.",
ModManager.LogLevel.Warn);
}
if (_foolproof is null)
Status += ", foolproof list UNREADABLE";
// The floor: stock's own body, callable without the patch we put on it.
try
{
harmony.CreateReversePatcher(target, new HarmonyMethod(AccessTools.Method(typeof(Formula), nameof(Stock)))).Patch();
StockAvailable = true;
}
catch (Exception ex)
{
StockAvailable = false;
ModManager.Log($"[{Mod.Name}] could not reverse-patch stock GetTinkerChance ({ex.Message}); " +
"a trained player's tink will use this mod's copy alone, with no stock floor under it.",
ModManager.LogLevel.Warn);
}
Ok = true;
}
catch (Exception ex)
{
Ok = false;
Status = $"the formula guard itself threw: {ex.Message}. The tinkers lend no skill.";
ModManager.Log($"[{Mod.Name}] {Status}", ModManager.LogLevel.Error);
}
}
///
/// Stock ACE's GetTinkerChance, reached through a Harmony reverse patch so our own prefix
/// on it is bypassed. The body here is never executed; Harmony replaces it with a copy of
/// the original method's IL in .
///
public static double? Stock(Player player, WorldObject tool, WorldObject target, Recipe recipe) =>
throw new NotImplementedException("replaced by Harmony's reverse patch");
///
/// ACE's tinkering chance with used in place of the
/// player's skill value, and no trained gate. Faithful to the cited region: the same
/// salvage and workmanship mods, the same TinkeringDifficulty step, the same difficulty
/// expression, the same SkillCheck call, the same imbue divide and augmentation bonus,
/// the same foolproof override.
///
public static double Chance(Player player, WorldObject tool, WorldObject target, Recipe recipe, uint effectiveSkill)
{
var toolWorkmanship = tool.Workmanship ?? 0;
var itemWorkmanship = target.Workmanship ?? 0;
var tinkeredCount = target.NumTimesTinkered;
var materialType = tool.MaterialType ?? MaterialType.Unknown;
var salvageMod = RecipeManager.GetMaterialMod(materialType);
var workmanshipMod = 1.0f;
if (toolWorkmanship >= itemWorkmanship)
workmanshipMod = 2.0f;
// Stock indexes TinkeringDifficulty with NumTimesTinkered and would throw on an item
// tinkered more than ten times. Clamped here instead: a prefix that throws is caught
// and falls through to stock, which would then throw at the player. The clamp picks
// the HARDEST step, so it is never generous.
var table = RecipeManager.TinkeringDifficulty;
var attemptMod = table is null || table.Count == 0
? 1.0f
: table[Math.Clamp(tinkeredCount, 0, table.Count - 1)];
var difficulty = (int)Math.Floor(((salvageMod * 5.0f) + (itemWorkmanship * salvageMod * 2.0f) - (toolWorkmanship * workmanshipMod * salvageMod / 5.0f)) * attemptMod);
var currentPlusLumAugSkilledCraft = effectiveSkill + (uint)player.LumAugSkilledCraft;
var successChance = SkillCheck.GetSkillChance((int)currentPlusLumAugSkilledCraft, difficulty);
// imbue: divide success by 3
if (recipe.IsImbuing())
{
successChance /= 3.0f;
if (player.AugmentationBonusImbueChance > 0)
successChance += player.AugmentationBonusImbueChance * 0.05f;
}
if (_foolproof is not null && _foolproof.Contains((WeenieClassName)tool.WeenieClassId))
successChance = 1.0;
return successChance;
}
// ---------------------------------------------------------------- the fingerprint
private static Dictionary? _opcodes;
///
/// The sha256 of a method's opcode sequence: every instruction's name in order, operands
/// skipped. Metadata tokens move whenever ACE.Server is rebuilt even if a method is
/// untouched, so hashing the raw IL bytes would cry wolf on every build; the opcode
/// sequence does not move unless the body really changes.
///
/// Returns "" when the IL cannot be read or an opcode is not recognised - treated by the
/// caller as a failed guard, not as a pass.
///
public static string OpcodeSha256(MethodBase method)
{
try
{
var il = method.GetMethodBody()?.GetILAsByteArray();
if (il is null || il.Length == 0)
return "";
_opcodes ??= typeof(OpCodes)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.FieldType == typeof(OpCode))
.Select(f => (OpCode)f.GetValue(null)!)
.GroupBy(o => o.Value)
.ToDictionary(g => g.Key, g => g.First());
var text = new StringBuilder();
for (var i = 0; i < il.Length;)
{
short value = il[i++];
if (value == 0xFE)
{
if (i >= il.Length)
return "";
value = (short)(0xFE00 | il[i++]);
}
if (!_opcodes.TryGetValue(value, out var op))
return "";
text.Append(op.Name).Append(';');
var skip = op.OperandType switch
{
OperandType.InlineNone => 0,
OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1,
OperandType.InlineVar => 2,
OperandType.InlineI8 or OperandType.InlineR => 8,
OperandType.InlineSwitch => i + 4 <= il.Length ? 4 + 4 * BitConverter.ToInt32(il, i) : int.MaxValue,
_ => 4,
};
if (skip < 0 || i + skip > il.Length)
return "";
i += skip;
}
return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
Encoding.UTF8.GetBytes(text.ToString()))).ToLowerInvariant();
}
catch
{
return "";
}
}
}