namespace Aeshnidae.AdminAudit; /// /// The hooks into ACE itself. Everything here is compile-time typed; the sibling-mod /// hooks, which cannot be, live in . /// /// Every patch is a postfix or a read-only prefix that returns void, so none of them /// can change what the server does. An audit trail that alters behaviour is worse than /// no audit trail. /// [HarmonyPatch] public static class CorePatches { private static Auditor? Audit => Mod.Auditor; // ---------------------------------------------------------------- command frame /// /// Brackets a whole in-game command. ACE invokes the handler synchronously inside /// GameActionTalk.Handle, so anything the handler does happens between these two, /// on this thread - which is what lets the outcome hooks below attribute themselves /// to a command and ignore ordinary gameplay. /// [HarmonyPrefix] [HarmonyPatch(typeof(GameActionTalk), nameof(GameActionTalk.Handle))] public static void PreTalk() => CommandContext.Clear(); [HarmonyPostfix] [HarmonyPatch(typeof(GameActionTalk), nameof(GameActionTalk.Handle))] public static void PostTalk() => CommandContext.Clear(); // -------------------------------------------------------------- command journal /// /// Every command, from the console and from the game, allowed or refused. /// /// This is ACE's single authorization gate - both callers go through it - which /// makes it the one place that sees an attempt regardless of outcome. Refused /// attempts are the most interesting records in the whole trail: they are what /// someone probing for access looks like. /// [HarmonyPostfix] [HarmonyPatch(typeof(CommandManager), nameof(CommandManager.GetCommandHandler))] public static void PostGetCommandHandler(Session session, string command, string[] parameters, ref CommandHandlerInfo commandInfo, CommandHandlerResponse __result) { try { if (Audit is not { } audit || command is null) return; // The console has unrestricted access by definition, so it is always worth // recording regardless of the configured threshold. var access = session?.AccessLevel ?? AccessLevel.Admin; var required = commandInfo?.Attribute?.Access ?? AccessLevel.Player; // Record if the actor is privileged, or if a plain player just reached for // a command they should not have. var interesting = audit.ShouldAudit(access, session?.Player?.Name) || (required > AccessLevel.Player && __result == CommandHandlerResponse.NotAuthorized); if (!interesting) return; // A Player-access command run by an admin is not a use of privilege. Skipping // those is what keeps a client plugin polling /b every minute out of the // trail; the bank, XP and item hooks still record anything it actually does. if (!Mod.Settings.ShouldJournalCommand(command, required, __result == CommandHandlerResponse.NotAuthorized)) { OpenFrame(session, command, __result); return; } var args = parameters is { Length: > 0 } ? " " + string.Join(" ", parameters) : ""; var readOnly = Mod.Settings.IsReadOnlyCommand(command); var record = Auditor.For(session?.Player, AuditKind.Command, command); record.Access = access.ToString(); record.Source = session is null ? "console" : "ingame"; record.ReadOnly = readOnly; record.Outcome = __result == CommandHandlerResponse.Ok ? "ok" : __result.ToString(); record.Detail = $"@{command}{args}"; record.Via = null; // this event IS the command record.With("requires", required) .With("sudo", __result == CommandHandlerResponse.SudoOk ? "true" : null); audit.Record(record); OpenFrame(session, command, __result); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] command journal failed for '{command}': {ex.Message}", ModManager.LogLevel.Error); } } /// /// Opens the command frame for the outcome hooks. Only for the in-game path: it is /// the only one bracketed by PreTalk/PostTalk, and an unbracketed frame would linger /// on the console thread and mis-attribute whatever ran next. /// /// Called even for commands that are not journalled, so an item conjured by one is /// still attributed to the command that conjured it. /// private static void OpenFrame(Session? session, string command, CommandHandlerResponse result) { if (session?.Player is not { } player || result is not (CommandHandlerResponse.Ok or CommandHandlerResponse.SudoOk)) return; CommandContext.Set(new CommandFrame { Actor = player.Name, Account = player.Account?.AccountName, Access = session.AccessLevel, Command = command, Detail = $"@{command}", ReadOnly = Mod.Settings.IsReadOnlyCommand(command), }); } // ------------------------------------------------------- ACE's own narrated acts /// /// ACE already describes 44 privileged actions in prose on its audit channel - /// object deletion, smite, teleporting players, server property changes, shutdown. /// Rather than re-deriving those, this captures what ACE already decided was worth /// announcing. A prefix, so it records whether or not anyone is listening on the /// channel in game. /// [HarmonyPrefix] [HarmonyPatch(typeof(PlayerManager), nameof(PlayerManager.BroadcastToAuditChannel))] public static void PreBroadcastToAuditChannel(Player issuer, string message) { try { if (Audit is not { } audit) return; var access = issuer?.Session?.AccessLevel ?? AccessLevel.Admin; if (issuer is not null && !audit.ShouldAudit(access, issuer.Name)) return; var record = Auditor.For(issuer, AuditKind.Narrated, CommandContext.Current?.Command ?? "narrated"); record.Detail = message ?? ""; record.Outcome = "ok"; audit.Record(record); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] audit-channel capture failed: {ex.Message}", ModManager.LogLevel.Error); } } // ------------------------------------------------------------------- item flows /// /// An object conjured into an inventory. Gated on an active command frame, which is /// what separates "an admin ran /ci" from the 28 other call sites that are ordinary /// gameplay - quest rewards, salvage, emote-granted items. /// /// Patches the two-argument overload only; the one-argument version delegates to it, /// so patching both would record every creation twice. /// [HarmonyPostfix] [HarmonyPatch(typeof(Player), nameof(Player.TryCreateInInventoryWithNetworking), new[] { typeof(WorldObject), typeof(Container) }, new[] { ArgumentType.Normal, ArgumentType.Out })] public static void PostTryCreateInInventory(Player __instance, WorldObject item, bool __result) { try { if (Audit is not { } audit || item is null) return; // No command running means this was gameplay, not an admin conjuring. if (CommandContext.Current is null) return; var record = Auditor.For(__instance, AuditKind.ItemCreated, "create"); record.Target = __instance?.Name; record.Outcome = __result ? "ok" : "failed"; record.Detail = $"created {Describe(item)} in {__instance?.Name}'s inventory"; record.With("wcid", item.WeenieClassId) .With("guid", $"0x{item.Guid.Full:X8}") .With("stack", item.StackSize ?? 1); audit.Record(record); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] item-creation capture failed: {ex.Message}", ModManager.LogLevel.Error); } } /// /// A privileged character putting something on the ground. Not a command, so this is /// filtered on the actor's access level rather than on a command frame. /// /// The prefix captures the item because by the time the postfix runs it has left the /// inventory and the guid no longer resolves. /// [HarmonyPrefix] [HarmonyPatch(typeof(Player), nameof(Player.HandleActionDropItem))] public static void PreDropItem(Player __instance, uint itemGuid, out string? __state) { __state = null; try { if (Audit is not { } audit || __instance is null) return; if (!audit.ShouldAudit(__instance.Session?.AccessLevel ?? AccessLevel.Player, __instance.Name)) return; __state = Describe(__instance.FindObject(itemGuid, Player.SearchLocations.MyInventory | Player.SearchLocations.MyEquippedItems)); } catch { // A drop we cannot describe is still worth recording; the postfix falls back // to the raw guid. __state = null; } } [HarmonyPostfix] [HarmonyPatch(typeof(Player), nameof(Player.HandleActionDropItem))] public static void PostDropItem(Player __instance, uint itemGuid, string? __state) { try { if (Audit is not { } audit || __state is null) return; var record = Auditor.For(__instance, AuditKind.ItemDropped, "drop"); record.Detail = $"dropped {__state}"; record.With("guid", $"0x{itemGuid:X8}"); audit.Record(record); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] drop capture failed: {ex.Message}", ModManager.LogLevel.Error); } } /// A privileged character handing an object to a player or NPC. [HarmonyPrefix] [HarmonyPatch(typeof(Player), nameof(Player.HandleActionGiveObjectRequest))] public static void PreGiveObject(Player __instance, uint targetGuid, uint itemGuid, int amount, out string? __state) { __state = null; try { if (Audit is not { } audit || __instance is null) return; if (!audit.ShouldAudit(__instance.Session?.AccessLevel ?? AccessLevel.Player, __instance.Name)) return; var item = __instance.FindObject(itemGuid, Player.SearchLocations.MyInventory | Player.SearchLocations.MyEquippedItems); var target = PlayerManager.GetOnlinePlayer(new ObjectGuid(targetGuid)); __state = $"{(amount > 1 ? $"{amount}x " : "")}{Describe(item)}|{target?.Name ?? $"0x{targetGuid:X8}"}"; } catch { __state = null; } } [HarmonyPostfix] [HarmonyPatch(typeof(Player), nameof(Player.HandleActionGiveObjectRequest))] public static void PostGiveObject(Player __instance, uint itemGuid, string? __state) { try { if (Audit is not { } audit || __state is null) return; var parts = __state.Split('|'); var record = Auditor.For(__instance, AuditKind.ItemGiven, "give"); record.Target = parts.Length > 1 ? parts[1] : null; record.Detail = $"gave {parts[0]} to {record.Target}"; record.With("guid", $"0x{itemGuid:X8}"); audit.Record(record); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] give capture failed: {ex.Message}", ModManager.LogLevel.Error); } } // ---------------------------------------------------------------------- grants /// /// XP granted by command. is the discriminator: it /// appears in exactly two places in all of ACE, both of them the grant commands, so /// this costs nothing on the gameplay path even though GrantXP itself is called on /// every kill, proficiency tick and fellowship split. /// [HarmonyPostfix] [HarmonyPatch(typeof(Player), nameof(Player.GrantXP))] public static void PostGrantXP(Player __instance, long amount, XpType xpType) => RecordGrant(__instance, amount, xpType, "xp"); [HarmonyPostfix] [HarmonyPatch(typeof(Player), nameof(Player.GrantLuminance))] public static void PostGrantLuminance(Player __instance, long amount, XpType xpType) => RecordGrant(__instance, amount, xpType, "luminance"); private static void RecordGrant(Player? recipient, long amount, XpType xpType, string currency) { try { if (Audit is not { } audit || xpType != XpType.Admin) return; var frame = CommandContext.Current; // The recipient is the patched instance; the admin who ran the command is in // the frame. They are usually different players, and the trail needs both. var record = Auditor.For(recipient, AuditKind.Grant, frame?.Command ?? $"grant{currency}"); record.Target = recipient?.Name; record.Detail = $"{frame?.Actor ?? "someone"} granted {amount:N0} {currency} to {recipient?.Name}"; record.Outcome = "ok"; record.With("amount", amount).With("currency", currency); if (frame is not null) { record.Actor = frame.Actor; record.Account = frame.Account; record.Access = frame.Access.ToString(); } audit.Record(record); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] grant capture failed: {ex.Message}", ModManager.LogLevel.Error); } } /// "Gold Scarab (0x50001234, wcid 30977)", or a guid when the object is gone. internal static string Describe(WorldObject? item) => item is null ? "an object" : $"{item.Name}{(item.StackSize > 1 ? $" x{item.StackSize}" : "")} (0x{item.Guid.Full:X8}, wcid {item.WeenieClassId})"; }