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 ""; } } }