Files

1696 lines
83 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { }
}
}