namespace Aeshnidae.AdminAudit; /// /// Entry point ACE looks for. /// /// ModContainer resolves the type purely by convention: /// assembly "Aeshnidae.AdminAudit.dll" -> type "Aeshnidae.AdminAudit.Mod" /// so this class must be named Mod and live in a namespace matching the /// assembly name. /// public class Mod : IHarmonyMod { public static readonly string Name = typeof(Mod).Assembly.GetName().Name!; /// Harmony owner id. Unique per mod - it is what UnpatchAll targets. public static readonly string HarmonyId = $"mod.{Name.ToLowerInvariant()}"; /// Folder this mod was loaded from, e.g. C:\ACEPublic\Mods\Aeshnidae.AdminAudit public static readonly string ModPath = Path.Combine(ModManager.ModPath, Name); public static ModContainer? Container => ModManager.GetModContainerByPath(ModPath); /// Live settings, re-read every time the mod is enabled. public static Settings Settings { get; private set; } = new(); /// The sinks. Null while the mod is disabled; every patch null-checks it. internal static Auditor? Auditor { get; private set; } /// When this mod last started recording - the point before which the trail says nothing. public static DateTime StartedUtc { get; private set; } private static Harmony? _harmony; private bool _disposed; public void Initialize() { try { Settings = Settings.Load(ModPath); _harmony = new Harmony(HarmonyId); _harmony.PatchAllUncategorized(typeof(Mod).Assembly); Auditor = new Auditor(Settings, ModPath); StartedUtc = DateTime.UtcNow; if (Settings.AuditSiblingMods) { SiblingPatches.Apply(_harmony); ScheduleRebind(); } var bound = SiblingPatches.Status.Count(h => h.Bound); ModManager.Log($"[{Name}] recording to {Auditor.Log.Directory}; " + $"threshold {Settings.Threshold}, " + $"Discord {(Auditor.Discord is null ? "off" : $"on ({Auditor.Sinks.Count} webhook{(Auditor.Sinks.Count == 1 ? "" : "s")})")}, " + $"sibling hooks {bound}/{SiblingPatches.Status.Count}"); // The trail should say when it started, so a gap is distinguishable from a // quiet period. Auditor.Record(new AuditEvent { Kind = nameof(AuditKind.Narrated), Actor = "server", Source = "server", Action = "audit-start", Outcome = "ok", Detail = $"audit started - threshold {Settings.Threshold}, {bound} sibling hook(s) bound", }); } catch (Exception ex) { // A throw here leaves the mod Inactive, which would mean no audit at all. ModManager.Log($"[{Name}] failed to initialize: {ex}", ModManager.LogLevel.Error); } } /// /// Binds the sibling hooks again a little after startup. /// /// ModMetadata.Priority is a uint, so there is no value that reliably sorts this mod /// after the others - they are all 0, and the order within a priority is just /// whatever order the folders come back in. So load order cannot be relied on: the /// first Apply may find none of the sibling assemblies loaded yet. /// /// Rather than fight the ordering, bind again once everything has settled. Apply is /// idempotent, so the pass costs nothing when the first one already succeeded. /// private static void ScheduleRebind() { var harmony = _harmony; Task.Run(async () => { try { await Task.Delay(TimeSpan.FromSeconds(20)); // The mod may have been disabled while we waited. if (!ReferenceEquals(_harmony, harmony) || harmony is null) return; var before = SiblingPatches.Status.Count(h => h.Bound); SiblingPatches.Apply(harmony); var after = SiblingPatches.Status.Count(h => h.Bound); if (after != before) ModManager.Log($"[{Name}] sibling hooks after startup settled: {after}/{SiblingPatches.Status.Count} bound"); foreach (var hook in SiblingPatches.Status.Where(h => !h.Bound)) ModManager.Log($"[{Name}] not auditing {hook.Target} - {hook.Note}", ModManager.LogLevel.Warn); } catch (Exception ex) { ModManager.Log($"[{Name}] deferred hook binding failed: {ex.Message}", ModManager.LogLevel.Warn); } }); } /// /// Re-resolves the sibling-mod hooks. Needed after any of those mods reloads - /// /mod find reloads every mod, which silently unbinds them. /// public static int Rebind() { if (_harmony is null) return 0; SiblingPatches.Apply(_harmony); return SiblingPatches.Status.Count(h => h.Bound); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) { // Say so in the trail before tearing anything down, so a deliberate // disable is visible rather than looking like an unexplained gap. Auditor?.Record(new AuditEvent { Kind = nameof(AuditKind.Narrated), Actor = "server", Source = "server", Action = "audit-stop", Outcome = "ok", Detail = "audit stopped", }); // Unpatch first so nothing new arrives while the sinks drain. _harmony?.UnpatchAll(HarmonyId); _harmony = null; Auditor?.Dispose(); Auditor = null; ModManager.Log($"[{Name}] shut down"); } _disposed = true; } }