using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Plugins; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using UnityEngine; namespace Oxide.Plugins { // 服务器优化器:把「卡在哪」先量出来,再决定动不动手。 // 设计原则:默认只读不动作,所有清理默认关闭且默认只预览(dry-run),服主看过报告再开。 [Info("ServerOptimizer", "biubr.com", "1.0.0")] [Description("服务器优化器:FPS 自采样 + 智能 GC 调度 + 安全实体清理 + 一键诊断报表")] class ServerOptimizer : RustPlugin { #region 配置 class ConfigData { [JsonProperty("采样间隔秒(多久算一次帧率,太密没意义)")] public int SampleIntervalSeconds { get; set; } [JsonProperty("FPS 低阈值(低于此值记为一次卡顿)")] public int LowFpsThreshold { get; set; } [JsonProperty("诊断历史保留小时(超出自动丢弃最旧的)")] public int HistoryHours { get; set; } [JsonProperty("智能GC:启用")] public bool GcEnabled { get; set; } [JsonProperty("智能GC:托管内存阈值MB(超过才考虑回收)")] public int GcMemoryThresholdMB { get; set; } [JsonProperty("智能GC:两次回收最小间隔秒")] public int GcMinIntervalSeconds { get; set; } [JsonProperty("智能GC:在线人数上限(超过就不做,避免高峰期添乱)")] public int GcMaxPlayers { get; set; } [JsonProperty("智能GC:FPS跌破阈值时立即触发(救急,不看人数)")] public bool GcOnLowFps { get; set; } [JsonProperty("实体清理:总开关(默认关!看过 dry-run 报告再开)")] public bool CleanEnabled { get; set; } [JsonProperty("实体清理:仅预览不真删(dry-run,强烈建议先保持 true)")] public bool CleanDryRun { get; set; } [JsonProperty("实体清理:执行间隔秒")] public int CleanIntervalSeconds { get; set; } [JsonProperty("实体清理:实体总数阈值(超过这个数才清)")] public int CleanEntityThreshold { get; set; } [JsonProperty("实体清理:单轮最多删除个数(防止极端情况卡住)")] public int CleanMaxPerRound { get; set; } [JsonProperty("实体清理:掉落物(地面散落的物品)")] public bool CleanDroppedItems { get; set; } [JsonProperty("实体清理:掉落物存活超过秒(太新的不动,刚死掉的不抢)")] public int DroppedItemMinAgeSeconds { get; set; } [JsonProperty("实体清理:尸体(玩家 / NPC 尸体)")] public bool CleanCorpses { get; set; } [JsonProperty("实体清理:尸体存活超过秒")] public int CorpseMinAgeSeconds { get; set; } [JsonProperty("实体清理:死亡背包(慎用!玩家会骂,默认关)")] public bool CleanDropContainers { get; set; } [JsonProperty("实体清理:死亡背包存活超过秒")] public int DropContainerMinAgeSeconds { get; set; } [JsonProperty("自动报表:每N分钟把诊断报表打进日志(0=关闭)")] public int AutoReportMinutes { get; set; } [JsonProperty("授权 Authorize")] public AuthData Authorize { get; set; } [JsonProperty("输出详细日志")] public bool Verbose { get; set; } } // 授权配置:码留空 = 未授权(只保留只读诊断,自动调度与清理不生效) private class AuthData { [JsonProperty("插件授权码 LicenseCode")] public string LicenseCode = ""; [JsonProperty("离线宽限天数 OfflineGraceDays")] public int OfflineGraceDays = 3; // 只在「连不上授权服务器」时启用,硬上限 3 天 } private ConfigData config; private ConfigData DefaultConfig() => new ConfigData { SampleIntervalSeconds = 5, LowFpsThreshold = 60, HistoryHours = 24, GcEnabled = true, GcMemoryThresholdMB = 4096, GcMinIntervalSeconds = 600, GcMaxPlayers = 20, GcOnLowFps = true, CleanEnabled = false, CleanDryRun = true, CleanIntervalSeconds = 1800, CleanEntityThreshold = 50000, CleanMaxPerRound = 2000, CleanDroppedItems = false, DroppedItemMinAgeSeconds = 1800, CleanCorpses = false, CorpseMinAgeSeconds = 600, CleanDropContainers = false, DropContainerMinAgeSeconds = 3600, AutoReportMinutes = 0, Authorize = new AuthData(), Verbose = true, }; #endregion #region 数据结构 class Sample { public float t; // 距离本次开服的秒数 public float fps; // 帧率 public int ent; // 实体数 public long mem; // 托管堆 MB public long wss; // 进程物理内存 MB public int plr; // 在线人数 } class GcRecord { public float t; public int ms; // 本次回收耗时 public long freed; // 回收掉的 MB(可能为负,表示反而涨了) public string reason; // 触发原因 public float fpsAfter; // 回收后一次采样的帧率 } #endregion #region 授权 // 授权服务地址与插件 ID:换服务器只改 AuthApiBase;plugin_id 必须与后台登记的一致 private const string AuthApiBase = "http://lic.biubr.com"; private const string AuthPluginId = "serveroptimizer"; private const string AuthCacheFile = "ServerOptimizer.auth"; // -> oxide/data/ServerOptimizer.auth.json private const float AuthRetrySeconds = 300f; // 未授权时每 5 分钟自动重试 private const float AuthRenewHours = 6f; // 常驻复核间隔 private const int AuthMaxGraceDays = 3; // 离线宽限硬上限,配置写再多也按 3 天算 private bool _authorized; // 未授权时:只保留诊断,自动调度与清理不生效 private bool _authPending; private AuthCache _authCache; private string _authHint = "尚未校验"; private int _nudgeCount; private class AuthCache { [JsonProperty("code")] public string Code = ""; [JsonProperty("plugin_id")] public string PluginId = ""; [JsonProperty("bound_at")] public string BoundAt = ""; [JsonProperty("last_ok")] public string LastOk = ""; // UTC,宽限期从这里起算 [JsonProperty("plugin_ver")] public string PluginVer = ""; } private class AuthResp { [JsonProperty("ok")] public bool ok; [JsonProperty("error")] public string error; [JsonProperty("msg")] public string msg; } private int GraceDays { get { int d = (config != null && config.Authorize != null) ? config.Authorize.OfflineGraceDays : 3; if (d < 0) d = 0; if (d > AuthMaxGraceDays) d = AuthMaxGraceDays; return d; } } private static bool TryParseUtc(string s, out DateTime t) { t = DateTime.MinValue; if (string.IsNullOrEmpty(s)) return false; return DateTime.TryParse(s, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out t); } private static string MaskCode(string code) { if (string.IsNullOrEmpty(code)) return "(未填写)"; if (code.Length < 12) return code; return code.Substring(0, 7) + "-****-" + code.Substring(code.Length - 4); } private void LoadAuthCache() { try { var f = Interface.Oxide.DataFileSystem.GetFile(AuthCacheFile); _authCache = (f != null && f.Exists()) ? f.ReadObject() : null; } catch (Exception ex) { PrintWarning("授权缓存读取失败(忽略,稍后重新联网校验): " + ex.Message); _authCache = null; } if (_authCache == null) _authCache = new AuthCache(); } private void SaveAuthCache() { try { Interface.Oxide.DataFileSystem.WriteObject(AuthCacheFile, _authCache); } catch (Exception ex) { PrintWarning("授权缓存保存失败: " + ex.Message); } } private bool CacheFresh() { DateTime t; if (_authCache == null) return false; if (_authCache.PluginId != AuthPluginId) return false; if (!TryParseUtc(_authCache.LastOk, out t)) return false; // 配置里写了另一个码 -> 旧凭据立刻作废 string cfgCode = (config != null && config.Authorize != null && config.Authorize.LicenseCode != null) ? config.Authorize.LicenseCode.Trim() : ""; string cacheCode = (_authCache.Code != null) ? _authCache.Code.Trim() : ""; if (cfgCode.Length > 0 && !string.Equals(cfgCode, cacheCode, StringComparison.OrdinalIgnoreCase)) return false; // 防系统时间回拨:当前时间比上次成功还早 6h 以上 = 有人在拨表,不认宽限 if (DateTime.UtcNow < t.AddHours(-6)) return false; return (DateTime.UtcNow - t).TotalDays <= GraceDays; } private string GraceExpireText() { DateTime t; if (_authCache == null || !TryParseUtc(_authCache.LastOk, out t)) return "(无)"; return t.ToLocalTime().AddDays(GraceDays) .ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture); } private string CurrentCode() { string c = (config != null && config.Authorize != null && config.Authorize.LicenseCode != null) ? config.Authorize.LicenseCode.Trim() : ""; if (c.Length > 0) return c.ToUpperInvariant(); return (_authCache != null && _authCache.Code != null) ? _authCache.Code.Trim().ToUpperInvariant() : ""; } private void InitAuthorize() { LoadAuthCache(); timer.Every(AuthRenewHours * 3600f, AuthRenew); // 常驻复核:后台停用最长 6h 生效 if (CacheFresh()) { _authorized = true; _authHint = "已授权(本地凭据有效)"; Puts("[授权] 已授权,本地凭据有效期至 " + GraceExpireText() + "(其间断网也能正常使用)。"); DateTime last; if (!TryParseUtc(_authCache.LastOk, out last) || (DateTime.UtcNow - last).TotalHours >= AuthRenewHours) RequestVerify(null, false); return; } _authorized = false; if (string.IsNullOrEmpty(CurrentCode())) { _authHint = "未填写授权码"; PrintWarning("本插件尚未授权:诊断报表可正常查看,但自动回收调度与实体清理不会生效。"); PrintWarning("激活:配置文件「授权 Authorize → 插件授权码 LicenseCode」填入 PL- 开头的码,"); PrintWarning("   或在控制台执行 serveroptimizer.auth <你的授权码>,然后 oxide.reload ServerOptimizer。"); return; // 没码就不空转重试 } _authHint = "正在联网校验…"; RequestVerify(null, false); } private void RetryAuth() { if (_authorized) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, false); } private void AuthRenew() { if (!initialized) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, false); } private void AuthNudge() { if (_authorized || _nudgeCount++ >= 3) return; Puts("★ 本插件尚未授权(" + _authHint + "):诊断可用,自动回收与清理不生效。" + "激活:控制台执行 serveroptimizer.auth PL-XXXX-XXXX-XXXX"); } private void RequestVerify(string code, bool manualInput) { if (_authPending) return; string use = string.IsNullOrEmpty(code) ? CurrentCode() : code.Trim().ToUpperInvariant(); if (string.IsNullOrEmpty(use)) return; if (webrequest == null) { OnAuthFailed("插件运行环境不支持联网(webrequest 不可用)", false, use); return; } // 故意不上报服务器 IP:IP 由授权服务按请求来源判定,杜绝伪造 var payload = new Dictionary { { "code", use }, { "plugin_id", AuthPluginId } }; var headers = new Dictionary { { "Content-Type", "application/json" } }; _authPending = true; Puts("[授权] 正在向授权服务器校验(" + MaskCode(use) + ")…"); webrequest.Enqueue(AuthApiBase + "/api/plugin/verify", JsonConvert.SerializeObject(payload), (status, response) => OnVerifyResponse(status, response, use), this, Oxide.Core.Libraries.RequestMethod.POST, headers, 15f); } private void OnVerifyResponse(int status, string response, string code) { _authPending = false; if (!initialized && !_authorized) { /* 仍继续处理,保证启动期校验结果能落地 */ } if (status != 200 || string.IsNullOrEmpty(response)) { OnAuthFailed(status == 0 ? "连不上授权服务器(出网被拦 / 服务临时不可用)" : ("授权服务器返回 HTTP " + status), false, code); return; } AuthResp r = null; try { r = JsonConvert.DeserializeObject(response); } catch (Exception) { OnAuthFailed("授权服务器返回内容无法解析", false, code); return; } if (r == null || !r.ok) { OnAuthFailed((r != null && !string.IsNullOrEmpty(r.error)) ? r.error : "授权校验未通过", true, code); return; } _authorized = true; _nudgeCount = 0; _authCache.Code = code; _authCache.PluginId = AuthPluginId; _authCache.LastOk = DateTime.UtcNow.ToString("o", System.Globalization.CultureInfo.InvariantCulture); _authCache.PluginVer = Version.ToString(); if (string.IsNullOrEmpty(_authCache.BoundAt)) _authCache.BoundAt = _authCache.LastOk; SaveAuthCache(); _authHint = "已授权"; Puts("[授权] 校验通过,全部功能已生效。授权码 " + MaskCode(code) + ",本地凭据有效期至 " + GraceExpireText() + "。"); } // serverRejected=true = 服务端明确拒绝(不接受离线宽限) private void OnAuthFailed(string reason, bool serverRejected, string attempted) { // 情形一:这次的码没通过,但本地还存着另一份仍然有效的凭据 -> 回退到那个有效码 if (serverRejected && CacheFresh() && !string.IsNullOrEmpty(_authCache.Code) && !string.Equals(_authCache.Code, attempted, StringComparison.OrdinalIgnoreCase)) { string good = _authCache.Code; config.Authorize.LicenseCode = good; SaveConfig(); PrintWarning("[授权] 这次用的码没通过(" + reason + "),已自动改回原来有效的授权码 " + MaskCode(good) + " 并重新校验。"); RequestVerify(good, false); return; } // 情形二:网络类问题 + 还在宽限期 -> 继续放行(不能因为一次抖动把服停了) if (!serverRejected && CacheFresh()) { _authorized = true; _authHint = "已授权(离线宽限中)"; PrintWarning("[授权] 本次联网校验没成功(" + reason + "),但本地凭据仍在 " + GraceDays + " 天宽限期内,功能继续正常。"); timer.Once(AuthRetrySeconds, RetryAuth); return; } // 情形三:真没授权。服务端明确拒绝时抹掉 last_ok,宽限立刻失效(作者停用时不会被绕过) if (serverRejected && _authCache != null && !string.IsNullOrEmpty(_authCache.LastOk)) { _authCache.LastOk = ""; SaveAuthCache(); Puts("[授权] 已清除本地凭据(服务端拒绝:\"" + reason + "\"),宽限期不再生效。"); } _authorized = false; _authHint = serverRejected ? ("授权失败:" + reason) : ("暂未授权:" + reason); PrintWarning("[授权] 未通过:" + reason + "(诊断报表仍可用,自动回收与清理不生效)"); timer.Once(AuthRetrySeconds, RetryAuth); } private void PrintAuthStatus() { Puts("===== 服务器优化器 ServerOptimizer 授权状态 ====="); Puts(" 状态:" + (_authorized ? "已授权(全部功能生效)" : "未授权(仅诊断报表可用)")); Puts(" 授权码:" + MaskCode(CurrentCode())); Puts(" 插件版本:" + Version.ToString()); Puts(" 本地凭据:" + (CacheFresh() ? ("有效,至 " + GraceExpireText()) : "无 / 已过期")); Puts(" 用法:serveroptimizer.auth (不带参数 = 只看状态)"); Puts(" serveroptimizer.deauth(注销)"); } #endregion #region 字段 private bool initialized; private List samples = new List(); private List gcRecords = new List(); private int lastFrameCount; private float lastSampleClock; private float startTime; // 实体首次被本插件看到的时刻(用于判断存活时长)。 // 不用实体自带的时间字段——各版本字段名不一致,自己观测最稳,且天然做到"首次不删"。 private readonly Dictionary firstSeen = new Dictionary(); private float lastGcClock = -9999f; private float lastCleanClock = -9999f; private Timer sampleTimer; private Timer cleanTimer; private Timer saveTimer; private const string PERM_ADMIN = "serveroptimizer.admin"; private const int MB = 1048576; #endregion #region 生命周期 void Init() { config = DefaultConfig(); permission.RegisterPermission(PERM_ADMIN, this); } void Loaded() { // 沿用官方配置时逐字段兜底:ReadObject 可能返回非 null 但字段全默认/全空 LoadConfigValues(); // 命令显式注册:部分服务端下属性方式收不到 cmd.AddConsoleCommand("perfstat", this, nameof(CmdStat)); cmd.AddConsoleCommand("perfreport", this, nameof(CmdReport)); cmd.AddConsoleCommand("perfgc", this, nameof(CmdGc)); cmd.AddConsoleCommand("perfclean", this, nameof(CmdClean)); cmd.AddChatCommand("perf", this, nameof(CmdChat)); cmd.AddConsoleCommand("serveroptimizer.auth", this, nameof(CmdAuth)); cmd.AddConsoleCommand("serveroptimizer.deauth", this, nameof(CmdDeauth)); } void OnServerInitialized() { initialized = true; InitAuthorize(); startTime = Time.realtimeSinceStartup; lastFrameCount = Time.frameCount; lastSampleClock = startTime; if (config.SampleIntervalSeconds < 1) config.SampleIntervalSeconds = 1; sampleTimer = timer.Repeat(config.SampleIntervalSeconds, 0, TakeSample); if (config.CleanEnabled) { int iv = config.CleanIntervalSeconds < 60 ? 60 : config.CleanIntervalSeconds; cleanTimer = timer.Repeat(iv, iv, () => RunClean(config.CleanDryRun, "定时")); } saveTimer = timer.Repeat(600, 600, SaveHistory); // 自动报表:部分服务端控制台命令收不到,这个开关保证报表一定能落到日志里 if (config.AutoReportMinutes > 0) { int secs = config.AutoReportMinutes * 60; timer.Repeat(secs, secs, () => Puts("\n" + BuildReport())); } if (config.Verbose) Puts($"已启动:每 {config.SampleIntervalSeconds} 秒采样一次 | 智能GC={config.GcEnabled} | 实体清理={config.CleanEnabled}(预览={config.CleanDryRun})"); } void Unload() { initialized = false; sampleTimer?.Destroy(); cleanTimer?.Destroy(); saveTimer?.Destroy(); SaveHistory(); } protected override void LoadDefaultConfig() => config = DefaultConfig(); protected override void SaveConfig() { if (config == null) config = DefaultConfig(); Config.WriteObject(config, true); } private void LoadConfigValues() { var def = DefaultConfig(); try { var loaded = Config.ReadObject(); if (loaded == null) { config = def; return; } config = loaded; // 逐字段兜底,防止旧配置/半截配置导致静默失效 if (config.SampleIntervalSeconds <= 0) config.SampleIntervalSeconds = def.SampleIntervalSeconds; if (config.LowFpsThreshold <= 0) config.LowFpsThreshold = def.LowFpsThreshold; if (config.HistoryHours <= 0) config.HistoryHours = def.HistoryHours; if (config.GcMemoryThresholdMB <= 0) config.GcMemoryThresholdMB = def.GcMemoryThresholdMB; if (config.GcMinIntervalSeconds <= 0) config.GcMinIntervalSeconds = def.GcMinIntervalSeconds; if (config.GcMaxPlayers < 0) config.GcMaxPlayers = def.GcMaxPlayers; if (config.CleanIntervalSeconds <= 0) config.CleanIntervalSeconds = def.CleanIntervalSeconds; if (config.CleanEntityThreshold <= 0) config.CleanEntityThreshold = def.CleanEntityThreshold; if (config.CleanMaxPerRound <= 0) config.CleanMaxPerRound = def.CleanMaxPerRound; if (config.DroppedItemMinAgeSeconds < 0) config.DroppedItemMinAgeSeconds = def.DroppedItemMinAgeSeconds; if (config.CorpseMinAgeSeconds < 0) config.CorpseMinAgeSeconds = def.CorpseMinAgeSeconds; if (config.DropContainerMinAgeSeconds < 0) config.DropContainerMinAgeSeconds = def.DropContainerMinAgeSeconds; if (config.AutoReportMinutes < 0) config.AutoReportMinutes = 0; if (config.Authorize == null) config.Authorize = new AuthData(); if (config.Authorize.LicenseCode == null) config.Authorize.LicenseCode = ""; } catch { config = def; } } #endregion #region 采样 private void TakeSample() { if (!initialized) return; float now = Time.realtimeSinceStartup; int frames = Time.frameCount; float dt = now - lastSampleClock; int df = frames - lastFrameCount; float fps = dt > 0.0001f ? df / dt : 0f; lastSampleClock = now; lastFrameCount = frames; int ent = 0; try { ent = BaseNetworkable.serverEntities.Count; } catch { } long mem; try { mem = GC.GetTotalMemory(false) / MB; } catch { mem = -1; } // 进程物理内存:Environment.WorkingSet 在这个运行时下恒为 0,必须走 Process long wss = -1; try { wss = Process.GetCurrentProcess().WorkingSet64 / MB; } catch { } int plr = BasePlayer.activePlayerList.Count; samples.Add(new Sample { t = now - startTime, fps = fps, ent = ent, mem = mem, wss = wss, plr = plr }); // 只保留配置时长内的历史 int max = (config.HistoryHours * 3600) / config.SampleIntervalSeconds; if (max < 60) max = 60; if (samples.Count > max) samples.RemoveRange(0, samples.Count - max); MaybeScheduleGc(fps, mem, plr, now); } #endregion #region 智能 GC private void MaybeScheduleGc(float fps, long memMB, int players, float now) { if (!config.GcEnabled) return; if (!_authorized) return; // ★ 软锁:未授权不做自动回收调度(诊断照常) if (now - lastGcClock < config.GcMinIntervalSeconds) return; // 救急:帧率已经掉下来了,不管多少人先回收一次 if (config.GcOnLowFps && fps > 0 && fps < config.LowFpsThreshold) { DoGc($"帧率跌破 {config.LowFpsThreshold}(当前 {fps:F1})"); return; } // 常规:内存超阈值且在线低谷,把回收动作挪到人少的时候做 if (memMB >= config.GcMemoryThresholdMB && players <= config.GcMaxPlayers) { DoGc($"内存 {memMB}MB 超阈值且在线 {players} 人(低谷)"); } } private void DoGc(string reason) { if (!initialized) return; long before; try { before = GC.GetTotalMemory(false); } catch { before = 0; } float t0 = Time.realtimeSinceStartup; try { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } catch (Exception ex) { Puts("回收失败:" + ex.Message); return; } float cost = (Time.realtimeSinceStartup - t0) * 1000f; long after; try { after = GC.GetTotalMemory(false); } catch { after = 0; } long freed = (before - after) / MB; // 回收太少 = 没多少可回收的,下次隔久一点再来,别空转着反复停顿 float realNow = Time.realtimeSinceStartup; lastGcClock = realNow + (freed < 32 ? config.GcMinIntervalSeconds * 2 : 0); var rec = new GcRecord { t = realNow - startTime, ms = (int)cost, freed = freed, reason = reason, fpsAfter = 0f, }; gcRecords.Add(rec); if (gcRecords.Count > 200) gcRecords.RemoveRange(0, gcRecords.Count - 200); if (config.Verbose) Puts($"已回收:耗时 {cost:F0} ms,托管堆 {(double)before / MB:F0} → {(double)after / MB:F0} MB({freed:+0;-0} MB),原因:{reason}"); } #endregion #region 实体清理 // 强保护名单:这些一律不碰,误删玩家家当是服主最大的事故 private static readonly string[] ProtectedTypes = { "BuildingBlock", "BuildingPrivlidge", "SleepingBag", "StorageContainer", "BaseVehicle", "CodeLock", "IOEntity", "BasePlayer", "BaseNpc", "AutoTurret", "DecayEntity", "BaseCombatEntity", }; private bool IsProtected(BaseEntity e) { // 只清三类明确的东西,其余一律不动 —— 这是安全性的根本保证 foreach (var t in ProtectedTypes) if (e.GetType().Name == t) return true; // 再保险一层:挂在建筑或领地柜保护下的不碰 try { var priv = e.GetBuildingPrivilege(); if (priv != null) return true; } catch { } return false; } private void RunClean(bool dryRun, string trigger) { if (!initialized) return; if (!_authorized) // ★ 软锁:未授权不做实体清理(诊断照常) { Puts("[清理] 本插件尚未授权,实体清理不生效(诊断报表可正常查看)。" + "激活:控制台执行 serveroptimizer.auth PL-XXXX-XXXX-XXXX"); return; } int total; try { total = BaseNetworkable.serverEntities.Count; } catch { return; } if (total < config.CleanEntityThreshold) { if (config.Verbose && !dryRun) Puts($"跳过清理:实体数 {total} 未达阈值 {config.CleanEntityThreshold}"); return; } float now = Time.realtimeSinceStartup; var toKill = new List(); var kinds = new Dictionary(); int scanned = 0; int protectedSkipped = 0; try { foreach (var n in BaseNetworkable.serverEntities.ToList()) { scanned++; var e = n as BaseEntity; if (e == null || e.IsDestroyed) continue; if (e is BasePlayer) { protectedSkipped++; continue; } if (IsProtected(e)) { protectedSkipped++; continue; } ulong id = e.net.ID.Value; float seen; if (!firstSeen.TryGetValue(id, out seen)) { // 第一次见到:只登记时刻,本轮绝不动它 firstSeen[id] = now; continue; } float age = now - seen; bool hit = false; string kind = null; if (config.CleanDroppedItems && e is DroppedItem && age >= config.DroppedItemMinAgeSeconds) { hit = true; kind = "掉落物"; } else if (config.CleanCorpses && e is BaseCorpse && age >= config.CorpseMinAgeSeconds) { hit = true; kind = "尸体"; } else if (config.CleanDropContainers && e is DroppedItemContainer && age >= config.DropContainerMinAgeSeconds) { hit = true; kind = "死亡背包"; } if (!hit) continue; if (toKill.Count < config.CleanMaxPerRound) { toKill.Add(e); kinds[kind] = kinds.ContainsKey(kind) ? kinds[kind] + 1 : 1; } } } catch (Exception ex) { Puts("扫描实体失败:" + ex.Message); return; } lastCleanClock = Time.realtimeSinceStartup; var sb = new System.Text.StringBuilder(); sb.Append($"扫描 {scanned} 个实体,受保护跳过 {protectedSkipped} 个"); if (kinds.Count > 0) { sb.Append(",命中:"); foreach (var kv in kinds) sb.Append($"{kv.Key} {kv.Value} 个、"); sb.Length -= 1; } else { sb.Append(",没有符合清理条件的实体"); } if (dryRun) { Puts($"[预览/{trigger}] {sb} —— 未真正删除(dry-run)。确认无误后把「仅预览不真删」设为 false 才会真删。"); return; } int killed = 0; foreach (var e in toKill) { try { if (e == null || e.IsDestroyed) continue; firstSeen.Remove(e.net.ID.Value); e.Kill(BaseNetworkable.DestroyMode.None); killed++; } catch { } } // 观测表别无限涨:超过上限就重置,下一轮重新登记 if (firstSeen.Count > 100000) firstSeen.Clear(); Puts($"[已清理/{trigger}] {sb},实际删除 {killed} 个。"); } #endregion #region 报表 private float AvgFps(float seconds) { if (samples.Count == 0) return 0f; float now = samples[samples.Count - 1].t; var list = samples.Where(s => s.t >= now - seconds).ToList(); if (list.Count == 0) return 0f; return (float)list.Average(s => s.fps); } private string BuildReport() { var sb = new System.Text.StringBuilder(); sb.AppendLine("========== 服务器性能诊断 =========="); if (samples.Count == 0) { sb.AppendLine("还没有采样数据,等一个采样间隔再看。"); return sb.ToString(); } var last = samples[samples.Count - 1]; float up = last.t; string upStr = up >= 3600f ? $"{up / 3600f:F1} 小时" : $"{up / 60f:F1} 分钟"; sb.AppendLine($"已运行:{upStr} 采样点:{samples.Count} 个 间隔:{config.SampleIntervalSeconds} 秒"); sb.AppendLine($"授权状态:{(_authorized ? "已授权(全部功能生效)" : "未授权(仅诊断可用,自动回收与清理不生效)")} 码:{MaskCode(CurrentCode())}"); sb.AppendLine(); sb.AppendLine($"当前帧率:{last.fps:F1} 近 1 分钟:{AvgFps(60):F1} 近 5 分钟:{AvgFps(300):F1} 近 1 小时:{AvgFps(3600):F1}"); sb.AppendLine($"实体数:{last.ent} 托管堆:{last.mem} MB 进程内存:{last.wss} MB 在线:{last.plr} 人"); // 掉速判定:用户最关心的「数值是不是一直在掉」 var first = samples[0]; if (up > 600 && first.fps > 0) { float drop = (last.fps - first.fps) / first.fps * 100f; string verdict = drop < -15 ? "明显下滑" : (drop < -5 ? "轻微下滑" : "基本平稳"); sb.AppendLine($"掉速趋势:开服 {first.fps:F1} → 现在 {last.fps:F1}({drop:+0.0;-0.0}%,{verdict})"); } float minFps = samples.Min(s => s.fps); int bad = samples.Count(s => s.fps > 0 && s.fps < config.LowFpsThreshold); int badRecent = samples.Where(s => s.t >= last.t - 3600).Count(s => s.fps > 0 && s.fps < config.LowFpsThreshold); sb.AppendLine($"最低帧率:{minFps:F1} 低于 {config.LowFpsThreshold} 的采样:共 {bad} 次,最近 1 小时 {badRecent} 次"); sb.AppendLine(); if (gcRecords.Count == 0) { sb.AppendLine("垃圾回收:尚未触发(阈值未到,属正常)"); } else { var recent = gcRecords.Where(g => g.t >= last.t - 3600).ToList(); var src = recent.Count > 0 ? recent : gcRecords; sb.AppendLine($"垃圾回收:共 {gcRecords.Count} 次(最近 1 小时 {recent.Count} 次)"); sb.AppendLine($" 平均耗时:{src.Average(g => g.ms):F0} ms 最长:{src.Max(g => g.ms)} ms"); sb.AppendLine($" 平均回收:{src.Average(g => g.freed):F0} MB"); var lg = gcRecords[gcRecords.Count - 1]; sb.AppendLine($" 最近一次:{lg.ms} ms / {lg.freed} MB 原因:{lg.reason}"); } // 实体构成 Top8 —— 看清是什么东西吃掉了帧率 sb.AppendLine(); try { var dict = new Dictionary(); foreach (var n in BaseNetworkable.serverEntities) { var e = n as BaseEntity; if (e == null || e.IsDestroyed) continue; string k = e.ShortPrefabName; if (string.IsNullOrEmpty(k)) k = e.GetType().Name; dict[k] = dict.ContainsKey(k) ? dict[k] + 1 : 1; } var top = dict.OrderByDescending(kv => kv.Value).Take(8); sb.AppendLine("实体构成 Top8:"); foreach (var kv in top) sb.AppendLine($" {kv.Key}:{kv.Value}"); } catch (Exception ex) { sb.AppendLine("实体构成统计失败:" + ex.Message); } sb.AppendLine(); sb.AppendLine("建议:"); if (minFps > 0 && minFps < config.LowFpsThreshold) sb.AppendLine($" · 帧率曾跌到 {minFps:F1},先把「智能GC」和「掉落物清理」开起来观察"); if (last.ent >= config.CleanEntityThreshold) sb.AppendLine($" · 实体数 {last.ent} 已达阈值,建议开启清理(先 dry-run 看命中清单)"); else sb.AppendLine($" · 实体数 {last.ent},未达清理阈值 {config.CleanEntityThreshold}"); sb.AppendLine(" · 每 5 分钟存盘会卡 2~8 秒,建议把 server.saveinterval 从 300 调到 600"); sb.AppendLine(" · 帧率上限受单核性能限制,插件只能减负,不能凭空造性能"); return sb.ToString(); } #endregion #region 命令 private void CmdStat(ConsoleSystem.Arg arg) { if (samples.Count == 0) { SendReply(arg, "还没有采样数据"); return; } var s = samples[samples.Count - 1]; SendReply(arg, $"帧率 {s.fps:F1} | 1分钟 {AvgFps(60):F1} | 实体 {s.ent} | 堆 {s.mem}MB | 内存 {s.wss}MB | 在线 {s.plr} | GC {gcRecords.Count} 次"); } private void CmdReport(ConsoleSystem.Arg arg) { SendReply(arg, "\n" + BuildReport()); } private void CmdGc(ConsoleSystem.Arg arg) { if (!_authorized) { SendReply(arg, "尚未授权:手动回收不可用(诊断报表可正常查看)。激活:serveroptimizer.auth <码>"); return; } DoGc("手动触发"); SendReply(arg, "已执行一次垃圾回收,详见上方日志"); } private void CmdClean(ConsoleSystem.Arg arg) { if (!_authorized) { SendReply(arg, "尚未授权:实体清理不可用(诊断报表可正常查看)。激活:serveroptimizer.auth <码>"); return; } bool dry = config.CleanDryRun; if (arg.HasArgs()) { string a = arg.GetString(0).ToLower(); if (a == "run") dry = false; else if (a == "dry") dry = true; } RunClean(dry, "手动"); SendReply(arg, dry ? "已扫描(预览模式,未删除),结果见上方日志" : "已清理,结果见上方日志"); } private void CmdChat(BasePlayer player, string command, string[] args) { if (player != null && !permission.UserHasPermission(player.UserIDString, PERM_ADMIN)) { SendReply(player, "没有权限"); return; } if (args != null && args.Length > 0 && args[0].ToLower() == "gc") { DoGc("管理员手动"); SendReply(player, "已执行一次垃圾回收"); return; } SendReply(player, "\n" + BuildReport()); } // 控制台 / RCON:serveroptimizer.auth [授权码],不带参数只看状态 private void CmdAuth(ConsoleSystem.Arg arg) { string code = (arg != null && arg.HasArgs()) ? arg.GetString(0).Trim() : ""; if (string.IsNullOrEmpty(code)) { PrintAuthStatus(); return; } if (config == null) config = DefaultConfig(); if (config.Authorize == null) config.Authorize = new AuthData(); config.Authorize.LicenseCode = code.ToUpperInvariant(); SaveConfig(); Puts("[授权] 已写入配置,开始校验 " + MaskCode(code) + " …"); RequestVerify(code, true); } // 注销:清本地凭据 + 清配置里的码,立刻回到未授权 private void CmdDeauth(ConsoleSystem.Arg arg) { _authorized = false; _authHint = "已注销(请重新填授权码)"; _authCache = new AuthCache(); SaveAuthCache(); if (config != null && config.Authorize != null) { config.Authorize.LicenseCode = ""; SaveConfig(); } Puts("[授权] 已注销,插件回到未授权状态(仅诊断报表可用)。重新激活:serveroptimizer.auth <码>"); } #endregion #region 存档 private void SaveHistory() { try { Interface.Oxide.DataFileSystem.WriteObject("ServerOptimizer/history", samples); } catch { } } #endregion } }