using System; using System.Diagnostics; using System.Drawing; using System.Drawing.Drawing2D; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using System.Threading.Tasks; using System.Windows.Forms; // Ananta Server & Client Launcher(界面版) // 启动服务器 -> 后台拉起 START.cmd(隐藏黑窗口)+ 注入器,实时回报状态 // 关闭服务器 -> 同一个大按键切换成「关闭服务端」,杀掉 Drmk.App + node 代理 + 气泡图床 // GM 面板 -> 服务端就绪后亮起,打开 admin 页面 // 启动游戏 -> 服务端就绪后亮起,启动玩家指定的 Ananta.exe // 配色与 GM 面板同源(见 AdminWebServer.cs 的 :root);纯色底 + 细描边。 // 无边框圆角窗口,按住顶部标题区可拖动。 internal sealed class MainForm : Form { private const string AppName = "Ananta Server & Client Launcher"; // ---------- 拖动无边框窗口 ---------- [DllImport("user32.dll")] private static extern bool ReleaseCapture(); [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); private const int WM_NCLBUTTONDOWN = 0x00A1; private const int HTCAPTION = 0x0002; // ---------- 版式(2026-09-29 整体放大约 1.24 倍)---------- private const int W = 770; // 客户区宽 // 2026-09-30:三个世界物件开关改成**独立卡片**(与「显示控制台窗口」同尺寸)⇒ +282; // 同日再加「安装证书」卡片 ⇒ +110 ⇒ 1236。 // 2026-10-02:再插一行「车流 / 人流」并排卡片 ⇒ +110 ⇒ 1346; // 同日下午:卡片 1(服务器/注入器状态)从 220 改成 ROW_H×2 = 188 ⇒ −32 ⇒ 1314。 // ⛑ H 是**设计基准高**(100% 缩放时的客户区高),实际高度 = H * _s(见 FitToScreen)。 // 2026-09-30 之前 H 就是最终高度 ⇒ 1080p(工作区 ~1030)上窗口顶天立地还被裁掉 // 最下面两张卡片(注入器 / 显示控制台窗口,y=1008 / 1118)。 private const int H = 1314; private const int ROW_H = 94; // 行式卡片统一高度(= 原「显示控制台窗口」卡片高度) private const int PAD = 22; // 左右外边距 private const int INNER = W - PAD * 2; // 卡片宽 = 726 private const int HEADER = 88; // 标题区高 // ---------- 路径 ---------- private readonly string _root, _logPath, _settingsPath, _startCmd; private readonly string _injectorDir, _injectorPy, _injectorStatus, _injectorLog; // ⛑ python 路径**不能写死**(旧版写的是开发者本机的 .workbuddy 路径 ⇒ 分发给别人一启动就报 // 「找不到装了 frida 的 python」)。运行时按优先级探测,见 ResolvePython()。 private string _pythonExe = ""; // 探测到的解释器("" = 没找到) private bool _pythonOk = false; // 这个解释器能不能 import frida private Label _lblInjectorSub; // 注入器卡副标题:环境缺失时改文案提示 // ---------- 控件 ---------- private HeroBtn _btnServer; private TileBtn _btnGame, _btnGm; private FlatBtn _btnPick; // 2026-09-30:「安装证书」卡片 / 2026-10-01:「安装快速启动补丁」卡片。 // ⛑ 两张都是**整块可点**(ActionTile 自绘,无子控件),右边不再放「安装」小按钮。 private ActionTile _cardCert, _cardBoot; // 快速启动补丁的源目录(随服务端分发)⇒ 整份覆盖进游戏根目录 private readonly string _bootCfgDir; private StatusDot _dotServer, _dotInjector; private Label _txtServer, _txtInjector, _lblGamePath; private Toggle _tgConsole; // 2026-09-30:注入器开关的界面控件(值存在 _injectorEnabled,持久化到 settings.json) private Toggle _tgInjector; // 2026-09-30:世界物件三条推送通道的开关。值**不存在 settings.json**,直接读写 // config/private-server.json 的 world.content.{sceneItems,gadgets,indoorSectors}.enabled // (配置才是事实源)。三份副本同时写,改完要重启服务端才生效(Lazy<>)。 private Toggle _tgSceneItems, _tgGadgets, _tgIndoor; // 2026-10-02:车流 / 人流两个并排开关。值同样**不存在 settings.json**,直接读写 // config/private-server.json 的 gameplay.aether.{trafficEnabled,crowdEnabled} // —— 与 GM 面板「车流调参 / 人流调参」里的「启用车流 / 启用人群」是同一个键。 // ⛑ 与 world.content.* 的区别:这两个**热重载即可生效**,服务端在跑时额外走 // POST /api/settings(同 GM 面板)+ 人流的 /api/crowd/refresh 推送。 private Toggle _tgTraffic, _tgCrowd; private Card _cardServer, _cardPath, _cardConsole, _cardInjector; // ---------- 分辨率自适应(2026-09-30 修「1080p 上顶天立地 / 底部被裁」)---------- // 版式常量(W/H/ROW_H/…)是**设计基准**,实际显示的整棵树按 _s 等比缩放: // _s = min(1, (屏幕工作区高 - 64) / H),再夹到 [0.55, 1]。 // 做法:控件树照设计尺寸建完 => Control.Scale(_s) 缩几何 => 自写 ScaleFonts 缩字号 // => 重设 Region / 标题区高。屏幕够大时 _s = 1,与原来**逐像素一致**。 private float _s = 1f; // 实际缩放系数 private int _hdr = HEADER; // 缩放后的标题区高(拖拖动判定 / OnPaint 都用这个) private readonly System.Windows.Forms.Timer _poll = new System.Windows.Forms.Timer(); private readonly System.Windows.Forms.Timer _pulse = new System.Windows.Forms.Timer(); private bool _pulseOn = true; private bool _serverBusy = false; private bool _serverReady = false; // 游戏进程检测(防重复启动):_gameRunning 只用来记「上一次状态」以便只在变化时写日志 private bool _gameRunning = false; // 点过「启动游戏」后的宽限期:引导进程要过一会儿才出现,这期间也按「已启动」处理 private DateTime _gameLaunchGrace = DateTime.MinValue; private string _lastInjectorState = ""; private int _injectorPid = 0; // 拉起注入器的节流时间戳:注入器自己起不来时别每秒 spawn 一个 private DateTime _lastInjectorStart = DateTime.MinValue; private bool _injectorSpawnPending = false; // 刚 spawn 过,等 12 秒复查 private DateTime _injectorSpawnAt = DateTime.MinValue; private bool _injectorFailWarned = false; // 「启动即退」只弹一次窗,别刷屏 // 结束注入器的节流时间戳:杀不掉时也别每秒刷一条日志 private DateTime _lastInjectorKill = DateTime.MinValue; private string _adminUrl = "http://127.0.0.1:17888/"; // 2026-09-30 05:1x:注入器总开关(默认关)。 // 根因定位(2026-09-30.md §32):bowling_agent.js:5660 在 frida onLeave 回调里同步 // invoke IL2CPP(playerZ())⇒ 客户端物件加载线程死锁 = 卡死。修复落地前默认不拉起注入器。 // 要用时在 settings.json 把 injectorEnabled 改 true,或点界面上的开关。 private bool _injectorEnabled = false; // 初始化期间给三个世界物件开关 SetChecked 会触发 CheckedChanged ⇒ 会去写配置。 // 用这个闸门挡住(规则 229:改配置前先读真实值,别一上来就用默认值覆盖)。 private bool _worldLoading = true; internal MainForm() { _root = AppContext.BaseDirectory.TrimEnd('\\', '/'); _logPath = Path.Combine(_root, "tools", "launcher", "launcher.log"); _settingsPath = Path.Combine(_root, "tools", "launcher", "settings.json"); _startCmd = Path.Combine(_root, "START.cmd"); _injectorDir = Path.Combine(_root, "tools", "AnantaInjector"); _injectorPy = Path.Combine(_injectorDir, "unified_loader.py"); _injectorStatus = Path.Combine(_injectorDir, "injector_status.json"); // 加载器自己的 stdout/stderr 落盘路径 —— 别的机器上排障只能靠它(见 CheckInjectorSpawn) _injectorLog = Path.Combine(_injectorDir, "injector_console.log"); // 2026-10-01:快速启动补丁(boot.config 直连官方地址 ⇒ 跳过启动器设备检测前的等待) _bootCfgDir = Path.Combine(_root, "tools", "bootcfg", "Ananta_Data"); // ⛑ 先建日志目录,再做下面的探测 —— 否则 Log() 会静默失败,排查时看不到这一行。 Directory.CreateDirectory(Path.Combine(_root, "tools", "launcher")); Log("============ " + AppName + " ============"); // ⛑ 分发优先:随包带一份便携 python(tools\python\python.exe,已预装 frida), // 找不到再退回系统 python。详细优先级见 ResolvePython()。 _pythonExe = ResolvePython(); _pythonOk = !string.IsNullOrEmpty(_pythonExe) && PythonHasFrida(_pythonExe); Log("python = " + (string.IsNullOrEmpty(_pythonExe) ? "(未找到)" : _pythonExe) + " frida " + (_pythonOk ? "可用" : "不可用")); PurgeStaleInjectorState(); // 分发包里带来的陈旧 pid 会在别的机器上把注入器卡死 _adminUrl = ReadAdminUrl(); BuildUi(); LoadSettings(); _poll.Interval = 1000; _poll.Tick += (s, e) => RefreshStates(); _poll.Start(); _pulse.Interval = 520; _pulse.Tick += (s, e) => { _pulseOn = !_pulseOn; PaintDots(); }; _pulse.Start(); RefreshStates(); } // ================= 界面 ================= private void BuildUi() { FormBorderStyle = FormBorderStyle.None; StartPosition = FormStartPosition.CenterScreen; BackColor = Col.Bg; ForeColor = Col.Text; Font = new Font("Microsoft YaHei UI", 9f); ClientSize = new Size(W, H); Text = AppName; ShowInTaskbar = true; DoubleBuffered = true; Icon = LoadAppIcon(); // ★ 任务栏图标(编译期嵌进 exe 的那个 ico) Opacity = 0; // 淡入 Region = Gfx.Region(new RectangleF(0, 0, W, H), 14f); // 标题区整块可拖动 MouseDown += (s, e) => { if (e.Button == MouseButtons.Left && e.Y < _hdr) DragMove(); }; // ⛑⛑ 定位(2026-09-30 修):**放得下就居中,放不下才顶对齐**。 // 第一版写成无条件顶对齐 ⇒ 在高屏上窗口永远贴着屏幕顶、下面空一大片(用户反馈)。 // 顶对齐只在 Height > 工作区高 时才需要:那会儿 CenterScreen 会把 Top 算成负数、 // 标题栏被顶出屏幕外(连拖都拖不了),所以退而求其次贴顶 + 水平居中,靠拖动看底部。 Load += (s, e) => { try { var wa = Screen.FromControl(this).WorkingArea; int x = wa.Left + (wa.Width - Width) / 2; int y = Height <= wa.Height ? wa.Top + (wa.Height - Height) / 2 // 放得下 ⇒ 垂直居中 : wa.Top; // 放不下 ⇒ 贴顶,保住标题栏 Location = new Point(x, y); } catch { } }; var pic = new RoundPic(LoadIconBitmap(56)) { Left = 20, Top = 16, Width = 56, Height = 56, Radius = 12f }; pic.MouseDown += (s, e) => { if (e.Button == MouseButtons.Left) DragMove(); }; Controls.Add(pic); var btnMin = MakeTopButton("–", W - PAD - 86); btnMin.Click += (s, e) => WindowState = FormWindowState.Minimized; var btnClose = MakeTopButton("×", W - PAD - 40); btnClose.Click += (s, e) => Close(); Controls.Add(btnMin); Controls.Add(btnClose); // ---- 卡片 1:服务器状态 / 注入器状态 + 主按键 ---- // ⛑ 2026-10-02 按用户要求:卡片高 = 行式卡片的**两倍**(ROW_H × 2 = 188), // 分隔线正好落在中间(94)⇒ 上下两行各 94,与别的行式卡片同一档。 // ⛑ 每行内的「标题 + 状态灯/文字」整组**上下居中**(旧版:上行留白 33 / 下行 12, // 明显偏上;现在上下各 ~14)。左右位置一律不动。 // · 图标 46×46 ⇒ 行内居中 top=(94-46)/2=24 // · 标题 20px 粗体 26 高 + 状态 22 高 + 中间 17 间距 ⇒ 整组 65 高 ⇒ top=(94-65)/2≈14 _cardServer = NewCard(PAD, 106, INNER, ROW_H * 2); // 188 _cardServer.DividerY = ROW_H; // 94(正中) _cardServer.DividerX1 = 32f; _cardServer.DividerX2 = 414f; // 第 1 行(0..94):服务器状态 _cardServer.Controls.Add(new IconBox(Ico.Server, Col.Acc) { Left = 32, Top = 24, Width = 46, Height = 46 }); _cardServer.Controls.Add(Cap("服务器状态", 96, 14)); AddStatus(_cardServer, 57, 98, 120, 300, out _dotServer, out _txtServer); Set(_dotServer, _txtServer, Col.Text3, "未启动", false); // 第 2 行(94..188):注入器状态 —— 与第 1 行同样的行内布局,整体 +94 _cardServer.Controls.Add(new IconBox(Ico.Syringe, Col.Violet) { Left = 32, Top = 118, Width = 46, Height = 46 }); _cardServer.Controls.Add(Cap("注入器状态", 96, 108)); AddStatus(_cardServer, 151, 98, 120, 300, out _dotInjector, out _txtInjector); Set(_dotInjector, _txtInjector, Col.Text3, "未运行", false); // 主按键:上下各留 22 ⇒ 188 - 44 = 144 _btnServer = new HeroBtn { Text = "启动服务器", Left = INNER - 32 - 244, Top = 22, Width = 244, Height = 144, Radius = 16f }; _btnServer.Click += OnServerButton; _cardServer.Controls.Add(_btnServer); // ---- 卡片 2:启动游戏 / GM 面板 ---- _btnGame = new TileBtn { Text = "启动游戏", Kind = Ico.Gamepad, Tint = Col.Acc, Left = PAD, Top = 310, Width = 357, Height = 100, Enabled = false }; _btnGame.Click += OnStartGame; Controls.Add(_btnGame); _btnGm = new TileBtn { Text = "GM 面板", Kind = Ico.Gm, Tint = Col.Violet, Left = PAD + 369, Top = 310, Width = 357, Height = 100, Enabled = false }; _btnGm.Click += (s, e) => { try { Process.Start(new ProcessStartInfo(_adminUrl) { UseShellExecute = true }); } catch (Exception ex) { Warn("打不开 " + _adminUrl + "\n" + ex.Message); } }; Controls.Add(_btnGm); // ---- 卡片 3:选择游戏路径 ---- _cardPath = NewCard(PAD, 426, INNER, ROW_H); _cardPath.Controls.Add(new IconBox(Ico.Folder, Col.Acc) { Left = 32, Top = 27, Width = 40, Height = 40 }); _cardPath.Controls.Add(Cap2("选择游戏路径", 88, 16)); _lblGamePath = new Label { Left = 88, Top = 52, Width = 426, Height = 22, Text = "未选择", ForeColor = Col.Text3, Font = new Font("Microsoft YaHei UI", 15f, FontStyle.Regular, GraphicsUnit.Pixel), TextAlign = ContentAlignment.MiddleLeft, AutoEllipsis = true }; _cardPath.Controls.Add(_lblGamePath); _btnPick = new FlatBtn { Text = "选择…", Kind = Ico.Folder, Left = INNER - 32 - 164, Top = 20, Width = 164, Height = 54, Radius = 10f, TextPx = 17f }; _btnPick.Click += (s, e) => PickGameExe(); _cardPath.Controls.Add(_btnPick); // ---- 卡片 4:安装证书(2026-09-30;按用户要求放在「选择游戏路径」正下方)---- // ⛑ 为什么必须有这一张:客户端启动时必须走 https://l50.update.netease.com/... 取 // 版本 / 补丁配置,该域名被 hosts 指向 127.0.0.1、由本地代理用**自签证书**应答。 // 证书不在 Windows「受信任的根证书颁发机构」⇒ Unity 报 Curl error 60 // ⇒ 界面弹「获取配置信息失败」。证书是每份服务端各自生成的(还会被 START.cmd // 重签),所以换服务端 / 重签后都要再点一次。 // ---- 卡片 4:安装证书 | 安装快速启动补丁(**并排两张**,2026-10-01 改)---- // ⛑ 用户要求:这一栏按「启动游戏 / GM 面板」那栏的排法(357 宽 ×2,中间 12 间隔), // 且**点整张卡片就执行**,右侧不再放「安装」按钮 ⇒ 用 ActionTile(自绘、无子控件)。 // ⛑ 左:证书。客户端启动时必须走 https://l50.update.netease.com/... 取 // 版本 / 补丁配置,该域名被 hosts 指向 127.0.0.1、由本地代理用**自签证书**应答。 // 证书不在 Windows「受信任的根证书颁发机构」⇒ Unity 报 Curl error 60 // ⇒ 界面弹「获取配置信息失败」。证书是每份服务端各自生成的(还会被 START.cmd // 重签),所以换服务端 / 重签后都要再点一次。 // ⛑ 右:快速启动补丁。**必须先选好游戏路径**才知道往哪儿拷 ⇒ 初始 Enabled=false(灰)。 _cardCert = NewActionTile(PAD, 536, 357, ROW_H, Ico.Shield, Col.Ok, "安装证书", "解决「获取配置信息失败」"); _cardCert.Click += OnInstallCert; Controls.Add(_cardCert); _cardBoot = NewActionTile(PAD + 369, 536, 357, ROW_H, Ico.Bolt, Col.Warn, "安装快速启动补丁", "跳过启动设备检测程序前的延迟"); _cardBoot.Enabled = false; // 选了游戏路径才亮(SetGameExe) _cardBoot.Click += OnInstallBootPatch; Controls.Add(_cardBoot); // ---- 卡片 5:车流 | 人流(**并排两张**,2026-10-02 新增)---- // ⛑ 排法同「安装证书 / 安装快速启动补丁」那一行(357 宽 ×2,中间 12 间隔)。 // 两个开关 = config/private-server.json 的 gameplay.aether.trafficEnabled / crowdEnabled, // 与 GM 面板「车流调参(即时生效 · 免重启)」的「启用车流」、 // 「人流调参(即时生效 · 需手动推送)」的「启用人群」**完全同一个键**。 // 开 ⇒ 服务端生成/推送车流、推送人群密度;关 ⇒ 不再生成并回收已在场的(人流要再推一次密度)。 AddToggleCardHalf(PAD, 646, Ico.Car, Col.Acc, "车流", "trafficEnabled", out _tgTraffic); _tgTraffic.CheckedChanged += (s, e) => OnAetherToggle("trafficEnabled", _tgTraffic.Checked, false); AddToggleCardHalf(PAD + 369, 646, Ico.Users, Col.Violet, "人流", "crowdEnabled", out _tgCrowd); _tgCrowd.CheckedChanged += (s, e) => OnAetherToggle("crowdEnabled", _tgCrowd.Checked, true); // ---- 卡片 6 / 7 / 8:世界物件三条推送通道(**独立卡片**,2026-09-30)---- // ⛑ 2026-09-30 晚:上面插了「安装证书」卡片 ⇒ 三条整体下移一档(568→678→788→898); // 2026-10-02 再插「车流 / 人流」一行 ⇒ 再下移一档(678→788→898→1008)。 // 每个开关 = config/private-server.json 里 world.content.<段>.enabled。 // 关 ⇒ 服务端不再推那条通道(世界会缺那类物件,用来定位「谁在制造卡顿 / 闪烁」)。 // ⛑ 三份副本一起写;配置是 Lazy<> 读的 ⇒ **必须重启服务端才生效**。 // ⛑ 顺序按用户指定:可破坏/吸取物品 → 可交互物品 → 室内物品。 // ⛑ 样式与「显示控制台窗口」卡片**逐像素一致**(走同一个 AddToggleCard, // 标题 Cap2 = 18px 粗体,第二行 15px 常规 Text3,图标 40×40 @(32,27),开关 @(INNER-102,30))。 AddToggleCard(756, Ico.Objects, Col.Acc, "可破坏/吸取物品", "sceneItemGrid,相当于 new-world mod", out _tgSceneItems); _tgSceneItems.CheckedChanged += (s, e) => OnWorldToggle("sceneItems", _tgSceneItems.Checked); AddToggleCard(866, Ico.Objects, Col.Ok, "可交互物品", "gadgets,包括衣柜,娃娃机和游戏机", out _tgGadgets); _tgGadgets.CheckedChanged += (s, e) => OnWorldToggle("gadgets", _tgGadgets.Checked); AddToggleCard(976, Ico.Objects, Col.Warn, "室内物品", "indoorSectors,包括入户门和电梯", out _tgIndoor); _tgIndoor.CheckedChanged += (s, e) => OnWorldToggle("indoorSectors", _tgIndoor.Checked); // ---- 卡片 8:注入器开关(2026-09-30)---- // ⛑ 卡死根因是注入器的 frida hook(bowling_agent.js:5660 在 onLeave 里同步 invoke IL2CPP // ⇒ 客户端物件加载线程死锁)。默认关:不拉起,且发现残留就主动结束(见 StopInjector)。 _cardInjector = NewCard(PAD, 1086, INNER, ROW_H); _cardInjector.Controls.Add(new IconBox(Ico.Syringe, Col.Violet) { Left = 32, Top = 27, Width = 40, Height = 40 }); _cardInjector.Controls.Add(Cap2("注入器", 88, 16)); // ⛑ 副标题留个引用:检测到 frida 环境缺失时改成红字提示(用户选的是「保持可见 + 标注」)。 _lblInjectorSub = new Label { Text = "frida,小游戏物品,不玩小游戏别开", Left = 88, Top = 52, Width = 500, Height = 22, ForeColor = Col.Text3, Font = new Font("Microsoft YaHei UI", 15f, FontStyle.Regular, GraphicsUnit.Pixel), TextAlign = ContentAlignment.MiddleLeft, AutoEllipsis = true }; _cardInjector.Controls.Add(_lblInjectorSub); _tgInjector = new Toggle { Left = INNER - 32 - 70, Top = 30, Width = 70, Height = 34 }; _tgInjector.CheckedChanged += (s, e) => { // ⛑ 环境缺失时不允许「开」:开了也只是拉不起来,不如当场说清楚该补什么。 if (_tgInjector.Checked && !_pythonOk) { _tgInjector.SetChecked(false); Warn(FridaHelp()); return; } _injectorEnabled = _tgInjector.Checked; SaveSettings(); Log("注入器开关 => " + (_injectorEnabled ? "开(服务端就绪后会拉起)" : "关(不拉起,并结束残留进程)")); // ⛑ 立刻拉一次,别等下一个轮询周期 —— 用户点开关就是要马上看到效果 // (服务端没起时先不动:加载器的 world-ready 闸门要读服务端日志) if (_injectorEnabled && IsRunning("Drmk.App")) TryStartInjector("开关刚打开"); }; _cardInjector.Controls.Add(_tgInjector); // ---- 卡片 9:显示控制台窗口(2026-09-30:按用户要求排到最后)---- _cardConsole = NewCard(PAD, 1196, INNER, ROW_H); _cardConsole.Controls.Add(new IconBox(Ico.Terminal, Col.Acc) { Left = 32, Top = 27, Width = 40, Height = 40 }); _cardConsole.Controls.Add(Cap2("显示控制台窗口", 88, 16)); _cardConsole.Controls.Add(new Label { Text = "排障时才开", Left = 88, Top = 52, Width = 340, Height = 22, ForeColor = Col.Text3, Font = new Font("Microsoft YaHei UI", 15f, FontStyle.Regular, GraphicsUnit.Pixel), TextAlign = ContentAlignment.MiddleLeft }); _tgConsole = new Toggle { Left = INNER - 32 - 70, Top = 30, Width = 70, Height = 34 }; _cardConsole.Controls.Add(_tgConsole); // ⛑ 必须在所有控件建完之后调用:整棵树(几何 + 字号)按屏幕缩放,并重设圆角 Region。 FitToScreen(); // 注入器环境缺失 ⇒ 卡片照常显示,但副标题改成红字(用户选「保持可见 + 标注」) if (!_pythonOk && _lblInjectorSub != null) { _lblInjectorSub.Text = "未检测到 frida 环境,点开关看怎么补"; _lblInjectorSub.ForeColor = Col.Err; } // 淡入 var fade = new System.Windows.Forms.Timer { Interval = 16 }; fade.Tick += (s, e) => { if (Opacity >= 1) { Opacity = 1; fade.Stop(); fade.Dispose(); return; } Opacity += 0.08; }; fade.Start(); } /// /// 分辨率自适应:整棵控件树(含字号)按 _s 等比缩放。 /// ⛑ 为什么不在建控件时用缩小后的坐标:版式常量有上百个字面量,逐个乘系数必错; /// 先按设计尺寸(100%)建完,再让 WinForms 自己 Scale 一遍,最后补字号。 /// ⛑ Control.Scale() **不会**动 Font(那是 AutoScaleMode 的活,这里是 None)⇒ 自己走一遍。 /// private void FitToScreen() { int waH; try { waH = Screen.PrimaryScreen.WorkingArea.Height; } catch { waH = H; } // 目标:窗口高 ≈ 工作区高 - 64(上下留白,别顶天立地);屏幕够大就保持 100%。 double s = (waH - 64.0) / H; if (s > 1.0) s = 1.0; if (s < 0.55) s = 0.55; // 下限:再小字就看不清了 _s = (float)s; if (Math.Abs(_s - 1f) > 0.004f) { SuspendLayout(); Scale(new SizeF(_s, _s)); // 几何:位置 / 宽高(含 Card 里的子控件) ScaleFonts(this, _s); // 字号 ResumeLayout(true); } _hdr = (int)Math.Round(HEADER * _s); ClientSize = new Size((int)Math.Round(W * _s), (int)Math.Round(H * _s)); Region = Gfx.Region(new RectangleF(0, 0, ClientSize.Width, ClientSize.Height), 14f * _s); Log(string.Format("界面自适应:工作区高={0}px 缩放={1:0.00} 窗口={2}x{3}", waH, _s, ClientSize.Width, ClientSize.Height)); } /// 递归缩字号 + 缩自定义控件里那几个不跟 Control.Size 走的 float(圆角 / 分隔线 / TextPx)。 private static void ScaleFonts(Control root, float s) { foreach (Control c in root.Controls) { try { if (c.Font != null) c.Font = new Font(c.Font.FontFamily, Math.Max(7f, c.Font.Size * s), c.Font.Style, c.Font.Unit); } catch { } // ⛑ 这几个是**自定义字段**,Scale() 管不到:不缩会让圆角/分隔线/小按钮文字偏大。 switch (c) { // ⛑ ActionTile 是 Card 的子类 ⇒ 必须排在 Card 前面,否则走 Card 分支、字号缩不到。 case ActionTile act: act.Radius *= s; act.TitlePx = Math.Max(9f, act.TitlePx * s); act.SubPx = Math.Max(8f, act.SubPx * s); Gfx.ApplyRegion(act, act.Radius); break; case Card card: card.Radius *= s; card.DividerY *= s; card.DividerX1 *= s; card.DividerX2 *= s; Gfx.ApplyRegion(card, card.Radius); break; // 圆角 Region 只在 OnSizeChanged 里生成 ⇒ 缩完要重贴 case HeroBtn hero: hero.Radius *= s; hero.Gap *= s; Gfx.ApplyRegion(hero, hero.Radius); break; case TileBtn tile: tile.Radius *= s; Gfx.ApplyRegion(tile, tile.Radius); break; case FlatBtn flat: flat.Radius *= s; flat.TextPx = Math.Max(9f, flat.TextPx * s); Gfx.ApplyRegion(flat, flat.Radius); break; // Toggle / IconBox / StatusDot 的绘制全按 Width/Height 算比例,Scale() 缩完就对。 } if (c.Controls.Count > 0) ScaleFonts(c, s); } } private Card NewCard(int left, int top, int width, int height) { var c = new Card { Left = left, Top = top, Width = width, Height = height, Radius = 14f, BackColor = Col.Card }; Controls.Add(c); return c; } /// 整块可点的行式卡片(不自动加进 Controls —— 调用处自己 Add,和 TileBtn 一样)。 private static ActionTile NewActionTile(int left, int top, int width, int height, Ico kind, Color tint, string title, string sub) { return new ActionTile { Left = left, Top = top, Width = width, Height = height, Radius = 14f, Kind = kind, Tint = tint, Title = title, Sub = sub }; } private static Label Cap(string text, int left, int top) => CapFont(text, left, top, 20f); // ⛑ 2026-10-02:Cap / Cap2 现在**同号(都是 20px 粗体)** —— 用户要求所有卡片标题 // 与「服务器状态 / 注入器状态」一样的字号。Cap2 原来 18px,比状态卡里的标题小一号。 private static Label Cap2(string text, int left, int top) => CapFont(text, left, top, 20f); private static Label CapFont(string text, int left, int top, float px) { return new Label { Text = text, Left = left, Top = top, Width = 250, Height = 26, ForeColor = Col.Text, Font = new Font("Microsoft YaHei UI", px, FontStyle.Bold, GraphicsUnit.Pixel), TextAlign = ContentAlignment.MiddleLeft }; } /// /// 行式开关卡片 —— 与「显示控制台窗口」卡片**完全同款**(同高度 、 /// 同字体:标题 Cap2 = 18px 粗体、第二行 15px 常规 Text3;同位置:图标 40×40 @(32,27)、 /// 标题 @(88,16)、第二行 @(88,52)、开关 @(INNER-102,30))。 /// 用户 2026-09-30 明确要求:六个行式卡片(选择游戏路径 / 三个世界物件 / 注入器 / 控制台) /// 字体、格式、大小全部统一。 /// private void AddToggleCard(int top, Ico kind, Color tint, string title, string sub, out Toggle tg) { var card = NewCard(PAD, top, INNER, ROW_H); card.Controls.Add(new IconBox(kind, tint) { Left = 32, Top = 27, Width = 40, Height = 40 }); card.Controls.Add(Cap2(title, 88, 16)); card.Controls.Add(new Label { Text = sub, Left = 88, Top = 52, Width = 470, Height = 22, ForeColor = Col.Text3, Font = new Font("Microsoft YaHei UI", 15f, FontStyle.Regular, GraphicsUnit.Pixel), TextAlign = ContentAlignment.MiddleLeft, AutoEllipsis = true }); tg = new Toggle { Left = INNER - 32 - 70, Top = 30, Width = 70, Height = 34 }; card.Controls.Add(tg); } /// /// 半宽(357)行式开关卡片 —— 给「车流 / 人流」并排那一行用。 /// 版式照 AddToggleCard 等比缩:图标 36×36 @(28,29)、标题 18px 粗 @(76,16)、 /// 第二行 15px 常规 @(76,52)、开关 @(357-102,30)。 /// ⛑ 标题/副标题宽度给 170(到开关左边为止),长了自动省略号,不会压到开关底下。 /// private void AddToggleCardHalf(int left, int top, Ico kind, Color tint, string title, string sub, out Toggle tg) { const int w = 357; var card = NewCard(left, top, w, ROW_H); card.Controls.Add(new IconBox(kind, tint) { Left = 28, Top = 29, Width = 36, Height = 36 }); card.Controls.Add(new Label { Text = title, Left = 76, Top = 16, Width = 170, Height = 26, ForeColor = Col.Text, Font = new Font("Microsoft YaHei UI", 20f, FontStyle.Bold, GraphicsUnit.Pixel), TextAlign = ContentAlignment.MiddleLeft, AutoEllipsis = true }); card.Controls.Add(new Label { Text = sub, Left = 76, Top = 52, Width = 170, Height = 22, ForeColor = Col.Text3, Font = new Font("Microsoft YaHei UI", 15f, FontStyle.Regular, GraphicsUnit.Pixel), TextAlign = ContentAlignment.MiddleLeft, AutoEllipsis = true }); tg = new Toggle { Left = w - 32 - 70, Top = 30, Width = 70, Height = 34 }; card.Controls.Add(tg); } /// /// 三份 private-server.json 的路径(源码份 / 运行份 / buildcheck 份)。 /// ⛑ 改配置必须三份同步 —— 服务端实际读的是 bin 那份,源码份是回滚基线。 /// private string[] ConfigCopies() { return new[] { Path.Combine(_root, "config", "private-server.json"), Path.Combine(_root, "Drmk.Server", "Drmk.App", "bin", "Debug", "net8.0", "config", "private-server.json"), Path.Combine(_root, "Drmk.Server", "buildcheck", "config", "private-server.json"), }; } /// /// 定位段落起始行:整行 trim 后以 "<section>": 开头。 /// 三个段名(sceneItems / gadgets / indoorSectors)在文件里各只出现一次,已 grep 确认。 /// private static int IndexOfSection(string[] lines, string section) { var key = "\"" + section + "\":"; for (int i = 0; i < lines.Length; i++) if (lines[i].TrimStart().StartsWith(key, StringComparison.Ordinal)) return i; return -1; } /// 读配置里 world.content.<section>.enabled(找不到 / 读失败 ⇒ 返回 fallback)。 private bool ReadWorldFlag(string section, bool fallback) { try { var file = ConfigCopies()[0]; if (!File.Exists(file)) return fallback; var lines = File.ReadAllLines(file); int i = IndexOfSection(lines, section); if (i < 0) return fallback; for (int j = i + 1; j < lines.Length && j < i + 80; j++) { var t = lines[j].Trim(); if (t.StartsWith("\"enabled\"", StringComparison.Ordinal)) return t.IndexOf("true", StringComparison.Ordinal) >= 0; if (t.StartsWith("}", StringComparison.Ordinal)) break; // 段落结束 } } catch (Exception ex) { Log("read world flag " + section + " 失败: " + ex.Message); } return fallback; } /// /// 写三份配置里 world.content.<section>.enabled。 /// ⛑ 走**逐行文本替换**而不是 JsonNode 全量重写:58 KB 的配置里有中文、有 4 MB 级的 /// 其他文件引用,全量序列化会改缩进 / 转义 / 数字写法,git diff 会被淹掉。 /// 只改那一行,其它字节一个不动(CRLF + 无 BOM 由 WriteAllLines 保证)。 /// private void WriteWorldFlag(string section, bool on) { int ok = 0; foreach (var file in ConfigCopies()) { try { if (!File.Exists(file)) { Log("跳过(不存在): " + file); continue; } var lines = File.ReadAllLines(file); int i = IndexOfSection(lines, section); if (i < 0) { Log("跳过(找不到段 " + section + "): " + file); continue; } for (int j = i + 1; j < lines.Length && j < i + 80; j++) { var t = lines[j].Trim(); if (t.StartsWith("\"enabled\"", StringComparison.Ordinal)) { var indent = lines[j].Substring(0, lines[j].Length - lines[j].TrimStart().Length); var tail = t.EndsWith(",", StringComparison.Ordinal) ? "," : ""; lines[j] = indent + "\"enabled\": " + (on ? "true" : "false") + tail; File.WriteAllLines(file, lines); ok++; break; } if (t.StartsWith("}", StringComparison.Ordinal)) break; } } catch (Exception ex) { Log("write world flag " + section + " 失败: " + ex.Message); } } Log($"世界物件开关 {section}.enabled => {on}(写入 {ok}/3 份配置;**需重启服务端生效**)"); } /// 三个世界物件开关共用:把新值落到三份 private-server.json。 private void OnWorldToggle(string section, bool on) { if (_worldLoading) return; WriteWorldFlag(section, on); } // ================= 车流 / 人流开关(2026-10-02)================= // ⛑ 与 GM 面板同源:键就是 gameplay.aether.trafficEnabled / crowdEnabled // (GM 面板里叫「启用车流」「启用人群」,见 AdminWebServer.cs 的 TF_META / CF_META)。 // GM 面板保存时发 POST /api/settings ⇒ RuntimeConfigEditor.Apply 写盘 + 热重载; // 人流因为**没有 tick**,改完还要再发一次 POST /api/crowd/refresh 才当场生效 // (否则玩家得走出 35 米才会重算)。启动器照做同样的两步。 /// 读配置里 gameplay.aether.<key>(找不到 / 读失败 ⇒ fallback)。 private bool ReadAetherFlag(string key, bool fallback) { try { var file = ConfigCopies()[0]; if (!File.Exists(file)) return fallback; var needle = "\"" + key + "\":"; foreach (var line in File.ReadAllLines(file)) { var t = line.Trim(); if (!t.StartsWith(needle, StringComparison.Ordinal)) continue; return t.IndexOf("true", StringComparison.Ordinal) >= 0; } } catch (Exception ex) { Log("read aether flag " + key + " 失败: " + ex.Message); } return fallback; } /// /// 写三份配置里 gameplay.aether.<key>。 /// ⛑ 同 WriteWorldFlag:**逐行文本替换**,不整体重新序列化(58 KB 配置里有中文和 /// 大段 hex,全量重写会把缩进 / 转义 / 数字写法全改一遍,git diff 会被淹掉)。 /// ⛑ trafficEnabled / crowdEnabled 在三份配置里各只出现一次(已 grep 计数确认)。 /// private void WriteAetherFlag(string key, bool on) { int ok = 0; var needle = "\"" + key + "\":"; foreach (var file in ConfigCopies()) { try { if (!File.Exists(file)) { Log("跳过(不存在): " + file); continue; } var lines = File.ReadAllLines(file); bool hit = false; for (int i = 0; i < lines.Length; i++) { if (!lines[i].TrimStart().StartsWith(needle, StringComparison.Ordinal)) continue; var indent = lines[i].Substring(0, lines[i].Length - lines[i].TrimStart().Length); var tail = lines[i].TrimEnd().EndsWith(",", StringComparison.Ordinal) ? "," : ""; lines[i] = indent + "\"" + key + "\": " + (on ? "true" : "false") + tail; hit = true; break; } if (!hit) { Log("跳过(找不到键 " + key + "): " + file); continue; } File.WriteAllLines(file, lines); ok++; } catch (Exception ex) { Log("write aether flag " + key + " 失败: " + ex.Message); } } Log($"车流/人流开关 gameplay.aether.{key} => {on}(写入 {ok}/3 份配置)"); } /// /// 开关动作:先落盘(下次启动也生效),服务端在跑就再走 GM 面板那套 API 即时生效。 /// /// true = 人流:改完必须再推一次密度才当场回收/生成。 private void OnAetherToggle(string key, bool on, bool needPush) { if (_worldLoading) return; WriteAetherFlag(key, on); // 服务端没起 ⇒ 只落盘,下次启动生效(API 没人应答,别去自讨没趣) if (!IsRunning("Drmk.App")) { Log($"{key} 已落盘;服务端未运行 ⇒ 下次启动生效"); return; } var body = "{\"changes\":{\"gameplay.aether." + key + "\":" + (on ? "true" : "false") + "}}"; bool ok = PostAdminJson("/api/settings", body, out string resp); Log($"{key} 热更新 => " + (ok ? "已应用" : "失败") + " " + Trim(resp, 200)); if (!ok) { Log("(热更新失败不影响落盘值,重启服务端后生效)"); return; } if (needPush) { bool pushed = PostAdminJson("/api/crowd/refresh", "{}", out string resp2); Log("人流密度推送 => " + (pushed ? "已推送" : "失败") + " " + Trim(resp2, 200)); } } /// 给 admin HTTP 接口发一个 JSON POST。失败返回 false,resp 里是响应体或异常信息。 private bool PostAdminJson(string path, string body, out string resp) { resp = ""; try { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(3) }; using var content = new StringContent(body, Encoding.UTF8, "application/json"); var r = http.PostAsync(_adminUrl.TrimEnd('/') + path, content).GetAwaiter().GetResult(); resp = r.Content.ReadAsStringAsync().GetAwaiter().GetResult(); return r.IsSuccessStatusCode; } catch (Exception ex) { resp = ex.Message; return false; } } private static string Trim(string s, int max) { if (string.IsNullOrEmpty(s)) return ""; s = s.Replace("\r", " ").Replace("\n", " "); return s.Length <= max ? s : s.Substring(0, max) + "…"; } private static void AddStatus(Card parent, int top, int dotLeft, int textLeft, int width, out StatusDot dot, out Label text) { dot = new StatusDot { Left = dotLeft, Top = top + 2, Width = 16, Height = 16 }; text = new Label { Left = textLeft, Top = top, Width = width, Height = 22, ForeColor = Col.Text2, Font = new Font("Microsoft YaHei UI", 16f, FontStyle.Regular, GraphicsUnit.Pixel), TextAlign = ContentAlignment.MiddleLeft, AutoEllipsis = true }; parent.Controls.Add(dot); parent.Controls.Add(text); } private static void Set(StatusDot dot, Label text, Color color, string msg, bool busy) { dot.Set(color, busy); text.Text = msg; text.ForeColor = color == Col.Text3 ? Col.Text3 : Col.Text2; } private Button MakeTopButton(string text, int left) { var b = new Button { Text = text, Left = left, Top = 24, Width = 40, Height = 40, FlatStyle = FlatStyle.Flat, BackColor = Col.BgDeep, ForeColor = Col.Text2, Font = new Font("Segoe UI", 14f), Cursor = Cursors.Hand, TabStop = false }; b.FlatAppearance.BorderSize = 0; b.FlatAppearance.MouseOverBackColor = text == "×" ? Col.Err : Col.Hover; return b; } private Icon LoadAppIcon() { // 单文件发布后磁盘上没有 app.ico,所以编译期就把它嵌进来了 try { using var s = Assembly.GetExecutingAssembly().GetManifestResourceStream("DrmkLauncher.app.ico"); if (s != null) return new Icon(s); } catch { } try { return Icon.ExtractAssociatedIcon(Environment.ProcessPath ?? Application.ExecutablePath); } catch { } return null; } private Bitmap LoadIconBitmap(int size) { try { if (Icon != null) return new Icon(Icon, size, size).ToBitmap(); } catch { } return new Bitmap(size, size); } private void DragMove() { ReleaseCapture(); SendMessage(Handle, WM_NCLBUTTONDOWN, (IntPtr)HTCAPTION, IntPtr.Zero); } private void PaintDots() { foreach (var d in new[] { _dotServer, _dotInjector }) { if (d == null) continue; d.Beat = _pulseOn; d.Invalidate(); } } // ================= 背景绘制 ================= protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 纯色底 using (var b = new SolidBrush(Col.Bg)) g.FillRectangle(b, ClientRectangle); // 顶栏(与 GM 面板 topbar 同色)+ 一条分隔线 // ⛑ 用缩放后的实际客户区(ClientSize)和标题区高(_hdr),别再用设计基准 W / HEADER。 int cw = ClientSize.Width; using (var b = new SolidBrush(Col.BgDeep)) g.FillRectangle(b, 0, 0, cw, _hdr); using (var p = new Pen(Col.Line, 1f)) g.DrawLine(p, 0, _hdr, cw, _hdr); // 品牌字:Ananta(粗体 + 竖向渐变)+ 副标题 var box = new RectangleF(92f * _s, 15f * _s, 380f * _s, 34f * _s); using (var f = BrandFont(30f * _s)) Gfx.GradText(g, "Ananta", f, box, Color.White, Color.FromArgb(0xc9, 0xd6, 0xe6), StringAlignment.Near, StringAlignment.Center); using (var f = new Font("Microsoft YaHei UI", Math.Max(9f, 15f * _s), FontStyle.Regular, GraphicsUnit.Pixel)) using (var b = new SolidBrush(Col.Text2)) g.DrawString("Server & Client Launcher", f, b, 94f * _s, 50f * _s); // 外框 using (var p = new Pen(Col.Line, 1f)) using (var gp = Gfx.Round(new RectangleF(0.5f, 0.5f, cw - 1f, ClientSize.Height - 1f), 14f * _s)) g.DrawPath(p, gp); } private static Font BrandFont(float px) { try { return new Font("Arial Black", px, FontStyle.Bold, GraphicsUnit.Pixel); } catch { return new Font("Segoe UI", px, FontStyle.Bold, GraphicsUnit.Pixel); } } // ================= 主按键:启动 / 关闭服务端 ================= private void OnServerButton(object sender, EventArgs e) { if (_serverReady) StopServer(); else OnStartServer(sender, e); } private async void OnStartServer(object sender, EventArgs e) { if (_serverBusy) return; if (!File.Exists(_startCmd)) { Warn("找不到 START.cmd:\n" + _startCmd); return; } // ⛑⛑ 2026-09-30:python / frida **不再拦启动服务端**。 // 旧版这里无条件检查 python ⇒ 没装 frida 的机器(= 绝大多数普通用户)点「启动服务器」 // 直接被挡死,哪怕注入器默认关着、压根用不到 python。 // 现在只在**真要拉起注入器**时才检查(见下面 MarkServerReady 之后那段)。 _serverBusy = true; _btnServer.Enabled = false; _btnServer.StopMode = false; _btnServer.Text = "启动中…"; Set(_dotServer, _txtServer, Col.Warn, "正在启动…", true); bool already = IsRunning("Drmk.App"); if (already) { Log("服务端已在运行,跳过 START.cmd"); } else { RunHidden("cmd.exe", _root, "/C", _startCmd); Log("START.cmd 已启动(隐藏窗口)"); } bool ok = already; if (!ok) { for (int i = 1; i <= 90 && !ok; i++) // 最多等 180 秒 { await Task.Delay(2000); ok = IsRunning("Drmk.App"); if (!ok) Set(_dotServer, _txtServer, Col.Warn, "正在启动…(已等 " + (i * 2) + " 秒)", true); } } if (ok) { MarkServerReady(); if (_injectorEnabled) { if (!_pythonOk) Warn(FridaHelp()); // 环境缺失:当场说清楚该补什么 else TryStartInjector("服务端刚就绪"); } else { Log("注入器开关 = 关(injectorEnabled=false)—— 不拉起。开法:settings.json 或界面开关。"); } EnablePlayButtons(true); } else { Set(_dotServer, _txtServer, Col.Err, "启动超时 —— 看 tools/launcher/launcher.log", false); _btnServer.Enabled = true; _btnServer.StopMode = false; _btnServer.Text = "重试启动服务器"; } _serverBusy = false; } /// 服务端起来了 ⇒ 主按键变成「关闭服务端」(灰蓝底 + 红色停止图标),仍然可点 private void MarkServerReady() { _serverReady = true; Set(_dotServer, _txtServer, Col.Ok, "已启动", false); _btnServer.Enabled = true; _btnServer.StopMode = true; _btnServer.Text = "关闭服务端"; } /// ⛑ 仅给 `--uitest-stop` 用:把主按键强推成某一态,好在没法手动点的环境里核对两种外观。 /// 不参与任何正常运行路径(正常运行只走 MarkServerReady / MarkServerDown)。 internal void ForceServerButtonForTest(bool stopMode) { _serverReady = stopMode; _btnServer.Enabled = true; _btnServer.StopMode = stopMode; _btnServer.Text = stopMode ? "关闭服务端" : "启动服务器"; _btnServer.Invalidate(); } /// 服务端没了 ⇒ 主按键回到「启动服务器」 private void MarkServerDown(string msg) { _serverReady = false; EnablePlayButtons(false); _btnServer.Enabled = true; _btnServer.StopMode = false; _btnServer.Text = "启动服务器"; Set(_dotServer, _txtServer, Col.Text3, msg, false); } private void StopServer() { Set(_dotServer, _txtServer, Col.Warn, "正在关闭…", true); _btnServer.Enabled = false; _btnServer.Text = "关闭中…"; Log("关闭服务端:开始"); // ⛑ 判据抄自 Run-All.ps1 的 Stop-StaleDrmkProcesses: // Drmk.App(dotnet 服务端)+ node 里跑 Drmk.Proxy\proxy\server.js 的那个 + 气泡图床那个。 // node.exe 不能全杀(机器上还有别的 node),必须按命令行匹配。 string ps = "Get-Process -Name Drmk.App -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue }; " + "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" -ErrorAction SilentlyContinue | " + " Where-Object { $_.CommandLine -match 'Drmk[\\\\.]Proxy' -and $_.CommandLine -match 'server[\\\\.]js' } | " + " ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; " + "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" -ErrorAction SilentlyContinue | " + " Where-Object { $_.CommandLine -match 'bubble-image-host[\\\\.]js' } | " + " ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"; try { RunHidden("powershell.exe", _root, "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps); Log("关闭服务端:已下发(Drmk.App + proxy server.js + bubble-image-host.js)"); } catch (Exception ex) { Warn("关闭服务端失败:\n" + ex.Message); Log("关闭服务端异常: " + ex.Message); _btnServer.Enabled = true; _btnServer.Text = "关闭服务端"; } // 剩下的交给 1 秒轮询:进程真没了 ⇒ MarkServerDown() } private void EnablePlayButtons(bool on) { _btnGm.Enabled = on; RefreshGameButton(); } // ================= 游戏进程检测(防重复启动) ================= // 游戏是两段式:Ananta.exe(引导,无参数)→ PCLoader.exe(NEAC 设备检测)→ 真正的 Ananta.exe。 // 任意一个在跑都算「已启动 / 启动中」,这时「启动游戏」必须锁掉。 // ⛑ 判据只取 Ananta / PCLoader 两个,**不要**把 WXDNeacClient.exe 算进来: // 它有可能在游戏退出后残留,一旦残留「启动游戏」就永久锁死,反而更难排查。 private static bool GameRunning() { return IsRunning("Ananta") || IsRunning("PCLoader"); } /// 按「服务端就绪 + 游戏没在跑」决定「启动游戏」按钮的文案与可用状态 private void RefreshGameButton() { bool running = GameRunning() || DateTime.Now < _gameLaunchGrace; bool on = _serverReady && !running; if (_btnGame.Text != (running ? "游戏已启动" : "启动游戏")) _btnGame.Text = running ? "游戏已启动" : "启动游戏"; if (_btnGame.DoneMode != running) _btnGame.DoneMode = running; if (_btnGame.Enabled != on) _btnGame.Enabled = on; if (running != _gameRunning) { _gameRunning = running; _btnGame.Invalidate(); Log(running ? "检测到游戏进程 ⇒ 「启动游戏」锁定(防重复启动)" : "游戏进程已退出 ⇒ 「启动游戏」解锁"); } } private void OnStartGame(object sender, EventArgs e) { // ⛑ 双保险:轮询可能还没刷新,点之前自己再确认一次 if (GameRunning()) { Warn("游戏已经在运行了,不能重复启动。"); RefreshGameButton(); return; } string exe = _lblGamePath.Tag as string; if (string.IsNullOrEmpty(exe) || !File.Exists(exe)) { if (!PickGameExe()) return; exe = _lblGamePath.Tag as string; } try { // 游戏要用它自己的目录做工作目录,否则资源路径会不对 Process.Start(new ProcessStartInfo(exe) { UseShellExecute = true, WorkingDirectory = Path.GetDirectoryName(exe) }); // ⛑ 引导进程要过一会儿才出现,给 30 秒宽限期,避免这段空窗里被连点两次 _gameLaunchGrace = DateTime.Now.AddSeconds(30); Log("游戏已启动:" + exe); RefreshGameButton(); } catch (Exception ex) { Warn("启动游戏失败:\n" + ex.Message); } } private bool PickGameExe() { var dlg = new OpenFileDialog { Title = "选择游戏主程序", Filter = "Ananta.exe|Ananta.exe|可执行文件|*.exe", CheckFileExists = true }; string dir = Path.GetDirectoryName(_root.TrimEnd('\\', '/')); if (dir != null && Directory.Exists(dir)) dlg.InitialDirectory = dir; if (dlg.ShowDialog(this) != DialogResult.OK) return false; SetGameExe(dlg.FileName); return true; } private void SetGameExe(string path) { if (string.IsNullOrEmpty(path)) return; if (!path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) { Warn("这不是 exe 文件:\n" + path); return; } _lblGamePath.Tag = path; _lblGamePath.Text = path; _lblGamePath.ForeColor = Col.Text2; // 「安装快速启动补丁」要知道往哪个目录拷 ⇒ 有路径才点得动。 if (_cardBoot != null) _cardBoot.Enabled = true; SaveSettings(); Log("游戏路径 = " + path); } // ================= 状态刷新 ================= private void RefreshStates() { bool srvRunning = IsRunning("Drmk.App"); if (!_serverReady && !_serverBusy) { if (srvRunning) { // ⛑⛑ 2026-09-30:注入器的补起判断**不能放在这里**(旧代码就放这儿,是个 bug)。 // 这里只在「启动器刚打开、第一次发现服务端已经在跑」时进来一次;用户之后再把 // 注入器开关打开时 _serverReady 已经是 true ⇒ 这个分支再也不会执行 ⇒ // 开关开了却永远不拉起(用户报的「别的电脑拉不起注入器」,日志里 // 「注入器开关 => 开」后面没有 spawn 就是这个症状)。统一交给下面的常驻判断。 MarkServerReady(); EnablePlayButtons(true); } else { Set(_dotServer, _txtServer, Col.Text3, "未启动", false); _btnServer.Enabled = true; _btnServer.StopMode = false; _btnServer.Text = "启动服务器"; } } // 服务端进程没了(含刚点过「关闭服务端」)⇒ 按键收回 if (_serverReady && !_serverBusy && !IsRunning("Drmk.App")) MarkServerDown("已停止"); // 游戏进程在不在 ⇒ 「启动游戏」按钮锁/放 RefreshGameButton(); RefreshInjector(); CheckInjectorSpawn(); // 刚拉起的注入器有没有活着?没活就把它的输出贴进日志 // ⛑⛑ 常驻补起:只要「服务端在跑 + 开关开着 + 注入器没活着」就拉,跟 _serverReady 无关。 // (旧逻辑只在启动器刚打开那一瞬判一次 ⇒ 用户后来开开关就永远拉不起来。) if (srvRunning) TryStartInjector("服务端在跑,补起"); // 2026-09-30:开关为「关」但注入器还活着 ⇒ 主动结束。 // ⛑ 这一步只能由启动器做:它是 requireAdministrator,而注入器正是它提权拉起来的, // 非管理员的 taskkill 会被「拒绝访问」挡掉(助手 shell 实测如此)。 if (!_injectorEnabled && InjectorAlive()) StopInjector(); } /// /// 拉起注入器(唯一入口,带 30 秒节流)。满足不了条件就写一行日志说明为什么没拉。 /// ⛑ 别在别处再各写一份 spawn —— 之前就是因为两处条件不一致才漏的。 /// private bool TryStartInjector(string why) { if (!_injectorEnabled) return false; if (InjectorAlive()) return false; if (!_pythonOk) { Log("注入器 = 开,但 frida 环境不可用 ⇒ 不拉起(" + why + ")"); return false; } if (!File.Exists(_injectorPy)) { Log("注入器 = 开,但找不到 " + _injectorPy + " ⇒ 不拉起(" + why + ")"); return false; } // 节流:注入器自己起不来(比如模块全关 / 撞上互斥锁)时别每几秒 spawn 一个。 // ⛑ 给 60 秒:正常起来后它会立刻写 injector_status.json,下一轮就判成「活着」了。 if ((DateTime.Now - _lastInjectorStart).TotalSeconds < 60) return false; _lastInjectorStart = DateTime.Now; PurgeStaleInjectorState(); // ⛑ 先把包里带来的陈旧 .loader.lock / 状态文件清掉 // ⛑ 输出重定向到 injector_console.log:加载器一崩就秒退,隐藏窗口下什么也看不到, // 不落盘就没法在别人的机器上排障(2026-09-30 的 ModuleNotFoundError 就是这么挖出来的)。 RunHidden("cmd.exe", _injectorDir, "/C", _pythonExe, _injectorPy, "1>", _injectorLog, "2>&1"); _injectorSpawnPending = true; _injectorSpawnAt = DateTime.Now; Log("注入器已拉起(" + why + ",隐藏窗口):" + _pythonExe + Environment.NewLine + " 输出 -> " + _injectorLog); return true; } /// /// 拉起后 12 秒复查:没活着就把加载器自己的输出末尾贴进 launcher.log(并弹一次窗)。 /// ⛑ 别的用户机器上「注入器拉不起来」90% 是它启动即崩,没有这段就只能靠猜。 /// private void CheckInjectorSpawn() { if (!_injectorSpawnPending) return; if ((DateTime.Now - _injectorSpawnAt).TotalSeconds < 12) return; _injectorSpawnPending = false; if (InjectorAlive()) return; string tail = "(没有 injector_console.log)"; try { if (File.Exists(_injectorLog)) { var lines = File.ReadAllLines(_injectorLog); int n = Math.Min(40, lines.Length); tail = string.Join(Environment.NewLine, lines, lines.Length - n, n).Trim(); if (tail.Length == 0) tail = "(文件是空的 —— 加载器连一行都没输出就退了)"; } } catch (Exception ex) { tail = "(读失败: " + ex.Message + ")"; } Log("注入器拉起后 12 秒仍未运行 ⇒ 它启动即退。加载器输出末尾:\n" + tail); if (!_injectorFailWarned) { _injectorFailWarned = true; Warn("注入器启动后立刻退出了。原因(加载器输出末尾):\n\n" + tail + "\n\n完整输出:" + _injectorLog + "\n已记进 launcher.log。"); } } /// /// 结束注入器(frida 宿主)。按命令行匹配,不按进程名 —— 机器上还有别的 python。 /// ⛑ 注意:如果注入器已经 attach 到游戏进程,杀掉 python 宿主只会断掉控制端, /// 已经注入到 Ananta.exe 里的 frida agent / hook 不会自动卸载 ⇒ 那种情况要重启游戏才算干净。 /// private void StopInjector() { if ((DateTime.Now - _lastInjectorKill).TotalSeconds < 10) return; // 节流 _lastInjectorKill = DateTime.Now; string ps = "Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" -ErrorAction SilentlyContinue | " + " Where-Object { $_.CommandLine -match 'AnantaInjector' -or $_.CommandLine -match 'unified_loader[\\\\.]py' } | " + " ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; " + "Get-CimInstance Win32_Process -Filter \"Name='cmd.exe'\" -ErrorAction SilentlyContinue | " + " Where-Object { $_.CommandLine -match 'AnantaInjector' } | " + " ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"; try { RunHidden("powershell.exe", _root, "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps); Log("注入器开关=关 => 已下发结束指令(python 宿主 + 它的 cmd 壳)"); } catch (Exception ex) { Log("结束注入器异常: " + ex.Message); } // 状态文件里 pid 留着会让 InjectorAlive() 一直判活 ⇒ 改写成 stopped/pid=0。 try { File.WriteAllText(_injectorStatus, "{\"state\": \"stopped\", \"pid\": 0}"); _injectorPid = 0; } catch { } } private void RefreshInjector() { string state = null; DateTime write = DateTime.MinValue; try { if (File.Exists(_injectorStatus)) { write = File.GetLastWriteTime(_injectorStatus); using var doc = JsonDocument.Parse(File.ReadAllText(_injectorStatus)); if (doc.RootElement.TryGetProperty("state", out var s)) state = s.GetString(); if (doc.RootElement.TryGetProperty("pid", out var p) && p.TryGetInt32(out int pid)) _injectorPid = pid; } } catch { } bool fresh = state != null && (DateTime.Now - write).TotalSeconds < 45; // ⛑ 陈旧的状态文件**连 pid 一起作废**:否则包里带过来的旧 pid 会一直污染 InjectorAlive()。 if (!fresh) { state = null; _injectorPid = 0; } if (!InjectorAlive()) state = null; string shown = state ?? ""; if (shown == _lastInjectorState) return; _lastInjectorState = shown; switch (shown) { case "starting": Set(_dotInjector, _txtInjector, Col.Warn, "正在启动…", true); break; case "waiting_game": Set(_dotInjector, _txtInjector, Col.Warn, "等待游戏打开", true); break; case "waiting_world": Set(_dotInjector, _txtInjector, Col.Warn, "等待进入游戏世界", true); break; case "attached": Set(_dotInjector, _txtInjector, Col.Warn, "正在装载…", true); break; case "injected": Set(_dotInjector, _txtInjector, Col.Ok, "已运行", false); break; case "stopped": Set(_dotInjector, _txtInjector, Col.Err, "已退出", false); break; default: Set(_dotInjector, _txtInjector, Col.Text3, "未运行", false); break; } } private bool InjectorAlive() { // ⛑ 按注入器自己写进状态文件的 pid 判,不能只看「有没有 python 进程」 // —— 机器上跑着别的 python 时会误判成注入器还活着。 if (_injectorPid <= 0) return false; // ⛑⛑ 2026-09-30:光判「进程还在」不够。分发包里带出去的 injector_status.json 存的是 // **开发机上一次的 pid**,目标机上随便一个活着的进程撞上同一个 pid ⇒ 被判成 // 「注入器已经活着」⇒ 永远不去 spawn(用户报的「别的电脑拉不起注入器」)。 // 所以必须校验那个 pid 真的是 python(注入器宿主必然是它)。 return IsPythonProcess(_injectorPid); } /// pid 是活着的 python / pythonw 吗(注入器宿主一定是它)。 private static bool IsPythonProcess(int pid) { try { if (pid <= 0) return false; var p = Process.GetProcessById(pid); if (p.HasExited) return false; string n = p.ProcessName.ToLowerInvariant(); return n == "python" || n == "pythonw"; } catch { return false; } } /// /// 清掉**分发包带过来的陈旧运行期状态**。 /// ⛑ 根因(2026-09-30 用户反馈「别的电脑上拉不起注入器」): /// tools\AnantaInjector\ 下这两个文件里存的是 pid,跟着包一起拷到别人机器上: /// ① .loader.lock → 加载器自己的互斥锁(loader_common.acquire_instance_lock): /// 那个 pid 在目标机上恰好活着 ⇒ 直接打印 /// 「已经有另一个常驻加载器在跑(pid=…)」然后退出,压根不干活。 /// ② injector_status.json → 启动器 InjectorAlive() 按这个 pid 判「已经活着」⇒ 不 spawn。 /// 两边一起把注入器卡死。判定标准:那个 pid 是不是本机活着的 python,不是就删。 /// private void PurgeStaleInjectorState() { try { string lp = Path.Combine(_injectorDir, ".loader.lock"); if (File.Exists(lp)) { int.TryParse((File.ReadAllText(lp) ?? "").Trim(), out int pid); if (!IsPythonProcess(pid)) { File.Delete(lp); if (pid > 0) Log("清掉陈旧的 .loader.lock(pid=" + pid + " 不是本机的注入器进程)"); } } } catch (Exception ex) { Log("清 .loader.lock 异常: " + ex.Message); } try { if (File.Exists(_injectorStatus)) { int pid = 0; try { using var doc = JsonDocument.Parse(File.ReadAllText(_injectorStatus)); if (doc.RootElement.TryGetProperty("pid", out var p) && p.TryGetInt32(out pid)) { } } catch { } if (!IsPythonProcess(pid)) { File.Delete(_injectorStatus); _injectorPid = 0; if (pid > 0) Log("清掉陈旧的 injector_status.json(pid=" + pid + " 不是本机的注入器进程)"); } } } catch (Exception ex) { Log("清 injector_status.json 异常: " + ex.Message); } } private static bool IsRunning(string name) { try { return Process.GetProcessesByName(name).Length > 0; } catch { return false; } } private void RunHidden(string exe, string workDir, params string[] args) { var psi = new ProcessStartInfo(exe) { UseShellExecute = true, WorkingDirectory = workDir }; // ★ 用 ShellExecute + SW_HIDE:进程仍然拿到控制台(只是不显示), // 这样 START.cmd 里 -NoNewWindow 拉起的 node/dotnet 才会继承这个隐藏控制台, // 不会自己弹新窗口。用 CreateNoWindow=true 反而会让它们各自弹一个黑窗口。 psi.WindowStyle = _tgConsole.Checked ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden; foreach (var a in args) psi.ArgumentList.Add(a); Log("spawn: " + exe + " " + string.Join(" ", psi.ArgumentList) + " (cwd=" + workDir + ", hidden=" + !_tgConsole.Checked + ")"); Process.Start(psi); } // ================= 杂项 ================= private string ReadAdminUrl() { try { var p = Path.Combine(_root, "config", "private-server.json"); if (!File.Exists(p)) return _adminUrl; using var doc = JsonDocument.Parse(File.ReadAllText(p)); var admin = doc.RootElement.GetProperty("admin"); string host = admin.TryGetProperty("host", out var h) ? h.GetString() : "127.0.0.1"; int port = admin.TryGetProperty("port", out var pt) ? pt.GetInt32() : 17888; return "http://" + host + ":" + port + "/"; } catch { return _adminUrl; } } /// /// 找能跑注入器(frida)的 python。按优先级: /// ① settings.json 里的 pythonExe(手动指定最优先) /// ② 随包分发的便携 python:tools\python\python.exe(推荐,普通用户免安装) /// ③ 注入器自带 venv:tools\AnantaInjector\.venv\Scripts\python.exe /// ④ PATH 里的 python.exe /// ⑤ 常见安装位置 %LOCALAPPDATA%\Programs\Python\Python3*\ /// 找不到返回 ""。⛑ 只找解释器,frida 是否可用另说(见 )。 /// private string ResolvePython() { var cand = new List(); string manual = ReadSettingString("pythonExe"); if (!string.IsNullOrEmpty(manual)) cand.Add(manual); cand.Add(Path.Combine(_root, "tools", "python", "python.exe")); cand.Add(Path.Combine(_injectorDir, ".venv", "Scripts", "python.exe")); foreach (var d in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(';')) { if (string.IsNullOrWhiteSpace(d)) continue; try { cand.Add(Path.Combine(d.Trim().Trim('"'), "python.exe")); } catch { } } try { string prog = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Python"); if (Directory.Exists(prog)) foreach (var d in Directory.GetDirectories(prog, "Python3*")) cand.Add(Path.Combine(d, "python.exe")); } catch { } foreach (var p in cand) { try { if (!string.IsNullOrEmpty(p) && File.Exists(p)) return p; } catch { } } return ""; } /// 跑一次 python -c "import frida",能过才算真能用(有 python 没 frida 也一样跑不了注入器)。 private static bool PythonHasFrida(string exe) { try { var psi = new ProcessStartInfo(exe, "-c \"import frida\"") { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; using var p = Process.Start(psi); if (p == null) return false; if (!p.WaitForExit(15000)) { try { p.Kill(); } catch { } return false; } return p.ExitCode == 0; } catch { return false; } } /// 环境缺失时给**可操作**的提示(旧版只有一句「找不到装了 frida 的 python」,用户不知道该干嘛)。 private string FridaHelp() { string bundled = Path.Combine(_root, "tools", "python", "python.exe"); string found = string.IsNullOrEmpty(_pythonExe) ? "没找到 python" : ("找到 python 但 import frida 失败:\n" + _pythonExe); return found + "\n\n" + "本启动器自带便携 python(已装好 frida),正常应该在这里:\n" + bundled + "\n" + "⇒ 如果这个文件夹不在,说明分发包缺了 tools\\python,请重新拷一份完整的包。\n\n" + "想用自己装的 python:在 tools\\launcher\\settings.json 里加一行\n" + " \"pythonExe\": \"你的python.exe的完整路径\"\n" + "再在那个 python 里跑一次: python -m pip install frida\n\n" + "注:不玩小游戏的话注入器可以不开,服务端和游戏本体不受影响。"; } /// 从 settings.json 读一个字符串字段(读不到返回 "")。 private string ReadSettingString(string key) { try { if (!File.Exists(_settingsPath)) return ""; using var doc = JsonDocument.Parse(File.ReadAllText(_settingsPath)); if (doc.RootElement.TryGetProperty(key, out var v)) return v.GetString() ?? ""; } catch { } return ""; } private void LoadSettings() { try { if (!File.Exists(_settingsPath)) return; using var doc = JsonDocument.Parse(File.ReadAllText(_settingsPath)); if (doc.RootElement.TryGetProperty("gameExe", out var g)) SetGameExe(g.GetString()); // 2026-09-30:注入器开关持久化(缺省 = 关,见字段声明处的根因说明)。 if (doc.RootElement.TryGetProperty("injectorEnabled", out var ie)) _injectorEnabled = ie.GetBoolean(); } catch { } // 2026-09-30:世界物件三条通道 —— 值以配置为准(事实源是 config,不是 settings.json)。 // ⛑ 规则 229:先读真实值再决定开关位置,别用默认值覆盖用户已经改过的配置。 try { _tgSceneItems.SetChecked(ReadWorldFlag("sceneItems", true)); _tgGadgets.SetChecked(ReadWorldFlag("gadgets", true)); _tgIndoor.SetChecked(ReadWorldFlag("indoorSectors", true)); Log($"世界物件开关初始态: sceneItems={_tgSceneItems.Checked} " + $"gadgets={_tgGadgets.Checked} indoorSectors={_tgIndoor.Checked}(读自 config/private-server.json)"); // 2026-10-02:车流 / 人流 —— 同样以配置为准(同一份 private-server.json)。 // ⛑ fallback 只在**读不到键**时才用:车流默认开、人流默认关(与当前配置现状一致)。 _tgTraffic.SetChecked(ReadAetherFlag("trafficEnabled", true)); _tgCrowd.SetChecked(ReadAetherFlag("crowdEnabled", false)); Log($"车流/人流开关初始态: trafficEnabled={_tgTraffic.Checked} " + $"crowdEnabled={_tgCrowd.Checked}(读自 config/private-server.json)"); } catch { } _worldLoading = false; try { // ⛑ 之前存的是「开」但现在环境没了 ⇒ 按「关」处理,别让界面显示一个拉不起来的开。 if (_injectorEnabled && !_pythonOk) { _injectorEnabled = false; Log("注入器开关:settings.json 里是开,但 frida 环境不可用 ⇒ 本次按关处理"); } _tgInjector.SetChecked(_injectorEnabled); } catch { } } private void SaveSettings() { try { File.WriteAllText(_settingsPath, JsonSerializer.Serialize(new { gameExe = _lblGamePath.Tag as string ?? "", injectorEnabled = _injectorEnabled })); } catch { } } // ================= 安装代理证书 ================= /// .pfx 的口令,与 config/private-server.json 的 /// network.proxy.certificatePassphrase 同值(.cer 不需要口令)。 private const string CertPassphrase = "ananta-local-proxy"; private void OnInstallCert(object sender, EventArgs e) { if (_cardCert != null) _cardCert.Enabled = false; // 装的过程里别重复点 try { string dir = Path.Combine(_root, "Drmk.Proxy", "proxy", "certs"); if (!Directory.Exists(dir)) { Warn("找不到证书目录:\n" + dir + "\n\n请确认这个启动器就在服务端根目录里。"); return; } int added = 0, skipped = 0, failed = 0; var sb = new System.Text.StringBuilder(); foreach (string file in EnumerateCertFiles(dir)) { string name = Path.GetFileName(file); using (var cert = LoadCert(file)) { if (cert == null) { failed++; sb.Append(" [失败] ").AppendLine(name); continue; } if (InRootStore(cert)) { skipped++; sb.Append(" [已有] ").AppendLine(name); continue; } if (AddToRootStore(cert)) { added++; sb.Append(" [已安装] ").AppendLine(name); } else { failed++; sb.Append(" [失败] ").AppendLine(name); } } } string summary = "新增 " + added + " 张,原本已有 " + skipped + " 张,失败 " + failed + " 张"; Log("安装证书 => " + summary); if (failed > 0) Warn(summary + "\n\n" + sb + "\n失败多半是权限问题:用管理员身份再点一次(会顺带装到本机所有账户)。"); else if (added == 0) Info("证书已经在信任库里了,不用再装。\n\n" + dir); else Info("已装进「受信任的根证书颁发机构」:\n" + summary + "\n\n" + sb + "\n现在登录游戏不会再跳「获取配置信息失败」。"); } catch (Exception ex) { Warn("安装证书失败:\n" + ex.Message); } finally { if (_cardCert != null) _cardCert.Enabled = true; } } // ================= 安装快速启动补丁(2026-10-01) ================= /// /// 把 tools\bootcfg\Ananta_Data 整份覆盖进游戏根目录(Ananta.exe 所在目录)。 /// ⛑ 目的:客户端启动时要先过一遍「启动设备检测程序」并在那之前等一段(boot.config 拉取), /// 这份 Ananta_Data 里已经是**直连官方地址**的 bootcfg.txt ⇒ 覆盖后跳过那段等待。 /// ⛑ 必须整份拷(目录里以后可能不止 bootcfg.txt 一个文件),且**允许覆盖**。 /// private void OnInstallBootPatch(object sender, EventArgs e) { string exe = _lblGamePath.Tag as string ?? ""; if (string.IsNullOrEmpty(exe)) { Warn("先点上面「选择游戏路径」,选到 Ananta.exe 之后再装这个补丁。"); return; } string gameRoot = Path.GetDirectoryName(exe) ?? ""; if (!Directory.Exists(gameRoot)) { Warn("游戏目录不存在:\n" + gameRoot); return; } if (!Directory.Exists(_bootCfgDir)) { Warn("找不到补丁源目录:\n" + _bootCfgDir + "\n\n请确认这个启动器就在服务端根目录里。"); return; } if (_cardBoot != null) _cardBoot.Enabled = false; try { int n = 0; var sb = new System.Text.StringBuilder(); // 递归拷:源目录下所有文件按相对路径落到 \Ananta_Data\ 下 foreach (string src in Directory.GetFiles(_bootCfgDir, "*", SearchOption.AllDirectories)) { string rel = src.Substring(_bootCfgDir.Length).TrimStart('\\', '/'); string dst = Path.Combine(gameRoot, "Ananta_Data", rel); Directory.CreateDirectory(Path.GetDirectoryName(dst)); File.Copy(src, dst, true); // ⛑ true = 覆盖 n++; sb.Append(" ").AppendLine(rel); } Log("安装快速启动补丁 => " + n + " 个文件 -> " + Path.Combine(gameRoot, "Ananta_Data")); Info("已把快速启动补丁装进游戏目录:\n" + Path.Combine(gameRoot, "Ananta_Data") + "\n共 " + n + " 个文件:\n" + sb + "\n现在启动游戏会跳过设备检测前的等待。"); } catch (Exception ex) { Warn("安装快速启动补丁失败:\n" + ex.Message + "\n\n多半是游戏正在运行、文件被占用 —— 关掉游戏再点一次。"); } finally { if (_cardBoot != null) _cardBoot.Enabled = true; } } /// certs 目录下的证书文件:.cer / .crt / .pfx,跳过备份(名字含 .bak)。 private static IEnumerable EnumerateCertFiles(string dir) { return Directory.GetFiles(dir).Where(p => { string n = Path.GetFileName(p); if (n.IndexOf(".bak", StringComparison.OrdinalIgnoreCase) >= 0) return false; string ext = Path.GetExtension(p).ToLowerInvariant(); return ext == ".cer" || ext == ".crt" || ext == ".pfx"; }).OrderBy(p => p, StringComparer.OrdinalIgnoreCase); } /// 读出**公钥证书**。.pfx 用口令解,之后只保留公钥部分(私钥不进信任库)。 private static X509Certificate2 LoadCert(string path) { try { byte[] bytes = File.ReadAllBytes(path); X509Certificate2 c = Path.GetExtension(path).ToLowerInvariant() == ".pfx" ? X509CertificateLoader.LoadPkcs12(bytes, CertPassphrase) : X509CertificateLoader.LoadCertificate(bytes); return X509CertificateLoader.LoadCertificate(c.RawData); } catch { return null; } } private static bool InRootStore(X509Certificate2 cert) { foreach (var loc in new[] { StoreLocation.CurrentUser, StoreLocation.LocalMachine }) { try { using var st = new X509Store(StoreName.Root, loc); st.Open(OpenFlags.ReadOnly); if (st.Certificates.Cast().Any(x => x.Thumbprint == cert.Thumbprint)) return true; } catch { } } return false; } /// 装进受信任根。当前用户库一定试;机器库只有管理员能写,写不进就算了。 private static bool AddToRootStore(X509Certificate2 cert) { bool any = false; try { using var cu = new X509Store(StoreName.Root, StoreLocation.CurrentUser); cu.Open(OpenFlags.ReadWrite); cu.Add(cert); any = true; } catch { } try { using var lm = new X509Store(StoreName.Root, StoreLocation.LocalMachine); lm.Open(OpenFlags.ReadWrite); lm.Add(cert); } catch { } return any; } private static void Info(string msg) { try { MessageBox.Show(msg, AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); } catch { } } private static void Warn(string msg) { try { MessageBox.Show(msg, AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); } catch { } try { File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "tools", "launcher", "launcher.log"), DateTime.Now.ToString("HH:mm:ss") + " [WARN] " + msg + Environment.NewLine); } catch { } } private void Log(string s) { try { File.AppendAllText(_logPath, DateTime.Now.ToString("HH:mm:ss") + " " + s + Environment.NewLine); } catch { } } }