Fix GeoLite crash, add PerfMode profiling, reload cache invalidation

- GeoLite/MaxMind lookup wrapped in try/catch and disabled for the session on failure
  (stops the per-tick exception spam when the assembly cannot load during hot-reload)
- New PerfMode config option: writes performance logs to logs/perf_<date>.txt
  (DisableAll/SetSkill/PlayerDeath totals, per-skill SkillAction timings,
  aggregated OnTick/CheckTransmit avg-max summaries)
- css_reload now invalidates the skill-info and freeze-time caches
- Removed the last null-forgiving player lookups (Command, Armored, Baseball,
  FriendlyFire, OneShot)
This commit is contained in:
ByDexter 2026-07-06 02:50:56 +03:00
parent 25cd8eb989
commit b68a033b4c
14 changed files with 157 additions and 10 deletions

View file

@ -78,7 +78,7 @@ namespace src.command
player = PlayerManager.GetPlayerEvent(player);
if (player == null || !player.IsValid) return;
var playerInfo = PlayerManager.GetPlayerByIndex(PlayerManager.GetPlayerEvent(player)!.Index);
var playerInfo = PlayerManager.GetPlayerByIndex(PlayerManager.GetPlayerEvent(player)?.Index ?? player.Index);
if (playerInfo == null || playerInfo.IsDrawing) return;
var playerPawn = player.PlayerPawn.Value;
@ -406,7 +406,7 @@ namespace src.command
if (player == null || !player.IsValid || player.PlayerPawn.Value == null || !player.PlayerPawn.Value.IsValid) return;
if (!string.IsNullOrEmpty(config.NormalCommands.HudCommand.Permissions) && !AdminManager.PlayerHasPermissions(player, config.NormalCommands.HudCommand.Permissions)) return;
var playerInfo = PlayerManager.GetPlayerByIndex(PlayerManager.GetPlayerEvent(player)!.Index);
var playerInfo = PlayerManager.GetPlayerByIndex(PlayerManager.GetPlayerEvent(player)?.Index ?? player.Index);
if (playerInfo == null) return;
playerInfo.DisplayHUD = !playerInfo.DisplayHUD;
@ -570,6 +570,9 @@ namespace src.command
if (SkillsInfo.GetValue<bool>(skill, "active"))
Instance.SkillAction(skill.ToString()!, "LoadSkill");
SkillData.Invalidate();
Event.InvalidateFreezeDisabledCache();
if (player != null && player.IsValid)
player.PrintToChat($" {ChatColors.Green}{player.GetTranslation("reload")}");
else

View file

@ -114,7 +114,15 @@ namespace src
return type.GetMethod(key.Method, BindingFlags.Static | BindingFlags.Public);
});
return method?.Invoke(null, param);
if (method == null) return null;
if (!PerfLog.Enabled)
return method.Invoke(null, param);
long perfStart = PerfLog.Start();
var result = method.Invoke(null, param);
PerfLog.End($"SkillAction {skill}.{methodName}", perfStart, 2.0);
return result;
}
internal new void AddCommand(string name, string description, CommandInfo.CommandCallback handler)

View file

@ -0,0 +1,85 @@
using src.utils;
using System.Collections.Concurrent;
using System.Diagnostics;
using static src.jRandomSkills;
namespace src.player
{
public static class PerfLog
{
private static readonly string logsFolder = Path.Combine(Instance.ModuleDirectory, "logs");
private static readonly string sessionId = $"{DateTime.Now:yyyy-MM-dd_HH-mm-ss}";
private static StreamWriter? _writer;
private static readonly object _writeLock = new();
public static bool Enabled => Config.LoadedConfig.PerfMode;
public static long Start() => Enabled ? Stopwatch.GetTimestamp() : 0;
// One-shot measurement: logs "label took X.XXms" when the elapsed time reaches the threshold.
public static void End(string label, long startTimestamp, double thresholdMs = 1.0)
{
if (startTimestamp == 0 || !Enabled) return;
double ms = (Stopwatch.GetTimestamp() - startTimestamp) * 1000.0 / Stopwatch.Frequency;
if (ms < thresholdMs) return;
Write($"{label} took {ms:F2}ms");
}
private sealed class Aggregate
{
public double TotalMs;
public double MaxMs;
public int Count;
public DateTime WindowStart = DateTime.Now;
}
private static readonly ConcurrentDictionary<string, Aggregate> _aggregates = new();
// Per-tick measurement: accumulates and logs an avg/max summary every few seconds,
// so tick paths do not produce one log line per tick.
public static void Sample(string label, long startTimestamp, double reportSeconds = 5.0, double maxThresholdMs = 0.5)
{
if (startTimestamp == 0 || !Enabled) return;
double ms = (Stopwatch.GetTimestamp() - startTimestamp) * 1000.0 / Stopwatch.Frequency;
var agg = _aggregates.GetOrAdd(label, _ => new Aggregate());
lock (agg)
{
agg.TotalMs += ms;
agg.Count++;
if (ms > agg.MaxMs) agg.MaxMs = ms;
if ((DateTime.Now - agg.WindowStart).TotalSeconds < reportSeconds) return;
if (agg.MaxMs >= maxThresholdMs)
Write($"{label} avg={agg.TotalMs / agg.Count:F2}ms max={agg.MaxMs:F2}ms samples={agg.Count}");
agg.TotalMs = 0;
agg.MaxMs = 0;
agg.Count = 0;
agg.WindowStart = DateTime.Now;
}
}
private static void Write(string message)
{
lock (_writeLock)
{
try
{
if (_writer == null)
{
Directory.CreateDirectory(logsFolder);
_writer = new StreamWriter(Path.Combine(logsFolder, $"perf_{sessionId}.txt"), append: true, System.Text.Encoding.UTF8) { AutoFlush = true };
}
_writer.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [PERF] {message}");
}
catch
{
}
}
}
}
}

View file

@ -440,6 +440,7 @@ namespace src.player
private static void OnTick()
{
long perfStart = PerfLog.Start();
lock (setLock)
{
_activeSkillsSet.Clear();
@ -462,6 +463,7 @@ namespace src.player
Instance.SkillAction(_skillNames[skill], "OnTick");
}
}
PerfLog.Sample("OnTick(skills)", perfStart);
}
private static void OnPlayerConnectedBot(int playerSlot)
@ -618,6 +620,13 @@ namespace src.player
}
private static void DisableAll()
{
long perfStart = PerfLog.Start();
DisableAllCore();
PerfLog.End("DisableAll total", perfStart, 2.0);
}
private static void DisableAllCore()
{
lock (setLock)
{
@ -742,6 +751,14 @@ namespace src.player
}
private static HookResult PlayerDeath(EventPlayerDeath @event, GameEventInfo info)
{
long perfStart = PerfLog.Start();
var result = PlayerDeathCore(@event, info);
PerfLog.End("PlayerDeath total", perfStart, 2.0);
return result;
}
private static HookResult PlayerDeathCore(EventPlayerDeath @event, GameEventInfo info)
{
lock (setLock)
{
@ -847,6 +864,13 @@ namespace src.player
}
private static void SetSkill()
{
long perfStart = PerfLog.Start();
SetSkillCore();
PerfLog.End("SetSkill total", perfStart, 2.0);
}
private static void SetSkillCore()
{
setSkillTimer = null;
lock (setLock)
@ -1149,10 +1173,12 @@ namespace src.player
public static void CheckTransmit([CastFrom(typeof(nint))] CCheckTransmitInfoList infoList)
{
long perfStart = PerfLog.Start();
lock (setLock)
{
DispatchToActiveSkills("CheckTransmit", infoList);
}
PerfLog.Sample("CheckTransmit", perfStart);
}
public static void UpdateSkillHUD(CCSPlayerController? player, string? headerLine, string? centerLine, string? extraLine, bool isDescription)

View file

@ -17,11 +17,13 @@ namespace src.player
UpdateGameRules();
if (Server.TickCount % 2 != 0) return;
long perfStart = PerfLog.Start();
foreach (var player in Utilities.GetPlayers())
{
if (player != null && player.IsValid)
UpdatePlayerHud(player);
}
PerfLog.Sample("OnTick(hud)", perfStart);
});
Instance.RegisterListener<OnMapStart>(OnMapStart);

View file

@ -47,7 +47,7 @@ namespace src.player.skills
var victim = victimController.As<CCSPlayerController>();
if (victim == null || !victim.IsValid) return;
var playerInfo = PlayerManager.GetPlayerByIndex(PlayerManager.GetPlayerEvent(victim)!.Index);
var playerInfo = PlayerManager.GetPlayerByIndex((PlayerManager.GetPlayerEvent(victim)?.Index ?? victim.Index));
if (playerInfo == null) return;
if (playerInfo.Skill == skillName && victim.PawnIsAlive)

View file

@ -36,7 +36,7 @@ namespace src.player.skills
if (victim.Index == attacker.Index || victim.Team == attacker.Team)
return;
var attackerInfo = PlayerManager.GetPlayerByIndex(PlayerManager.GetPlayerEvent(attacker)!.Index);
var attackerInfo = PlayerManager.GetPlayerByIndex((PlayerManager.GetPlayerEvent(attacker)?.Index ?? attacker.Index));
if (attackerInfo?.Skill != skillName) return;
SkillUtils.TakeHealth(victim!.PlayerPawn.Value, SkillsInfo.GetValue<int>(skillName, "damageDeal"));

View file

@ -50,7 +50,7 @@ namespace src.player.skills
var victim = victimController.As<CCSPlayerController>();
if (victim == null || !victim.IsValid) return;
var playerInfo = PlayerManager.GetPlayerByIndex(PlayerManager.GetPlayerEvent(attacker)!.Index);
var playerInfo = PlayerManager.GetPlayerByIndex((PlayerManager.GetPlayerEvent(attacker)?.Index ?? attacker.Index));
if (playerInfo?.Skill != skillName || attacker!.Team != victim!.Team) return;
float damage = param2.Damage;

View file

@ -32,9 +32,10 @@ namespace src.player.skills
if (attackerPawn == null || attackerPawn.Controller?.Value == null || victimPawn == null || victimPawn.Controller?.Value == null)
return;
CCSPlayerController attacker = PlayerManager.GetPlayerEvent(attackerPawn.Controller.Value.As<CCSPlayerController>())!;
var attacker = PlayerManager.GetPlayerEvent(attackerPawn.Controller.Value.As<CCSPlayerController>());
if (attacker == null || !attacker.IsValid) return;
var playerInfo = PlayerManager.GetPlayerByIndex(PlayerManager.GetPlayerEvent(attacker)!.Index);
var playerInfo = PlayerManager.GetPlayerByIndex(attacker.Index);
if (playerInfo == null) return;
if (playerInfo.Skill == skillName)

View file

@ -80,6 +80,7 @@ namespace src.utils
public bool EnableBotSkills { get; set; }
public bool EnableBotKickDebug { get; set; }
public bool DebugMode { get; set; }
public bool PerfMode { get; set; }
public string? AlternativeSkillButton { get; set; }
public float SkillTimeBeforeStart { get; set; }
public float SkillHudDuration { get; set; }
@ -107,6 +108,7 @@ namespace src.utils
EnableBotSkills = true;
EnableBotKickDebug = false;
DebugMode = false;
PerfMode = false;
AlternativeSkillButton = null;
SkillTimeBeforeStart = 7;
SkillHudDuration = -1;

View file

@ -172,6 +172,8 @@ namespace src.utils
return key;
}
private static bool _geoLiteBroken = false;
private static string GetLangCode(CCSPlayerController? player)
{
if (player == null || !player.IsValid || player.IsBot) return defaultLangCode;
@ -180,10 +182,24 @@ namespace src.utils
if (!string.IsNullOrEmpty(fileLangCode))
return fileLangCode;
if (Config.LoadedConfig.LanguageSystem.DisableGeoLite == true)
if (Config.LoadedConfig.LanguageSystem.DisableGeoLite == true || _geoLiteBroken)
return defaultLangCode;
string? geoliteLandCode = GetLangCodeFromDatabase(GetPlayerIP(player)) ?? defaultLangCode;
string? geoliteLandCode;
try
{
geoliteLandCode = GetLangCodeFromDatabase(GetPlayerIP(player)) ?? defaultLangCode;
}
catch (Exception ex)
{
// MaxMind.Db can fail to load (e.g. hot-reload while the old AssemblyLoadContext is
// unloading) or the .mmdb can be corrupt; fall back to the default language and stop
// trying for the rest of the session instead of throwing every HUD tick.
_geoLiteBroken = true;
Server.PrintToConsole($"[jRandomSkills] GeoLite lookup disabled for this session: {ex.GetType().Name}: {ex.Message}");
geoliteLandCode = defaultLangCode;
}
ChangePlayerLanguage(player, geoliteLandCode);
return geoliteLandCode;
}
@ -197,6 +213,9 @@ namespace src.utils
return parts.Length > 1 ? parts[0] : playerIP;
}
// NoInlining keeps the MaxMind.Db type references out of GetLangCode, so an assembly-load
// failure surfaces inside the try/catch at the call site instead of when GetLangCode is JITed.
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private static string? GetLangCodeFromDatabase(string? playerIP)
{
if (string.IsNullOrEmpty(playerIP)) return null;

View file

@ -8,6 +8,7 @@
"EnableBotSkills": true,
"EnableBotKickDebug": false,
"DebugMode": false,
"PerfMode": false,
"AlternativeSkillButton": null,
"SkillTimeBeforeStart": 7.0,
"SkillHudDuration": -1.0,