using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Configuration; using Oxide.Core.Libraries; // webrequest 的 RequestMethod 枚举在这个命名空间下 using Oxide.Core.Plugins; using System; using System.Collections; using System.Collections.Generic; using System.IO; using UnityEngine; using UnityEngine.Networking; namespace Oxide.Plugins { // 注意:插件内部名保持 ImageLibrary —— 服主直接覆盖 oxide/plugins/ImageLibrary.cs, // 其他插件的 [PluginReference] Plugin ImageLibrary 依然能找到它,零改动迁移。 [Info("ImageLibrary", "biubr.com", "2.0.68-cn")] [Description("ImageLibrary 国内加速版:本地图标包 + 并发下载 + 快速失败,API 完全兼容官方")] class ImageLibrary : RustPlugin { #region 常量与配置 private const int MAX_IMAGE_BYTES = 3145728; // 3MB,与官方一致,超过客户端收不到 class ConfigData { [JsonProperty("物品图标镜像根地址(国内 CDN/COS,末尾带斜杠;留空=只用本地图标包)")] public string IconMirrorUrl { get; set; } [JsonProperty("本地图标目录(相对 oxide/data/,放 png,文件名即图片名如 wood_0.png)")] public string LocalIconDir { get; set; } [JsonProperty("并发下载路数(官方是串行 1 路)")] public int Concurrency { get; set; } [JsonProperty("单张下载超时秒数(超时立刻跳过,不卡住整个队列)")] public int TimeoutSeconds { get; set; } [JsonProperty("单张失败最大重试次数")] public int MaxRetry { get; set; } [JsonProperty("启动时自动注册本地图标包")] public bool AutoLoadLocalIcons { get; set; } [JsonProperty("启动时自动从镜像并发拉取物品图标(填了镜像就生效,服主不用敲命令)")] public bool AutoLoadIconsOnBoot { get; set; } [JsonProperty("本地注册每帧处理张数(防止大包注册时卡顿)")] public int LocalBatchPerFrame { get; set; } [JsonProperty("输出详细日志")] public bool Verbose { get; set; } [JsonProperty("维护用:启动时把已缓存图片全部导出到磁盘(用于制作图标包)")] public bool ExportImagesToDisk { get; set; } [JsonProperty("维护用:导出目录(相对 oxide/data/)")] public string ExportDir { get; set; } [JsonProperty("授权 Authorize")] public AuthData Authorize { get; set; } } // 授权配置:码留空 = 未授权(等价官方原版行为:不启用国内镜像与自动预载) private class AuthData { [JsonProperty("插件授权码 LicenseCode")] public string LicenseCode = ""; [JsonProperty("离线宽限天数 OfflineGraceDays")] public int OfflineGraceDays = 3; // 只在连不上授权服务器时启用,硬上限 3 天 } private ConfigData config; protected override void LoadDefaultConfig() { config = new ConfigData { IconMirrorUrl = "", LocalIconDir = "ImageLibrary/icons", Concurrency = 8, TimeoutSeconds = 8, MaxRetry = 2, AutoLoadLocalIcons = true, AutoLoadIconsOnBoot = true, LocalBatchPerFrame = 20, Verbose = true, Authorize = new AuthData() }; } protected override void LoadConfig() { base.LoadConfig(); try { config = Config.ReadObject(); } catch { config = null; } // 关键:服主覆盖安装时 oxide/config/ImageLibrary.json 是官方旧配置, // 键名全不匹配 → ReadObject 返回的对象字段全是 null(对象本身非 null), // 必须逐字段兜底,否则本地目录为空字符串会抛异常、并发数 0 会导致永远不下载。 ConfigData def = new ConfigData { IconMirrorUrl = "", LocalIconDir = "ImageLibrary/icons", Concurrency = 8, TimeoutSeconds = 8, MaxRetry = 2, AutoLoadLocalIcons = true, AutoLoadIconsOnBoot = true, LocalBatchPerFrame = 20, Verbose = true, ExportImagesToDisk = false, ExportDir = "ImageLibrary/export", Authorize = new AuthData() }; if (config == null) config = def; if (config.ExportDir == null) config.ExportDir = def.ExportDir; if (config.IconMirrorUrl == null) config.IconMirrorUrl = def.IconMirrorUrl; if (config.LocalIconDir == null) config.LocalIconDir = def.LocalIconDir; if (config.Concurrency <= 0) config.Concurrency = def.Concurrency; if (config.TimeoutSeconds <= 0) config.TimeoutSeconds = def.TimeoutSeconds; if (config.MaxRetry < 0) config.MaxRetry = def.MaxRetry; if (config.LocalBatchPerFrame <= 0) config.LocalBatchPerFrame = def.LocalBatchPerFrame; if (config.Authorize == null) config.Authorize = new AuthData(); if (config.Authorize.LicenseCode == null) config.Authorize.LicenseCode = ""; SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(config); #endregion #region 授权 // 授权服务地址与插件 ID:换服务器只改 AuthApiBase;plugin_id 必须与后台登记的一致 private const string AuthApiBase = "http://lic.biubr.com"; private const string AuthPluginId = "imagelibrary-cn"; private const string AuthCacheFile = "ImageLibrary.auth"; // -> oxide/data/ImageLibrary.auth.json private const float AuthRetrySeconds = 300f; // 未授权时每 5 分钟自动重试 private const float AuthRenewHours = 6f; // 常驻复核间隔 private const int AuthMaxGraceDays = 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 = ""; [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; // 防系统时间回拨 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); 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 = "未填写授权码"; // 温和软锁:未授权只是没有加速,图片 API 与官方一致,绝不影响依赖插件正常工作 PrintWarning("本插件尚未授权:国内镜像与自动预载不启用,行为等同官方原版(图片照常可用,只是没有加速)。"); PrintWarning("激活:配置文件「授权 Authorize → 插件授权码 LicenseCode」填入 PL- 开头的码,"); PrintWarning("   或在控制台执行 imagelibrary.auth <你的授权码>,然后 oxide.reload ImageLibrary。"); 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 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; } 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, RequestMethod.POST, headers, 15f); } private void OnVerifyResponse(int status, string response, string code) { _authPending = false; 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() + "。"); } 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; } if (serverRejected && _authCache != null && !string.IsNullOrEmpty(_authCache.LastOk)) { _authCache.LastOk = ""; SaveAuthCache(); Puts("[授权] 已清除本地凭据(服务端拒绝:\"" + reason + "\"),宽限期不再生效。"); } _authorized = false; _authHint = serverRejected ? ("授权失败:" + reason) : ("暂未授权:" + reason); PrintWarning("[授权] 未通过:" + reason + "(已退回官方原版行为,图片 API 不受影响)"); timer.Once(AuthRetrySeconds, RetryAuth); } private void PrintAuthStatus() { Puts("===== 图片库加速版 ImageLibrary 授权状态 ====="); Puts(" 状态:" + (_authorized ? "已授权(国内镜像 + 自动预载生效)" : "未授权(等同官方原版行为)")); Puts(" 授权码:" + MaskCode(CurrentCode())); Puts(" 插件版本:" + Version.ToString()); Puts(" 本地凭据:" + (CacheFresh() ? ("有效,至 " + GraceExpireText()) : "无 / 已过期")); Puts(" 用法:imagelibrary.auth (不带参数 = 只看状态)"); Puts(" imagelibrary.deauth(注销)"); } #endregion #region 数据 // 图片名 -> CRC32(与官方 image_data.json 格式一致,覆盖安装后旧图直接复用) class StoredImages { public Dictionary imageIds = new Dictionary(); } // 图片名 -> 源 URL(沿用官方 image_urls.json,供 GetImageURL / 失效重下使用) class StoredUrls { public Dictionary URLs = new Dictionary(); } private Dictionary imageIds = new Dictionary(); private Dictionary imageUrls = new Dictionary(); private readonly Dictionary queueUrls = new Dictionary(); private readonly Dictionary pendingData = new Dictionary(); // 确认拿不到的图(多为国外图源本来就没有的物品):记下来,开服不再反复请求 private HashSet knownMissing = new HashSet(); private readonly HashSet inFlight = new HashSet(); private readonly Dictionary retryCount = new Dictionary(); private readonly Dictionary> callbacks = new Dictionary>(); private int activeWorkers; private bool initialized; private bool savingData; private string noneCrc; // 无图占位 private string loadCrc; // 加载中占位 private void LoadDataFiles() { StoredImages stored = Interface.Oxide.DataFileSystem.ReadObject("ImageLibrary/image_data"); if (stored?.imageIds != null) imageIds = stored.imageIds; StoredUrls urls = Interface.Oxide.DataFileSystem.ReadObject("ImageLibrary/image_urls"); if (urls?.URLs != null) imageUrls = urls.URLs; List missing = Interface.Oxide.DataFileSystem.ReadObject>("ImageLibrary/missing"); if (missing != null) knownMissing = new HashSet(missing); } private void SaveData() { if (savingData) return; savingData = true; timer.Once(2f, () => { savingData = false; Interface.Oxide.DataFileSystem.WriteObject("ImageLibrary/image_data", new StoredImages { imageIds = imageIds }); Interface.Oxide.DataFileSystem.WriteObject("ImageLibrary/image_urls", new StoredUrls { URLs = imageUrls }); Interface.Oxide.DataFileSystem.WriteObject("ImageLibrary/missing", new List(knownMissing)); }); } #endregion #region 生命周期 private void Init() { LoadConfig(); LoadDataFiles(); } // 用 Oxide 显式注册命令:[ConsoleCommand] 属性在部分服上收不到,显式注册最稳 private void Loaded() { cmd.AddConsoleCommand("ilstats", this, nameof(CmdStats)); cmd.AddConsoleCommand("ilmirror", this, nameof(CmdMirror)); cmd.AddConsoleCommand("ilicons", this, nameof(CmdLoadIcons)); cmd.AddConsoleCommand("illocal", this, nameof(CmdLocalDir)); cmd.AddConsoleCommand("ilreset", this, nameof(CmdReset)); cmd.AddConsoleCommand("imagelibrary.auth", this, nameof(CmdAuth)); cmd.AddConsoleCommand("imagelibrary.deauth", this, nameof(CmdDeauth)); } private void OnServerInitialized() { initialized = true; InitAuthorize(); CreatePlaceholders(); FlushPendingData(); // 把初始化前攒下的本地图一次性落盘 if (config.AutoLoadLocalIcons) ServerMgr.Instance.StartCoroutine(RegisterLocalIcons()); // 配了国内镜像就开服自动并发拉取,服主不用敲任何命令 // ★ 软锁:未授权不启用镜像自动预载(退回官方原版行为,图片 API 照常可用) if (_authorized && config.AutoLoadIconsOnBoot && !string.IsNullOrEmpty(config.IconMirrorUrl)) timer.Once(6f, LoadAllItemIcons); if (config.ExportImagesToDisk) ServerMgr.Instance.StartCoroutine(ExportAllImages()); // 旧数据里已注册但重启后丢失的图,有 URL 就自动补下 timer.Once(3f, RequeueMissing); } private void Unload() => SaveDataNow(); private void SaveDataNow() { Interface.Oxide.DataFileSystem.WriteObject("ImageLibrary/image_data", new StoredImages { imageIds = imageIds }); Interface.Oxide.DataFileSystem.WriteObject("ImageLibrary/image_urls", new StoredUrls { URLs = imageUrls }); } // 初始化前攒下的本地图(AddImageData 在服务器就绪前被调用时先存内存) private void FlushPendingData() { if (pendingData.Count == 0) return; foreach (KeyValuePair kv in new Dictionary(pendingData)) { byte[] png = NormalizeToPng(kv.Value, kv.Key); if (png != null) { imageIds[kv.Key] = StoreBytes(png); FireCallback(kv.Key); } pendingData.Remove(kv.Key); } SaveData(); Puts($"初始化前暂存的 {pendingData.Count} 张本地图已落盘"); } // 生成两张纯色占位图,避免拿不到图时 UI 出错 private void CreatePlaceholders() { try { noneCrc = StoreBytes(MakeSolidTexture(new Color32(60, 60, 66, 255))); loadCrc = StoreBytes(MakeSolidTexture(new Color32(120, 124, 132, 255))); } catch (Exception ex) { // 占位图生不出来也不能让插件挂掉,退回 0(客户端按空处理) PrintWarning($"占位图生成失败,回退为空:{ex.Message}"); noneCrc = "0"; loadCrc = "0"; } } private byte[] MakeSolidTexture(Color32 color) { Texture2D tex = new Texture2D(2, 2, TextureFormat.RGBA32, false); Color32[] pixels = new Color32[4]; for (int i = 0; i < 4; i++) pixels[i] = color; tex.SetPixels32(pixels); tex.Apply(); byte[] png = tex.EncodeToPNG(); UnityEngine.Object.DestroyImmediate(tex); return png; } #endregion #region 核心:FileStorage 存取(客户端唯一认的机制) private string StoreBytes(byte[] bytes) { uint crc = FileStorage.server.Store(bytes, FileStorage.Type.png, CommunityEntity.ServerInstance.net.ID); return crc.ToString(); } private bool IsInStorage(uint crc) => FileStorage.server.Get(crc, FileStorage.Type.png, CommunityEntity.ServerInstance.net.ID) != null; // 统一转 PNG;源图本来就是 PNG 且体积合规时直接复用原字节,省掉一次编解码 private byte[] NormalizeToPng(byte[] raw, string key) { if (raw == null || raw.Length == 0) return null; bool isPng = raw.Length > 4 && raw[0] == 0x89 && raw[1] == 0x50 && raw[2] == 0x4E && raw[3] == 0x47; if (isPng && raw.Length <= MAX_IMAGE_BYTES) return raw; Texture2D tex = new Texture2D(2, 2); if (!tex.LoadImage(raw)) { UnityEngine.Object.DestroyImmediate(tex); PrintWarning($"图片无法解析(不是有效的图片文件): {key}"); return null; } byte[] png = tex.EncodeToPNG(); UnityEngine.Object.DestroyImmediate(tex); if (png == null || png.Length > MAX_IMAGE_BYTES) { PrintWarning($"图片超过 {MAX_IMAGE_BYTES} 字节上限,已跳过: {key}"); return null; } return png; } #endregion #region 本地图标包(零网络,秒级) private IEnumerator RegisterLocalIcons() { string dir = Path.Combine(Interface.Oxide.DataDirectory, config.LocalIconDir); if (!Directory.Exists(dir)) { if (config.Verbose) Puts($"本地图标目录不存在,跳过:{dir}"); yield break; } string[] files = Directory.GetFiles(dir, "*.png", SearchOption.AllDirectories); if (files.Length == 0) yield break; int done = 0; int batch = 0; foreach (string file in files) { string name = Path.GetFileNameWithoutExtension(file); string key = name.Contains("_") ? name : $"{name}_0"; if (imageIds.ContainsKey(key)) continue; byte[] raw; try { raw = File.ReadAllBytes(file); } catch { continue; } byte[] png = NormalizeToPng(raw, key); if (png == null) continue; imageIds[key] = StoreBytes(png); imageUrls[key] = "local:" + file; done++; if (++batch >= config.LocalBatchPerFrame) { batch = 0; yield return null; // 分帧,避免大包注册时服务器卡顿 } } if (done > 0) { Puts($"本地图标包注册完成 {done} 张(共 {imageIds.Count} 张)"); SaveData(); } } // 维护用:把已缓存图片回写磁盘(制作国内镜像图标包时用一次即可) private IEnumerator ExportAllImages() { string dir = Path.Combine(Interface.Oxide.DataDirectory, config.ExportDir); try { Directory.CreateDirectory(dir); } catch (Exception ex) { PrintWarning($"导出目录创建失败:{ex.Message}"); yield break; } int done = 0; int batch = 0; foreach (KeyValuePair kv in new Dictionary(imageIds)) { if (!uint.TryParse(kv.Value, out uint crc)) continue; byte[] bytes = FileStorage.server.Get(crc, FileStorage.Type.png, CommunityEntity.ServerInstance.net.ID); if (bytes == null || bytes.Length == 0) continue; try { File.WriteAllBytes(Path.Combine(dir, kv.Key + ".png"), bytes); done++; } catch { } if (++batch >= 50) { batch = 0; yield return null; } } Puts($"图片导出完成:{done} 张 → {dir}"); } #endregion #region 并发下载(取代官方的串行队列) private void Pump() { if (!initialized) return; int want = Math.Min(config.Concurrency - activeWorkers, queueUrls.Count - inFlight.Count); for (int i = 0; i < want; i++) ServerMgr.Instance.StartCoroutine(DownloadWorker()); } private IEnumerator DownloadWorker() { activeWorkers++; while (true) { string key = null; string url = null; foreach (KeyValuePair kv in queueUrls) { if (inFlight.Contains(kv.Key)) continue; key = kv.Key; url = kv.Value; break; } if (key == null) break; inFlight.Add(key); using (UnityWebRequest www = UnityWebRequest.Get(url)) { www.timeout = config.TimeoutSeconds; yield return www.SendWebRequest(); bool ok = www.result == UnityWebRequest.Result.Success && www.downloadHandler?.data != null; if (ok) { byte[] png = NormalizeToPng(www.downloadHandler.data, key); if (png != null) { imageIds[key] = StoreBytes(png); queueUrls.Remove(key); inFlight.Remove(key); FireCallback(key); continue; } } // 失败:重试计数,超过上限就丢弃并给占位,绝不卡住队列 inFlight.Remove(key); int tries = retryCount.TryGetValue(key, out int t) ? t : 0; if (tries < config.MaxRetry) { retryCount[key] = tries + 1; } else { queueUrls.Remove(key); knownMissing.Add(key); // 记下来,以后不再白跑一趟 PrintWarning($"图片下载失败,已放弃({url}):{(ok ? "格式错误" : www.error)}"); FireCallback(key); } } } activeWorkers--; if (activeWorkers <= 0) { SaveData(); if (config.Verbose) Puts($"本轮下载结束,已缓存 {imageIds.Count} 张图"); } } private void FireCallback(string key) { if (!callbacks.TryGetValue(key, out List list)) return; callbacks.Remove(key); foreach (Action cb in list) { try { cb?.Invoke(); } catch { } } } private void RequeueMissing() { int n = 0; foreach (KeyValuePair kv in new Dictionary(imageIds)) { if (!uint.TryParse(kv.Value, out uint crc) || IsInStorage(crc)) continue; if (!imageUrls.TryGetValue(kv.Key, out string url) || url.StartsWith("local:")) continue; queueUrls[kv.Key] = url; n++; } if (n > 0) { Puts($"重启后 {n} 张图需要重新拉取"); Pump(); } } #endregion #region 对外 API(与官方同名同签名,老插件零改动) [HookMethod("AddImage")] public bool AddImage(string url, string imageName, ulong imageId = 0, Action callback = null) { if (string.IsNullOrEmpty(url)) return false; string key = $"{imageName}_{imageId}"; imageUrls[key] = url; // 已经确认拿不到的图,不再反复请求(开服时省掉几十次无效访问) if (knownMissing.Contains(key)) return false; if (imageIds.ContainsKey(key)) { callback?.Invoke(); return true; } if (callback != null) { if (!callbacks.TryGetValue(key, out List list)) callbacks[key] = list = new List(); list.Add(callback); } queueUrls[key] = url; Pump(); return true; } [HookMethod("AddImageData")] public bool AddImageData(string imageName, byte[] array, ulong imageId = 0, Action callback = null) { if (array == null || array.Length == 0) return false; string key = $"{imageName}_{imageId}"; // 【重要】服务器未完成初始化前,绝不能碰 FileStorage。 // 早期调用会触发它的静态构造,而此时工作目录还是默认的 my_server_identity, // 一旦抛出 TypeInitializationException,该类型永久不可用 → 整个进程图片功能全废, // 甚至带崩服务器初始化。官方用队列等待来规避,这里改为"先用内存兜住,就绪后再落盘"。 if (!initialized) { pendingData[key] = array; if (callback != null) { if (!callbacks.TryGetValue(key, out List list)) callbacks[key] = list = new List(); list.Add(callback); } return true; } byte[] png = NormalizeToPng(array, key); if (png == null) return false; // 本地字节不进队列,同步存完立刻可用(官方要排队,这是本地图提速的关键) imageIds[key] = StoreBytes(png); SaveData(); callback?.Invoke(); return true; } [HookMethod("GetImage")] public string GetImage(string imageName, ulong imageId = 0, bool returnUrl = false) { string key = $"{imageName}_{imageId}"; if (imageIds.TryGetValue(key, out string crc)) return crc; if (returnUrl && imageUrls.TryGetValue(key, out string url)) return url ?? string.Empty; // 正在下载 → 返回"加载中"占位,UI 不会崩 if (queueUrls.ContainsKey(key) || inFlight.Contains(key)) return loadCrc; return noneCrc; } [HookMethod("GetImageURL")] public string GetImageURL(string imageName, ulong imageId = 0) => imageUrls.TryGetValue($"{imageName}_{imageId}", out string url) ? url : string.Empty; [HookMethod("HasImage")] public bool HasImage(string imageName, ulong imageId = 0) { // 未初始化时不查 FileStorage(见 AddImageData 里的说明) if (!initialized) return false; string key = $"{imageName}_{imageId}"; return imageIds.TryGetValue(key, out string crc) && uint.TryParse(crc, out uint value) && IsInStorage(value); } // 修正版:只反映本插件自己的待处理任务,不会像官方那样因为别的插件排队而永远 false [HookMethod("IsReady")] public bool IsReady() => initialized && queueUrls.Count == 0 && inFlight.Count == 0 && pendingData.Count == 0; [HookMethod("GetImageList")] public List GetImageList(string name) { List ids = new List(); foreach (KeyValuePair kv in imageIds) { int idx = kv.Key.LastIndexOf('_'); if (idx < 0 || kv.Key.Substring(0, idx) != name) continue; if (ulong.TryParse(kv.Key.Substring(idx + 1), out ulong skinId)) ids.Add(skinId); } return ids; } [HookMethod("RemoveImage")] public void RemoveImage(string imageName, ulong imageId) { string key = $"{imageName}_{imageId}"; if (!initialized) { imageIds.Remove(key); return; } if (!imageIds.TryGetValue(key, out string crc)) return; if (uint.TryParse(crc, out uint value)) FileStorage.server.Remove(value, FileStorage.Type.png, CommunityEntity.ServerInstance.net.ID); imageIds.Remove(key); SaveData(); } [HookMethod("SendImage")] public void SendImage(BasePlayer player, string imageName, ulong imageId = 0) { if (!HasImage(imageName, imageId) || player?.net?.connection == null) return; uint crc = uint.Parse(GetImage(imageName, imageId)); byte[] array = FileStorage.server.Get(crc, FileStorage.Type.png, CommunityEntity.ServerInstance.net.ID); if (array == null) return; ClientRPC(RpcTarget.SendInfo("CL_ReceiveFilePng", new Network.SendInfo(player.net.connection) { channel = 2, method = Network.SendMethod.Reliable }), crc, (uint)array.Length, array); } private void ClientRPC(RpcTarget target, T1 arg1, T2 arg2, T3 arg3) { if (!Network.Net.sv.IsConnected()) return; if (CommunityEntity.ServerInstance.net == null) return; CommunityEntity.ServerInstance.GetRpcTargetNetworkGroup(ref target); Network.NetWrite netWrite = CommunityEntity.ServerInstance.ClientRPCStart(target.Function); CommunityEntity.ServerInstance.ClientRPCWrite(netWrite, arg1); CommunityEntity.ServerInstance.ClientRPCWrite(netWrite, arg2); CommunityEntity.ServerInstance.ClientRPCWrite(netWrite, arg3); CommunityEntity.ServerInstance.ClientRPCSend(netWrite, target.Connections); CommunityEntity.ServerInstance.FreeRPCTarget(target); } // 批量导入:URL 字典 [HookMethod("ImportImageList")] public void ImportImageList(string title, Dictionary imageList, ulong imageId = 0, bool replace = false, Action callback = null) { if (imageList == null) return; foreach (KeyValuePair kv in imageList) { if (!replace && imageIds.ContainsKey($"{kv.Key}_{imageId}")) continue; AddImage(kv.Value, kv.Key, imageId, callback); } } // 批量导入:本地字节字典(同步,瞬间完成) [HookMethod("ImportImageData")] public void ImportImageData(string title, Dictionary imageList, ulong imageId = 0, bool replace = false, Action callback = null) { if (imageList == null) return; foreach (KeyValuePair kv in imageList) { if (!replace && imageIds.ContainsKey($"{kv.Key}_{imageId}")) continue; AddImageData(kv.Key, kv.Value, imageId); } callback?.Invoke(); } // 工坊皮肤列表:国内 Steam API 通常不通,失败不抛异常 [HookMethod("ImportItemList")] public void ImportItemList(string title, Dictionary> itemList, bool replace = false, Action callback = null) { if (itemList == null) return; foreach (KeyValuePair> item in itemList) { if (item.Value == null) continue; foreach (KeyValuePair skin in item.Value) AddImage(skin.Value, item.Key, skin.Key); } } [HookMethod("LoadImageList")] public void LoadImageList(string title, List> imageList, Action callback = null) { if (imageList == null) return; foreach (KeyValuePair image in imageList) { if (!_authorized) continue; // ★ 软锁:未授权不走国内镜像 if (string.IsNullOrEmpty(config.IconMirrorUrl)) continue; AddImage($"{config.IconMirrorUrl}{image.Key}.png", image.Key, image.Value, callback); } } [HookMethod("GetSkinInfo")] public Dictionary GetSkinInfo(string name, ulong id) => null; #endregion #region 控制台命令 // 注意:命令名一律不带点号,避免 Rust 控制台把点号当命名空间解析导致命令收不到 public void CmdStats(ConsoleSystem.Arg arg) { Puts($"已缓存图片 {imageIds.Count} 张 | 待下载 {queueUrls.Count} 张 | 下载中 {inFlight.Count} 张 | 工作协程 {activeWorkers}"); Puts($"授权状态:{(_authorized ? "已授权(国内镜像 + 自动预载生效)" : "未授权(等同官方原版行为)")} 码:{MaskCode(CurrentCode())}"); Puts($"图标镜像:{(string.IsNullOrEmpty(config.IconMirrorUrl) ? "未配置(仅本地包)" : config.IconMirrorUrl)}"); Puts($"本地图标目录:{config.LocalIconDir}"); } public void CmdMirror(ConsoleSystem.Arg arg) { if (!_authorized) { Puts("尚未授权:国内镜像不启用(当前等同官方原版行为)。激活:imagelibrary.auth <授权码>"); return; } if (!arg.HasArgs(1)) { Puts("用法:ilmirror <国内图标镜像根地址> 例:ilmirror https://cos.example.com/icons/"); return; } config.IconMirrorUrl = arg.GetString(0); SaveConfig(); Puts($"图标镜像已设为:{config.IconMirrorUrl}"); } // 按游戏内物品清单从国内镜像并发拉取全部图标(官方这一步要走 GitHub + 国外 CDN) public void CmdLoadIcons(ConsoleSystem.Arg arg) { if (!_authorized) { Puts("尚未授权:从国内镜像批量拉取图标不启用(当前等同官方原版行为)。激活:imagelibrary.auth <授权码>"); return; } if (string.IsNullOrEmpty(config.IconMirrorUrl)) { Puts("未配置国内镜像,请先执行:ilmirror <地址>;或把 png 放进本地图标目录后用 illocal 注册"); return; } LoadAllItemIcons(); } // 直接读游戏内物品表取短名,不依赖任何外部清单(官方要访问 GitHub) private void LoadAllItemIcons() { if (!_authorized) return; // ★ 软锁:未授权不从镜像批量拉取 int n = 0; foreach (ItemDefinition def in ItemManager.itemList) { if (def == null || string.IsNullOrEmpty(def.shortname)) continue; string key = $"{def.shortname}_0"; if (imageIds.ContainsKey(key) || knownMissing.Contains(key)) continue; AddImage($"{config.IconMirrorUrl}{def.shortname}.png", def.shortname, 0); n++; } Puts($"已排入 {n} 个物品图标,{config.Concurrency} 路并发下载中,用 ilstats 查看进度"); } // 控制台 / RCON:imagelibrary.auth [授权码],不带参数只看状态 public void CmdAuth(ConsoleSystem.Arg arg) { string code = (arg != null && arg.HasArgs()) ? arg.GetString(0).Trim() : ""; if (string.IsNullOrEmpty(code)) { PrintAuthStatus(); return; } if (config == null) LoadDefaultConfig(); if (config.Authorize == null) config.Authorize = new AuthData(); config.Authorize.LicenseCode = code.ToUpperInvariant(); SaveConfig(); Puts("[授权] 已写入配置,开始校验 " + MaskCode(code) + " …"); RequestVerify(code, true); } // 注销:清本地凭据 + 清配置里的码 public void CmdDeauth(ConsoleSystem.Arg arg) { _authorized = false; _authHint = "已注销(请重新填授权码)"; _authCache = new AuthCache(); SaveAuthCache(); if (config != null && config.Authorize != null) { config.Authorize.LicenseCode = ""; SaveConfig(); } Puts("[授权] 已注销,插件回到未授权状态(等同官方原版行为)。重新激活:imagelibrary.auth <码>"); } public void CmdLocalDir(ConsoleSystem.Arg arg) { ServerMgr.Instance.StartCoroutine(RegisterLocalIcons()); Puts("正在注册本地图标目录…"); } // 清空"确认缺失"清单,让之前下不到的图下次重新尝试(补完镜像后用) public void CmdReset(ConsoleSystem.Arg arg) { int n = knownMissing.Count; knownMissing.Clear(); SaveData(); Puts($"已清空缺失清单(原 {n} 项),下次加载会重新尝试这些图"); } #endregion } }