Files

1695 lines
83 KiB
C#
Raw Permalink Normal View History

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();
}
/// <summary>
/// 分辨率自适应:整棵控件树(含字号)按 _s 等比缩放。
/// ⛑ 为什么不在建控件时用缩小后的坐标:版式常量有上百个字面量,逐个乘系数必错;
/// 先按设计尺寸(100%)建完,再让 WinForms 自己 Scale 一遍,最后补字号。
/// ⛑ Control.Scale() **不会**动 Font(那是 AutoScaleMode 的活,这里是 None)⇒ 自己走一遍。
/// </summary>
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));
}
/// <summary>递归缩字号 + 缩自定义控件里那几个不跟 Control.Size 走的 float(圆角 / 分隔线 / TextPx)。</summary>
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;
}
/// <summary>整块可点的行式卡片(不自动加进 Controls —— 调用处自己 Add,和 TileBtn 一样)。</summary>
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
};
}
/// <summary>
/// 行式开关卡片 —— 与「显示控制台窗口」卡片**完全同款**(同高度 <see cref="ROW_H"/>、
/// 同字体:标题 Cap2 = 18px 粗体、第二行 15px 常规 Text3;同位置:图标 40×40 @(32,27)、
/// 标题 @(88,16)、第二行 @(88,52)、开关 @(INNER-102,30))。
/// 用户 2026-09-30 明确要求:六个行式卡片(选择游戏路径 / 三个世界物件 / 注入器 / 控制台)
/// 字体、格式、大小全部统一。
/// </summary>
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);
}
/// <summary>
/// 半宽(357)行式开关卡片 —— 给「车流 / 人流」并排那一行用。
/// 版式照 AddToggleCard 等比缩:图标 36×36 @(28,29)、标题 18px 粗 @(76,16)、
/// 第二行 15px 常规 @(76,52)、开关 @(357-102,30)。
/// ⛑ 标题/副标题宽度给 170(到开关左边为止),长了自动省略号,不会压到开关底下。
/// </summary>
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);
}
/// <summary>
/// 三份 private-server.json 的路径(源码份 / 运行份 / buildcheck 份)。
/// ⛑ 改配置必须三份同步 —— 服务端实际读的是 bin 那份,源码份是回滚基线。
/// </summary>
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"),
};
}
/// <summary>
/// 定位段落起始行:整行 trim 后以 <c>"&lt;section&gt;":</c> 开头。
/// 三个段名(sceneItems / gadgets / indoorSectors)在文件里各只出现一次,已 grep 确认。
/// </summary>
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;
}
/// <summary>读配置里 world.content.&lt;section&gt;.enabled(找不到 / 读失败 ⇒ 返回 fallback)。</summary>
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;
}
/// <summary>
/// 写三份配置里 world.content.&lt;section&gt;.enabled。
/// ⛑ 走**逐行文本替换**而不是 JsonNode 全量重写:58 KB 的配置里有中文、有 4 MB 级的
/// 其他文件引用,全量序列化会改缩进 / 转义 / 数字写法,git diff 会被淹掉。
/// 只改那一行,其它字节一个不动(CRLF + 无 BOM 由 WriteAllLines 保证)。
/// </summary>
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 份配置;**需重启服务端生效**)");
}
/// <summary>三个世界物件开关共用:把新值落到三份 private-server.json。</summary>
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 米才会重算)。启动器照做同样的两步。
/// <summary>读配置里 gameplay.aether.&lt;key&gt;(找不到 / 读失败 ⇒ fallback)。</summary>
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;
}
/// <summary>
/// 写三份配置里 gameplay.aether.&lt;key&gt;。
/// ⛑ 同 WriteWorldFlag:**逐行文本替换**,不整体重新序列化(58 KB 配置里有中文和
/// 大段 hex,全量重写会把缩进 / 转义 / 数字写法全改一遍,git diff 会被淹掉)。
/// ⛑ trafficEnabled / crowdEnabled 在三份配置里各只出现一次(已 grep 计数确认)。
/// </summary>
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 份配置)");
}
/// <summary>
/// 开关动作:先落盘(下次启动也生效),服务端在跑就再走 GM 面板那套 API 即时生效。
/// </summary>
/// <param name="needPush">true = 人流:改完必须再推一次密度才当场回收/生成。</param>
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));
}
}
/// <summary>给 admin HTTP 接口发一个 JSON POST。失败返回 false,resp 里是响应体或异常信息。</summary>
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();
}
/// <summary>
/// 拉起注入器(唯一入口,带 30 秒节流)。满足不了条件就写一行日志说明为什么没拉。
/// ⛑ 别在别处再各写一份 spawn —— 之前就是因为两处条件不一致才漏的。
/// </summary>
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;
}
/// <summary>
/// 拉起后 12 秒复查:没活着就把加载器自己的输出末尾贴进 launcher.log(并弹一次窗)。
/// ⛑ 别的用户机器上「注入器拉不起来」90% 是它启动即崩,没有这段就只能靠猜。
/// </summary>
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。");
}
}
/// <summary>
/// 结束注入器(frida 宿主)。按命令行匹配,不按进程名 —— 机器上还有别的 python。
/// ⛑ 注意:如果注入器已经 attach 到游戏进程,杀掉 python 宿主只会断掉控制端,
/// 已经注入到 Ananta.exe 里的 frida agent / hook 不会自动卸载 ⇒ 那种情况要重启游戏才算干净。
/// </summary>
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);
}
/// <summary>pid 是活着的 python / pythonw 吗(注入器宿主一定是它)。</summary>
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; }
}
/// <summary>
/// 清掉**分发包带过来的陈旧运行期状态**。
/// ⛑ 根因(2026-09-30 用户反馈「别的电脑上拉不起注入器」):
/// tools\AnantaInjector\ 下这两个文件里存的是 pid,跟着包一起拷到别人机器上:
/// ① .loader.lock → 加载器自己的互斥锁(loader_common.acquire_instance_lock):
/// 那个 pid 在目标机上恰好活着 ⇒ 直接打印
/// 「已经有另一个常驻加载器在跑(pid=…)」然后退出,压根不干活。
/// ② injector_status.json → 启动器 InjectorAlive() 按这个 pid 判「已经活着」⇒ 不 spawn。
/// 两边一起把注入器卡死。判定标准:那个 pid 是不是本机活着的 python,不是就删。
/// </summary>
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; }
}
/// <summary>
/// 找能跑注入器(frida)的 python。按优先级:
/// ① settings.json 里的 <c>pythonExe</c>(手动指定最优先)
/// ② 随包分发的便携 python:<c>tools\python\python.exe</c>(推荐,普通用户免安装)
/// ③ 注入器自带 venv:tools\AnantaInjector\.venv\Scripts\python.exe
/// ④ PATH 里的 python.exe
/// ⑤ 常见安装位置 %LOCALAPPDATA%\Programs\Python\Python3*\
/// 找不到返回 ""。⛑ 只找解释器,frida 是否可用另说(见 <see cref="PythonHasFrida"/>)。
/// </summary>
private string ResolvePython()
{
var cand = new List<string>();
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 "";
}
/// <summary>跑一次 <c>python -c "import frida"</c>,能过才算真能用(有 python 没 frida 也一样跑不了注入器)。</summary>
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; }
}
/// <summary>环境缺失时给**可操作**的提示(旧版只有一句「找不到装了 frida 的 python」,用户不知道该干嘛)。</summary>
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"
+ "注:不玩小游戏的话注入器可以不开,服务端和游戏本体不受影响。";
}
/// <summary>从 settings.json 读一个字符串字段(读不到返回 "")。</summary>
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 { }
}
// ================= 安装代理证书 =================
/// <summary>.pfx 的口令,与 config/private-server.json 的
/// network.proxy.certificatePassphrase 同值(.cer 不需要口令)。</summary>
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) =================
/// <summary>
/// 把 tools\bootcfg\Ananta_Data 整份覆盖进游戏根目录(Ananta.exe 所在目录)。
/// ⛑ 目的:客户端启动时要先过一遍「启动设备检测程序」并在那之前等一段(boot.config 拉取),
/// 这份 Ananta_Data 里已经是**直连官方地址**的 bootcfg.txt ⇒ 覆盖后跳过那段等待。
/// ⛑ 必须整份拷(目录里以后可能不止 bootcfg.txt 一个文件),且**允许覆盖**。
/// </summary>
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();
// 递归拷:源目录下所有文件按相对路径落到 <gameRoot>\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;
}
}
/// <summary>certs 目录下的证书文件:.cer / .crt / .pfx,跳过备份(名字含 .bak)。</summary>
private static IEnumerable<string> 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);
}
/// <summary>读出**公钥证书**。.pfx 用口令解,之后只保留公钥部分(私钥不进信任库)。</summary>
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<X509Certificate2>().Any(x => x.Thumbprint == cert.Thumbprint)) return true;
}
catch { }
}
return false;
}
/// <summary>装进受信任根。当前用户库一定试;机器库只有管理员能写,写不进就算了。</summary>
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 { }
}
}