using System.Diagnostics; using PhysicsLandblock = ACE.Server.Physics.Common.Landblock; namespace Aeshnidae.InstancesNoDat; /// /// Keeps the master of an instanced dungeon unloaded when nothing needs it. /// /// The cause found on 2026-10-02 (Matron Hive South, 6346): a portal into an /// instanced dungeon loaded the dungeon's MASTER before the player had even left. /// Portal.ActOnUse runs AdjustDungeon(destination) before it queues the teleport, /// and AdjustDungeon asks LScape.get_landblock(cell) whether the landblock has a /// dungeon - which, with no copy ambient (the portal is used from outside, or from the /// hub copy, a different landblock), is LandblockManager.GetLandblock, and that LOADS /// the real landblock: 424 Olthoi, its generators and acid, ticking for five minutes /// with nobody in it, while the player was in their copy. "Told to forget 0 object(s) /// of the master" at the arrival (10-01 17:15, the master half-built) and "585" (10-02 /// 17:01) were it. The teleport itself is aimed at the copy (Patches.Aim); only this /// adjustment, a step earlier, was not. The same call precedes the portal-recall and /// portal-sending spells and NPC teleports. /// /// What the adjustment needs is the dat only - does the landblock have a dungeon, and /// which EnvCell holds the point - which is identical for the master and every copy. /// So while a portal is being used, or a destination adjusted, a lookup of an /// instanced landblock whose master is NOT loaded is answered with a stand-in built from /// the dat (a physics landblock that is never registered anywhere, kept per landblock), /// and the master stays unloaded. A master that is already loaded answers as it always /// did, and nothing outside these calls is changed. /// /// The second half is a diagnostic: any other load of an instanced master while copies /// are open is logged with the call stack ("MASTER LOAD"), once a minute per landblock, /// so whatever else wakes a master can be named from the journal rather than guessed. /// [HarmonyPatch] public static class MasterGuard { /// How deep this thread is inside a call that only needs the dat (a portal use, a destination adjustment). [ThreadStatic] private static int _datOnly; public static bool DatOnly => _datOnly > 0; private static readonly ConcurrentDictionary _shells = new(); private static readonly ConcurrentDictionary _lastShellLogged = new(); private static int _shellAnswers; public static int ShellAnswers => _shellAnswers; private static void Enter() => _datOnly++; private static void Leave() { if (_datOnly > 0) _datOnly--; } // ---- the calls that only need the dat --------------------------------------------- /// /// Portal.ActOnUse: AdjustDungeon, then a QUEUED teleport (WorldManager.ThreadSafeTeleport). /// Nothing in it needs a loaded landblock. A virtual method, so never inlined into its /// caller - this is the hook that holds whatever the JIT does with the small ones below. /// [HarmonyPrefix] [HarmonyPriority(Priority.First)] [HarmonyPatch(typeof(Portal), nameof(Portal.ActOnUse), new[] { typeof(WorldObject) })] public static void PrePortalUse() => Enter(); [HarmonyFinalizer] [HarmonyPatch(typeof(Portal), nameof(Portal.ActOnUse), new[] { typeof(WorldObject) })] public static void FinalPortalUse() => Leave(); /// /// The destination adjustment itself, for the spell and emote teleports (portal /// recall, portal sending, NPC teleports), which call it inside a lambda and teleport /// in the same breath. Small static methods: where the JIT has inlined one into its /// caller this hook does nothing and the old behaviour stands (the diagnostic below /// would then say so). /// [HarmonyPrefix] [HarmonyPatch(typeof(WorldObject), nameof(WorldObject.AdjustDungeonPos), new[] { typeof(Position) })] public static void PreAdjustPos() => Enter(); [HarmonyFinalizer] [HarmonyPatch(typeof(WorldObject), nameof(WorldObject.AdjustDungeonPos), new[] { typeof(Position) })] public static void FinalAdjustPos() => Leave(); [HarmonyPrefix] [HarmonyPatch(typeof(WorldObject), nameof(WorldObject.AdjustDungeonCells), new[] { typeof(Position) })] public static void PreAdjustCells() => Enter(); [HarmonyFinalizer] [HarmonyPatch(typeof(WorldObject), nameof(WorldObject.AdjustDungeonCells), new[] { typeof(Position) })] public static void FinalAdjustCells() => Leave(); /// /// Called from the physics landblock lookup (Patches.PreGetPhysicsLandblock) when it /// would otherwise hand the question to ACE. Inside a dat-only call, for an instanced /// landblock (or one with copies open) whose master is not loaded: the dat stand-in. /// Otherwise null, and ACE answers as always. /// public static PhysicsLandblock? ShellFor(ushort landblock) { if (_datOnly <= 0 || !Mod.Settings.Enabled) return null; try { if (!Mod.IsInstanced(landblock) && InstanceWorld.OpenCount(landblock) == 0) return null; if (LandblockManager.IsLoaded(new LandblockId((uint)landblock << 16 | 0xFFFF))) return null; // a loaded master answers for itself, as it always has var shell = _shells.GetOrAdd(landblock, lb => new PhysicsLandblock(ACE.Server.Physics.Common.DBObj.GetCellLandblock((uint)lb << 16 | 0xFFFF))); Interlocked.Increment(ref _shellAnswers); // One line a minute per landblock: building a dungeon's cell table the first time // asks once per cell and neighbour - tens of thousands of answers in one call. var now = DateTime.UtcNow; if (!_lastShellLogged.TryGetValue(landblock, out var last) || (now - last).TotalSeconds >= 60) { _lastShellLogged[landblock] = now; Mod.Trace($"{landblock:X4}: destination adjusted from the dat; the master stays unloaded"); } return shell; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] dat stand-in for {landblock:X4} failed, ACE answers: {ex.Message}", ModManager.LogLevel.Warn); return null; } } // ---- the diagnostic ---------------------------------------------------------------- private static readonly ConcurrentDictionary _lastLogged = new(); /// /// Called from LandblockManager.GetLandblock (Patches.PreGetLandblock) when the /// lookup goes to ACE. When that is about to LOAD the master of an instanced /// landblock, says so, with who asked. Hot path: one dictionary lookup in the common /// case; the rest only for an instanced landblock. /// public static void NoteLookup(LandblockId landblockId) { try { var landblock = (ushort)((landblockId.LandblockX << 8) | landblockId.LandblockY); if (!Mod.IsInstanced(landblock) || !Mod.Settings.Enabled) return; if (LandblockManager.IsLoaded(new LandblockId((uint)landblock << 16 | 0xFFFF))) return; var now = DateTime.UtcNow; if (_lastLogged.TryGetValue(landblock, out var last) && (now - last).TotalSeconds < 60) return; _lastLogged[landblock] = now; ModManager.Log($"[{Mod.Name}] MASTER LOAD of {landblock:X4} ({InstanceWorld.OpenCount(landblock)} copy(ies) open, " + $"thread {Environment.CurrentManagedThreadId}): {Caller()}"); } catch { /* a diagnostic must never be the thing that breaks */ } } /// The managed call stack above the lookup, Harmony's own frames left out. private static string Caller() { var frames = new StackTrace(2, false).GetFrames(); var names = new List(); foreach (var frame in frames) { var method = frame.GetMethod(); var type = method?.DeclaringType; if (method is null) continue; var ns = type?.Namespace ?? ""; if (ns.StartsWith("HarmonyLib") || ns.StartsWith("MonoMod") || ns.StartsWith("System")) continue; names.Add($"{type?.Name}.{method.Name}"); if (names.Count >= 16) break; } return string.Join(" <- ", names); } public static string Describe() => $"master guard: {_shellAnswers} lookup(s) answered from the dat, {_shells.Count} stand-in(s) kept"; // ---- console --------------------------------------------------------------------- /// /// /inst-masterguard: the counters, whether the hooks are in place, and a console test /// that runs a portal's destination adjustment for a landblock whose master is not /// loaded and reports whether it stayed unloaded. Never loads a master on purpose. /// [CommandHandler("inst-masterguard", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Instanced dungeon masters: is any loaded, are the hooks in place, and a console test of a portal's destination adjustment. Console-safe.", "/inst-masterguard counters, hooks, which instanced masters are loaded\n" + "/inst-masterguard test adjust a destination in as a portal does; the master must stay unloaded")] public static void HandleCommand(Session session, params string[] parameters) { WorldManager.EnqueueAction(new ActionEventDelegate(() => { try { Reply(session, Run(parameters)); } catch (Exception ex) { Reply(session, $"inst-masterguard failed: {ex}"); } })); } private static string Run(string[] parameters) { var sb = new StringBuilder(); sb.AppendLine(Describe()); sb.AppendLine("hooks: " + string.Join(", ", Hooked(AccessTools.Method(typeof(Portal), nameof(Portal.ActOnUse), new[] { typeof(WorldObject) }), "Portal.ActOnUse"), Hooked(AccessTools.Method(typeof(WorldObject), nameof(WorldObject.AdjustDungeonPos), new[] { typeof(Position) }), "AdjustDungeonPos"), Hooked(AccessTools.Method(typeof(WorldObject), nameof(WorldObject.AdjustDungeonCells), new[] { typeof(Position) }), "AdjustDungeonCells"))); var loaded = Mod.Instanced.Keys .Where(lb => LandblockManager.IsLoaded(new LandblockId((uint)lb << 16 | 0xFFFF))) .Select(lb => $"{lb:X4} ({InstanceWorld.OpenCount(lb)} copy(ies) open)") .ToList(); sb.AppendLine($"instanced masters loaded now: {(loaded.Count == 0 ? "none" : string.Join(", ", loaded))}"); if (parameters.Length >= 2 && parameters[0].Equals("test", StringComparison.OrdinalIgnoreCase)) sb.AppendLine(Test(parameters[1])); return sb.ToString(); } private static string Hooked(System.Reflection.MethodBase? method, string name) { if (method is null) return $"{name} missing"; var info = Harmony.GetPatchInfo(method); var ours = info is not null && info.Prefixes.Concat(info.Finalizers).Any(p => p.owner == Mod.HarmonyId && p.PatchMethod.DeclaringType == typeof(MasterGuard)); return $"{name} {(ours ? "hooked" : "NOT hooked")}"; } private static string Test(string text) { text = text.Trim().Replace("0x", "", StringComparison.OrdinalIgnoreCase); if (text.Length > 4) text = text[..4]; if (!ushort.TryParse(text, System.Globalization.NumberStyles.HexNumber, null, out var landblock)) return $"test: '{text}' is not a hex landblock id"; var id = new LandblockId((uint)landblock << 16 | 0xFFFF); if (!Mod.IsInstanced(landblock)) return $"test: {landblock:X4} is not an instanced dungeon"; if (LandblockManager.IsLoaded(id)) return $"test: the master of {landblock:X4} is loaded already - nothing to prove; try again once it has unloaded"; // The first EnvCell of the dungeon, a little off its origin, as a portal destination. var cell = (uint)landblock << 16 | 0x0100; var before = _shellAnswers; // As ACE's AdjustDungeon does it, with no ambient, from the world thread: once bare // (the AdjustDungeonPos/Cells hooks), once inside a portal-use scope (the ActOnUse hook). var previous = Context.Ambient; Context.Ambient = null; try { Enter(); Position scoped; try { scoped = new Position(cell, 0, 0, 0, 0, 0, 0, 1); WorldObject.AdjustDungeon(scoped); } finally { Leave(); } var afterScoped = LandblockManager.IsLoaded(id); // Bare second: if the JIT has inlined the small hooks away, this one loads the // master, and the scoped result above is already in. var bare = new Position(cell, 0, 0, 0, 0, 0, 0, 1); var afterBare = afterScoped; if (!afterScoped) { WorldObject.AdjustDungeon(bare); afterBare = LandblockManager.IsLoaded(id); } return $"test {landblock:X4}: in a portal scope -> {scoped.Cell:X8}, master {(afterScoped ? "LOADED" : "still unloaded")}; " + $"bare adjustment -> {bare.Cell:X8}, master {(afterBare ? "LOADED (hook inlined away?)" : "still unloaded")}; " + $"{_shellAnswers - before} answer(s) from the dat"; } finally { Context.Ambient = previous; } } private static void Reply(Session? session, string message) { foreach (var line in (message ?? "").Split('\n')) { var text = line.TrimEnd('\r'); if (string.IsNullOrWhiteSpace(text)) continue; if (session?.Player is null) ModManager.Log(text); else session.Player.SendMessage(text); } } }