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