namespace Aeshnidae.AdminAudit; /// /// Hooks into the other Aeshnidae mods - bank transfers, XP-currency transfers and /// instance creation. /// /// These cannot be written as ordinary [HarmonyPatch] classes. Each mod is loaded into /// its own collectible AssemblyLoadContext, so this assembly cannot reference /// CurrencyKind or BankResult at compile time - there is no build-time /// relationship between the mods at all. So the targets are resolved by name at /// runtime and patched through Harmony's imperative API. /// /// Three things make that workable, all verified rather than assumed: /// /// - Harmony binds postfix parameters by name, and only the ones you ask for. /// Declaring just (Player player, long amount) and simply not mentioning /// kind sidesteps the untypeable parameter entirely. /// - object[] __args carries every argument boxed, for the ones that cannot be /// declared. Enum values arrive as their own type and render correctly via ToString. /// - object __result works for value-type returns too; Harmony boxes them. /// /// The one thing that does not work: out parameters read through /// __args arrive as null. Declare those by name instead, which is possible /// whenever the type is a shared one - out string message is just a string. /// /// Targets are resolved through ModManager.GetModContainerByName(...).ModAssembly /// - never by scanning AppDomain.CurrentDomain.GetAssemblies(). That distinction /// matters more than it looks: every /mod find loads a fresh copy of each mod into a new /// context, and the previous copy stays loaded until its context is collected. A scan /// returns the *oldest* match, so the patch binds to an assembly nothing calls any more /// and cheerfully reports success. ACE's own container is the only authority on which /// copy is live. /// /// The cost of this approach is that a rename in a sibling mod silently drops a hook, /// and reloading a sibling mod (which /mod find does to all of them) unbinds it. Hence /// , surfaced by /adminaudit hooks, and /adminaudit rebind. /// internal static class SiblingPatches { public sealed record HookStatus(string Target, bool Bound, string Note); private static readonly List _status = new(); public static IReadOnlyList Status { get { lock (_status) return _status.ToList(); } } /// Landblock copies already seen, so GetOrCreate can tell creation from a lookup. private static readonly HashSet<(ushort Landblock, int Copy)> _knownCopies = new(); private static Auditor? Audit => Mod.Auditor; /// Methods actually patched, so a re-Apply can detach from the old ones. private static readonly Dictionary _bound = new(); public static void Apply(Harmony harmony) { lock (_status) _status.Clear(); _knownCopies.Clear(); Bind(harmony, "Aeshnidae.Bank", "Aeshnidae.Bank.BankService", "Deposit", nameof(AfterBankMove)); Bind(harmony, "Aeshnidae.Bank", "Aeshnidae.Bank.BankService", "Withdraw", nameof(AfterBankMove)); Bind(harmony, "Aeshnidae.Bank", "Aeshnidae.Bank.Transfer", "Send", nameof(AfterBankPay)); Bind(harmony, "Aeshnidae.XpCurrency", "Aeshnidae.XpCurrency.Transfer", "Send", nameof(AfterXpSend)); Bind(harmony, "Aeshnidae.InstancesNoDat", "Aeshnidae.InstancesNoDat.InstanceWorld", "GetOrCreate", nameof(AfterGetOrCreate)); } private static void Bind(Harmony harmony, string modName, string typeName, string methodName, string postfix) { var target = $"{typeName}.{methodName}"; try { // Detach from whatever we patched last time, by the MethodInfo we actually // used. Unpatching the *newly* resolved method would silently miss a stale // binding, and leaving a patch on a dead assembly pins its load context. if (_bound.Remove(target, out var previous)) { try { harmony.Unpatch(previous, HarmonyPatchType.Postfix, Mod.HarmonyId); } catch { /* the old assembly may already be gone; nothing to detach from */ } } var container = ModManager.GetModContainerByName(modName, allowPartial: false); if (container is null) { Note(target, false, "mod not installed"); return; } if (container.Status != ModStatus.Active) { Note(target, false, $"mod is {container.Status}"); return; } var type = container.ModAssembly?.GetType(typeName, throwOnError: false); if (type is null) { Note(target, false, "type not found in that mod's assembly"); return; } var method = AccessTools.Method(type, methodName); if (method is null) { Note(target, false, "method not found - renamed upstream?"); return; } harmony.Patch(method, postfix: new HarmonyMethod(typeof(SiblingPatches), postfix)); _bound[target] = method; Note(target, true, "bound"); } catch (Exception ex) { Note(target, false, $"{ex.GetType().Name}: {ex.Message}"); ModManager.Log($"[{Mod.Name}] could not hook {target}: {ex.Message}", ModManager.LogLevel.Warn); } } private static void Note(string target, bool bound, string note) { lock (_status) _status.Add(new HookStatus(target, bound, note)); } // ------------------------------------------------------------------------ bank /// /// Serves both Deposit and Withdraw - __originalMethod says which. /// kind is deliberately not declared: it is Aeshnidae.Bank's own enum, which /// this assembly cannot name. It comes out of __args instead. /// public static void AfterBankMove(Player player, long amount, object[] __args, object __result, MethodBase __originalMethod) { try { if (Audit is not { } audit || player is null) return; if (!audit.ShouldAudit(player.Session?.AccessLevel ?? AccessLevel.Player, player.Name)) return; var direction = __originalMethod?.Name ?? "bank"; var kind = __args is { Length: > 1 } ? __args[1]?.ToString() ?? "?" : "?"; var (ok, message) = ReadBankResult(__result); var record = Auditor.For(player, AuditKind.Bank, direction.ToLowerInvariant()); record.Target = player.Name; record.Outcome = ok ? "ok" : "failed"; record.Detail = $"{direction.ToLowerInvariant()} {amount:N0} {kind}" + (ok ? "" : $" - refused: {message}"); record.With("amount", amount).With("currency", kind); audit.Record(record); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] bank capture failed: {ex.Message}", ModManager.LogLevel.Error); } } /// /// /b pay - the one bank action that reaches somebody else's balance, and the only /// way Radiance and Resonance move between accounts. Unhooked until 2026-09-14: the /// hook beside it watched the retired XpCurrency's transfers instead. The currency /// is Bank's own enum and comes out of __args, as in AfterBankMove. /// public static void AfterBankPay(Player sender, string recipientName, long amount, object[] __args, object __result) { try { if (Audit is not { } audit || sender is null) return; if (!audit.ShouldAudit(sender.Session?.AccessLevel ?? AccessLevel.Player, sender.Name)) return; var kind = __args is { Length: > 2 } ? __args[2]?.ToString() ?? "?" : "?"; var (ok, message) = ReadBankResult(__result); var record = Auditor.For(sender, AuditKind.Bank, "pay"); record.Target = recipientName; record.Outcome = ok ? "ok" : "failed"; record.Detail = $"pay {amount:N0} {kind} to {recipientName}" + (ok ? "" : $" - refused: {message}"); record.With("amount", amount).With("currency", kind); audit.Record(record); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] bank-pay capture failed: {ex.Message}", ModManager.LogLevel.Error); } } /// BankResult is a record in another load context, so it is read reflectively. private static (bool Ok, string Message) ReadBankResult(object? result) { if (result is null) return (false, "no result"); try { var type = result.GetType(); var ok = type.GetProperty("Ok")?.GetValue(result) as bool? ?? false; var message = type.GetProperty("Message")?.GetValue(result) as string ?? ""; return (ok, message); } catch { return (false, "unreadable result"); } } // ------------------------------------------------------------------ xp currency /// /// Every parameter here is a shared type - Player, string, long - so they are all /// declared by name, including the out parameter, which __args would report as null. /// public static void AfterXpSend(Player sender, string targetName, long amount, ref string message, bool __result) { try { if (Audit is not { } audit || sender is null) return; if (!audit.ShouldAudit(sender.Session?.AccessLevel ?? AccessLevel.Player, sender.Name)) return; var record = Auditor.For(sender, AuditKind.XpTransfer, "xpsend"); record.Target = targetName; record.Outcome = __result ? "ok" : "failed"; record.Detail = $"sent {amount:N0} XP to {targetName}" + (__result ? "" : $" - refused: {message}"); record.With("amount", amount); audit.Record(record); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] xp-transfer capture failed: {ex.Message}", ModManager.LogLevel.Error); } } // -------------------------------------------------------------------- instances /// /// GetOrCreate runs on every entry into an instanced dungeon, not just on creation, /// so creation is inferred by keeping our own set of copies seen. Cheaper and more /// robust than reflecting into the mod's private dictionary, and being wrong once /// after a reload costs at most one duplicate record. /// public static void AfterGetOrCreate(ushort landblock, int copy, object __result) { try { if (Audit is not { } audit || __result is null) return; lock (_knownCopies) { if (!_knownCopies.Add((landblock, copy))) return; } var frame = CommandContext.Current; var record = Auditor.For(null, AuditKind.Instance, frame?.Command ?? "instance"); record.Actor = frame?.Actor ?? "server"; record.Account = frame?.Account; record.Source = frame is null ? "server" : "ingame"; record.Outcome = "ok"; record.Detail = $"created copy {copy} of landblock {landblock:X4}"; record.With("landblock", $"{landblock:X4}").With("copy", copy); audit.Record(record); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] instance capture failed: {ex.Message}", ModManager.LogLevel.Error); } } }