Compare commits

12 Commits
Author SHA1 Message Date
hanfuit cc546031b0 gmweb:游戏内一键换装、方案配置与全局热键说明 2026-11-05 18:19:24 +08:00
hanfuit 3b240ec176 docs:新增 AGENTS.md 作为 AI / 新人接续开发的入口文件
一页纸解决「接手时不知道从哪读、哪些不能碰、改完怎么验」:
- 0 组件与端口速览;1 必读顺序(踩坑记录 P-001/P-002/P-011 列为首要)+ 按改动选文档
- 2 硬规则 R1–R9:DLL 与 exe 成对、改 DLL 要补 CRC 并重编服务端、Z 盘只是部署副本、
  gmweb 只能 IE11、静态 no-store、sys.executable 陷阱、不提交产物、版本号三处一致、声明与 GPL
- 3 构建命令(服务端三个 exe / 启动器单文件 / 客户端补丁 / PVF 导出)
- 4 代码地图:server.py 按功能给出行号区间,其余给模块职责
- 5 验证方法:进程端口、启动器日志、CRC verify、diff -rq,
  以及本项目一直在用的「静态服务 + 无头浏览器截图/量 rect」手法,附分场景清单
- 6 发版七项清单;7 当前状态;8 提交约定;9 不要做的事
- CLAUDE.md 作为指向 AGENTS.md 的兼容入口;根 README 顶部与文档索引、docs/README 同步挂上
2026-11-05 17:40:13 +08:00
hanfuit adcc5b0f3a docs:新增踩坑记录,13 条坑的成因复盘与判断规则
和「排查记录」不同,这份文档不写怎么修,只回答「为什么会踩、为什么没早发现」,
给的是可迁移的判断,而不是单次操作步骤。

- P-001 启动器套娃开窗:三层成因(打包改变 sys.executable 语义 / Popen 只保证
  进程创建不保证脚本执行 / 端口顺延把必然失败伪装成正常启动)、四个掩盖因素、
  四个不成立的隐含假设,以及「拉起子进程要校验副作用而非返回值」等五条教训
- P-002~P-013:DLL 指纹绑定、CRC32 种子、CRC 槽位扫错节、CSS 特异性、IE11、
  旧进程占端口、WebView2 缓存、console=False、图片占位时序、仓库与部署副本、
  replace 静默失败、补丁包缺文件
- 结尾提炼八条通用规则与发版检查清单
- 文档索引:根 README、docs/README 各补一行;排查文档顶部加成因复盘的交叉链接
2026-11-05 17:27:47 +08:00
hanfuit c6587d88ee 启动器:修复打包后自我套娃开窗,补运行日志与排查文档
根因:start_gmweb() 用 sys.executable 拉起 gmweb/serve.py,源码运行时是
python.exe,PyInstaller 单文件打包后却是启动器自身 —— 于是 `exe serve.py`
又开了一个启动器,新启动器再去拉 serve.py,每代顺延一个端口并各开一个
空窗口,直到 19546 端口用尽。开发机因为 18112 早被占用走了 early return,
所以看不出来。

- 新增多调用模式 run_script_instead():exe <xxx.py> [args] 时用 runpy 直接
  执行该脚本,不开窗口/不提权/不占互斥量,打包后拉起 serve.py 才合法
- 新增单实例互斥(Local\DNF90Launcher-5E9C1B7A):重复启动把已有窗口
  ShowWindow+SetForegroundWindow 后退出,不再开第二个窗口
- start_gmweb() 增加就绪校验:spawn 后最多等 6 秒确认 18112 在监听,未监听
  则改用 importlib 在本进程线程里跑 serve.py 的 main(),绝不再拉起新窗口
- self_elevate() 提权重启带上原始参数(原来 ShellExecuteW 传 None,参数全丢);
  DNF90_GMWEB_CHILD=1 的子进程不再触发 UAC
- WebView2 数据目录固定到 %LOCALAPPDATA%\DNF90Launcher\WebView2 且
  private_mode=False,避免 exe 在只读目录/网络盘时窗口空白且无报错;
  启动前查注册表确认运行时是否存在
- 新增 启动器日志.txt(exe 同目录,512KB 转 .old.txt):console=False 下
  print 与未捕获异常全部落盘,顶部记录 pid/argv/SERVER_ROOT
- 文档:新增 docs/启动器窗口风暴排查.md(现象、根因、复现手法、修复点、
  日志判读表),README 常见坑与文档索引、docs/README、launcher/README 同步
2026-11-05 17:22:51 +08:00
hanfuit da502b3524 docs:补上 90CN.dll 自校验 CRC 工具的文档说明
- 新增 docs/客户端补丁与CRC校验.md:SelfCrcSlot 结构、CRC32 算法与种子坑、
  槽位为什么只在 .data/.rdata 里找、info/verify/patch 用法与退出码、
  build_90cn.ps1 集成、与「服务端 DLL 指纹」的区别、六类故障排查
- 新增 client-patch/tools/README.md:脚本清单与常用命令速查
- docs/README.md:文档索引与上手路径补一条
- 根 README:关键设计说明补「客户端自校验(CRC32)」小节,文档索引补一行
- 90CN.cpp:清掉过期的重复注释(旧的 patch_selfcrc.py / .text 段说法),与实现对齐
2026-11-05 16:40:33 +08:00
hanfuit 9bdaf28683 docs:网盘地址改为真实百度网盘分享(提取码 s33r)
- docs/release/网盘下载.url:URL 换成 pan.baidu.com/s/1j31dVPF_nPKSN2QYHU-Ygg?pwd=s33r,双击直达目录
- 发布说明模板:下载表格填百度网盘真实链接与提取码,阿里云盘一行保留待填
- docs/release/README.md:占位说明改为「已指向真实分享,重新分享后需同步」
- 根 README:标题下与快速开始处补上成品下载入口
2026-11-05 16:19:37 +08:00
hanfuit 904f8a45d0 README:架构章节改用表格,避免中文宽度导致的 ASCII 图错位 2026-11-05 16:17:10 +08:00
hanfuit 607d705908 README:按 v2026.11.05 现状重写
- 补全整体架构图(启动器 19527 / 服务端 18111 / GM 页 18112 / 客户端 DLL)
- 目录结构补 gmweb、launcher/qr、docs/release
- 子项目表补 gmweb,标注 Go 1.26.5、Python 3.15 32 位
- 新增:启动器六页与更新日志双来源、GM 控制台 IE11 布局约束、二维码替换规则
- 新增:版本号与发布流程(CURRENT_VERSION、Release、网盘 .url 附件、DLL 指纹绑定)
- 新增:常见坑速查表、文档索引、提交约定
2026-11-05 16:16:45 +08:00
hanfuit 65169f5e81 docs:发布物料入库(Release 说明模板 + 网盘 .url 快捷方式)
- docs/release/发布说明模板.md:本次更新 / 下载渠道 / 安装 / 说明四段
- docs/release/网盘下载.url:InternetShortcut 模板,URL 为占位,发版前替换
- docs/release/README.md:发版七步与常见坑(占位地址别直传、DLL 与 EXE 需配套)
2026-11-05 16:16:41 +08:00
hanfuit fb3ec9f777 GM 页:关于页补上官方QQ群二维码,面板改为三列
- 关于页组合面板 534→712,新增中间列「官方QQ群」(178px,含 QQ 群号 1128511524)
- 新增 gmweb/qr/qq.png,沿用 bindQr 的缺图占位逻辑(qr-qq)
- 介绍卡片同步加宽到 712,与面板左右对齐;资源版本号 ?v=20261105C
2026-11-05 16:13:53 +08:00
hanfuit cc6beb0a71 启动器:关于页新增官方QQ群二维码,内容整体居中
- 关于页二维码面板改为三列:赞助我们(支付宝/微信)| 官方QQ群 | 团队公众号
- 新增 qr/qq.png 并加入 /qr/ 白名单与打包资源
- 各列二维码改为列内居中,QQ群列宽 184px,避免群号文字折行
- 关于页内容块 margin:0 auto 整体居中,并压缩上下间距使 1204x717 窗口内不出现滚动条
2026-11-05 16:11:26 +08:00
hanfuit 6a4b7e58d3 启动器:游戏更新拆分为当前/历史双子页,新增关于我们页
- 游戏更新页改为「当前更新 / 历史更新」两个标签页,历史页显示条数
- 更新日志合并仓库 Release 与本地 更新日志.txt,按日期去重、仓库优先;断网时仅用本地记录
- 主导航新增「关于我们」,内容与游戏内控制台关于页一致(四行文案 + 赞助/公众号二维码)
- 新增 /qr/<name> 白名单路由读取启动器目录 qr\ 下图片,防止目录穿越
- 二维码支持点击放大(灯箱),图片缺失时显示占位而不是破图
- 打包 spec 补入 qr 三张图,静态响应统一 Cache-Control: no-store
2026-11-05 16:05:00 +08:00
29 changed files with 2319 additions and 134 deletions

No files matched your search

+5
View File
@@ -53,3 +53,8 @@ launcher/dnf_items.json
*.swo
.DS_Store
Thumbs.db
# 启动器运行日志(exe 同目录)
launcher/启动器日志.txt
启动器日志.txt
启动器日志.old.txt
+236
View File
@@ -0,0 +1,236 @@
# AGENTS.md
> **给接续开发的 AI 的入口文件。** 先读完这一页再看代码。
> 这里只放三样东西:**哪些规则不能破**、**命令怎么跑**、**改完怎么验证**。
> 背景与完整说明看 [README.md](README.md),历史踩过的坑看 [docs/踩坑记录.md](docs/踩坑记录.md)。
---
## 0. 三十秒认识这个仓库
《地下城与勇士》90 级版本的**本地单机模拟器**,非私服、不联网、不开服。monorepo,四个进程协作:
| 组件 | 目录 | 技术栈 | 端口 / 产物 |
|------|------|--------|------------|
| 整合启动器 | `launcher/` | Python 3.15 **32 位** + pywebview + PyInstaller | `127.0.0.1:19527`,产物 `launcher/dist/DNF90启动器.exe` |
| 服务端 | `server/` | Go 1.26.5(模块 `longheng.io/server`) | `127.0.0.1:18111`(Admin),产物 `DNF90Server.exe` 等三个 |
| 游戏内 GM 控制台 | `gmweb/` | 纯 HTML/CSS/JS + Python 标准库 `serve.py` | `127.0.0.1:18112`,反代 18111 并注入 token |
| 客户端补丁 | `client-patch/` | C++ / MSVC vcxproj | 产物 `90CN.dll`,接管游戏面板弹 WebView2 窗口 |
其余:`pvf/`(PVF 解析,Go,GPL-3.0)、`loadout-tool/`(换装工具,C#)、`gmtool/`(旧版 GM 工具,仅参考)。
**仓库只存源码**:exe / dll / 美术 / PVF 资源都不在库里,成品走网盘。
当前版本 **v2026.11.05**,远程 `origin` = `https://git.hanfuit.com/Lingbao/90CNServer.git`,分支 `main`。
---
## 1. 开工前必读(按顺序,别跳)
1. 本文件(硬规则 + 验证方法)
2. [docs/踩坑记录.md](docs/踩坑记录.md) —— **至少读 P-001 / P-002 / P-011**,这三条是「不知道就会重新踩一遍」的
3. 然后按要改的地方选读:
| 要改什么 | 读哪份 |
|---------|--------|
| 启动器 / GM 工具 / 打包 | [docs/开发文档.md](docs/开发文档.md) |
| 服务端 GM 接口、拍卖行、背包 | [docs/服务端开发文档.md](docs/服务端开发文档.md) |
| 客户端补丁 90CN.dll、CRC 自校验 | [docs/客户端补丁与CRC校验.md](docs/客户端补丁与CRC校验.md) |
| PVF 解析、离线物品名 | [docs/PVF解析开发文档.md](docs/PVF解析开发文档.md) |
| 发版 | [docs/release/README.md](docs/release/README.md) + [发布说明模板](docs/release/发布说明模板.md) |
---
## 2. 硬规则(不是代码风格,违反了就出故障)
**R1|`90CN.dll` 与 `DNF90Server.exe` 是一对,必须同批构建、同批发布。**
服务端编译期把 DLL 的 SHA-256 写进常量 `boundDllSHA256`,DLL 一换指纹就不配套,
客户端能启动但**登录阶段被踢**,服务端日志没有明显报错。只改其中一半是最常见的翻车方式。
**R2|改完 DLL 必须先补 CRC32,再重编服务端。**
`client-patch/tools/build_90cn.ps1` 第三步会自动补;带 `-SkipCrc` 出来的 DLL 不要发布。
自校验失败的表现是客户端弹框退出(详见 P-003 / P-004:CRC 种子别手填、槽位只在 `.data`/`.rdata` 扫)。
**R3|仓库是唯一真源,`Z:\90server` 只是部署副本。**
调试可以直接在部署目录改,但**改完必须回灌仓库**,并用 `diff -rq` 核对(P-011 就是这么漏过
`90CN_web.ini` 和整个 `gmweb/` 的)。提交前 `git status` 确认没有该进的 ini / 静态资源留在外面。
**R4|`gmweb/` 只能写 IE11 兼容代码。**
游戏内嵌的是老 WebView 控件:不用 flex、不用 CSS 变量、不用 `transform`。布局统一
`float` + `box-sizing:border-box`;垂直居中用 `position:absolute; top:50%` + `-ms-transform:translateY(-50%)`。
改完 CSS/JS **必须把 `?v=YYYYMMDDx` 加一版**,否则 IE11 缓存不刷新。
**R5|静态响应统一 `Cache-Control: no-store`。**
启动器侧已在 `server.py` 的 `_send` 里加,新增路由时别绕过它;否则 WebView2 会缓存旧页面,
现象是「改了没生效」,会浪费大量时间。
**R6|单文件打包会改变 `sys.executable` 的含义。**
源码运行时它是 `python.exe`,`--onefile` 打包后**是启动器自己**。任何 `Popen([sys.executable, ...])`
的地方打包前都要重新推一遍;拉起子进程后要**校验副作用**(端口真在监听)而不是校验返回值。
这就是 P-001 的整条成因链,别再犯。
**R7|不提交编译产物。**
`*.exe`、`*.dll`、`launcher/dist/`、`launcher/build/`、`server/build_verify/`、`__pycache__/`、
`launcher/dnf_items.json`、`*.pvf`、`*.log` 已在 `.gitignore`。不要为了「方便别人」把它们塞进库。
**R8|版本号三处一致**:`launcher/server.py` 的 `CURRENT_VERSION`、发布包根目录 `更新日志.txt`、
客户端的版本显示。改一漏二会出现「首页还是旧版本号」。
**R9|品牌与声明不能动。**
「本模拟器资源禁止倒卖,请到官方微信公众号或社群下载」「禁止用于开服」这类声明必须保留;
`pvf/` 源自 pvfine,**GPL-3.0 有传染性**,改它之前先确认许可证影响。
---
## 3. 环境与构建
作者机器上的路径(其他环境自行替换,逻辑不依赖盘符):
```
Python 3.15 32 位 C:\Python315\python.exe # 32 位是硬要求,客户端/工具链都是 32 位
Go ≥ 1.26.5
MSVC Visual Studio(client-patch 用 vcxproj)
部署目录 Z:\90server # 只是副本
桌面发布包 90CN灵宝小竹整合20261105更新补丁\
```
```bash
# 服务端(三个 exe)
cd server
go env -w GOPROXY=https://goproxy.cn,direct
go env -w GOSUMDB=sum.golang.google.cn
go build -buildvcs=false -trimpath -o DNF90Server.exe ./cmd/server/dnf90
go build -buildvcs=false -trimpath -o DNF90Control.exe ./cmd/server/control
go build -buildvcs=false -trimpath -o DNF90Doctor.exe ./cmd/server/doctor
# 启动器(单文件)
cd launcher
C:/Python315/python.exe -m pip install pywebview pythonnet clr-loader pyinstaller
C:/Python315/python.exe -m PyInstaller --clean --noconfirm "DNF90启动器.spec"
# → launcher/dist/DNF90启动器.exe
# 客户端补丁(Release|Win32,自动补 CRC)
client-patch\tools\build_90cn.bat
# PVF 物品名导出
cd pvf && go build ./cmd/pvfexport/ && pvfexport -pvf Script.pvf -out dnf_items.json -compact
```
---
## 4. 代码地图
```
launcher/
├── server.py 2809 行,本地 HTTP 服务 + 全部 GM 逻辑 + 窗口启动(核心,占 80% 工作量)
│ ├── 202-360 日志(_logf) / 单实例互斥 / 多调用模式 run_script_instead()
│ ├── 369-430 start_gmweb():拉起 18112 + 就绪校验 + 进程内兜底 ← 高危区,见 P-001
│ ├── 551-686 更新日志:仓库 Release + 本地 txt 合并去重
│ └── 2481-2800 main() / webview2_installed() / show_window() / 窗口清理
├── index.html 234 行,六页骨架(开始/GM工具/一键换装/游戏设置/游戏更新/关于我们)
├── shell.js 804 行,页面路由、更新日志渲染、二维码灯箱
├── shell.css 447 行,页面样式(WebView2 环境,可用现代 CSS)
└── qr/ 四张二维码:alipay.png / wechat.png / gzh.jpg / qq.png
gmweb/ 游戏内控制台,共约 1100 行
├── serve.py 187 行,静态托管 + /api/* 反代 18111 + 注入 admin token + /qr/<name> 白名单
├── index.html / style.css / app.js IE11 约束,别用现代语法
└── qr/ 同上四张
client-patch/
├── 90CN.cpp 客户端补丁主体(极大,改前先搜函数名定位)
├── 90CN_web.ini width=800 height=720 close_action=1 ← 内嵌 GM 页尺寸,改这里不是改代码
└── tools/ build_90cn.ps1(构建+补CRC)/ patch_dll_crc.py(info|verify|patch)
server/cmd/server/ dnf90(主体)/ control(控制台)/ doctor(自检)/ launcher / logtool / 等
```
---
## 5. 改完怎么验证(**别只看代码,必须跑**)
```bash
# 进程与端口(改启动器后必查:正常只有 19527 + 18112 两个监听)
tasklist | findstr DNF90
netstat -ano | findstr "127.0.0.1:19527 127.0.0.1:18112 127.0.0.1:18111"
# 启动器现场日志(出问题先看这个,比猜快得多)
type 启动器日志.txt # exe 同目录;应有 pid / argv / 端口 / 多调用模式
# DLL 自校验状态
C:/Python315/python.exe client-patch/tools/patch_dll_crc.py verify Z:/90server/Client/90CN.dll
C:/Python315/python.exe client-patch/tools/patch_dll_crc.py info Z:/90server/Client/90CN.dll
# 仓库 ↔ 部署目录一致性
diff -rq gmweb Z:/90server/gmweb
diff -q client-patch/90CN_web.ini Z:/90server/Client/90CN_web.ini
```
**看界面的推荐做法**(比肉眼读像素可靠,本项目一直这么验):
```bash
# 1) 起静态服务;2) 注入脚本切到目标页;3) 无头浏览器截图/量尺寸
cd gmweb && C:/Python315/python.exe -m http.server 18198
# 页面里注入:document.getElementById('pane-about').className='pane on';
# (启动器则是给对应 .shell-page 加 active)
msedge --headless=new --disable-gpu --hide-scrollbars --window-size=800,720 \
--screenshot=shot.png --virtual-time-budget=3000 http://127.0.0.1:18198/_t.html
# 量尺寸:把 getBoundingClientRect 结果写进 document.title,再用 --dump-dom | grep title
```
尺寸口径:GM 页内嵌窗口 **800×720**;启动器窗口 **1204×717**。改完两边都看一眼有没有滚动条。
**分场景清单**
| 改了什么 | 必须确认 |
|---------|---------|
| 启动器页面/样式 | 打包 → 杀旧进程 → 启动 → 六页都能切、无滚动条、`启动器日志.txt` 无异常 |
| 启动器 Python 逻辑 | 先跑源码再打包:`cd launcher && DNF90_SERVER_ROOT=Z:/90server DNF90_NO_ELEVATE=1 python server.py`(`SERVER_ROOT` 靠 EXE 同级有 `Server/` 识别,脱离部署目录时用环境变量指定);确认没有新增 `sys.executable` 陷阱 |
| gmweb 页面 | `?v=` 加一版;800×720 截图三列/四页无溢出;`/api/*` 仍能通 |
| 服务端接口 | `go build` 通过;`DNF90Doctor.exe` 自检;启动器「GM工具」页能读到数据 |
| 客户端补丁 | `build_90cn.bat` 全流程(含补 CRC)→ **重编服务端** → 两边一起替换 → 登录验证 |
| 发版 | 按 [docs/release/README.md](docs/release/README.md) 七步走,见第 6 节清单 |
---
## 6. 发版检查清单(缺一项就会有人装不上)
- [ ] `Client\90CN.dll` 与 `Server\runtime\bin\DNF90Server.exe` **成对且指纹一致**
- [ ] `gmweb\` 整个目录(含 `qr\` 四张图)
- [ ] `Client\90CN_web.ini`(缺它回退 1040 宽、`close_action=0`)
- [ ] 启动器 exe + `更新日志.txt`
- [ ] 版本号三处一致(`CURRENT_VERSION` / 客户端显示 / 更新日志)
- [ ] **干净目录**(非中文、无空格)实跑一遍再对外发
- [ ] Release 说明用模板;附件传 `docs/release/网盘下载.url`(别直传十几 G 的包)
---
## 7. 当前状态(2026-11-05)
- 版本 `v2026.11.05`;`main` 与 `origin/main` 同步
- 启动器:六页(开始/GM工具/一键换装/游戏设置/游戏更新[当前|历史]/关于我们),关于页三列二维码
- gmweb:四页,关于页三列(赞助我们 353 | 官方QQ群 178 | 团队公众号 179,面板宽 712)
- 已修复:启动器打包后自我套娃开窗(P-001),带单实例互斥 + 运行日志 + WebView2 固定数据目录
- 已知待办:release 附件 `.url` 里的网盘链接换代时,要同步改 `发布说明模板.md` 表格;`gmtool/` 为旧版遗留,可择机归档
---
## 8. 提交约定
1. 提交信息格式:`范围:做了什么`,例如 `启动器:游戏更新拆分为当前/历史双子页,新增关于我们页`
2. 一次提交一件事;改了接口就同步改对应文档(服务端接口 → `docs/服务端开发文档.md`)
3. 只提交源码与文档;产物走 `.gitignore`
4. 新踩的坑,追加到 `docs/踩坑记录.md`(编号延续 P-0xx,格式照抄现有条目)
5. 推送前 `git status -sb` 确认工作区干净、与 `origin/main` 无分叉
---
## 9. 不要做的事
- ❌ 不要「为了省事」只替换 DLL 或只替换服务端 exe —— 见 R1
- ❌ 不要用 `!important` 硬压 gmweb 的样式问题 —— 先看是哪个选择器赢了(P-005)
- ❌ 不要用现代 CSS/JS 写 `gmweb/` —— 见 R4
- ❌ 不要把 `Z:\90server` 当工作区改完就完事 —— 见 R3
- ❌ 不要在启动器里加「打包后会指向自己」的子进程调用而不做校验 —— 见 R6
- ❌ 不要提交 exe/dll/`dnf_items.json`/PVF 资源
- ❌ 不要把本机测试用的掉落改动带进对外发布的服务端
- ❌ 不要公开分发或倒卖本仓库代码与资源(`pvf/` 除外,但受 GPL-3.0 约束)
+10
View File
@@ -0,0 +1,10 @@
# CLAUDE.md
本仓库的统一入口是 **[AGENTS.md](AGENTS.md)**(硬规则 R1–R9、构建命令、验证方法、代码地图、发版清单)。
开工前请先完整读一遍,再按其中的「开工前必读」选读 `docs/` 下的对应文档。
三条最容易被忽略、且会直接导致故障的规则:
1. `90CN.dll` 与 `DNF90Server.exe` 指纹绑定,**必须成对构建、同批发布**(R1/R2)
2. `gmweb/` 是 IE11 环境,**不能用 flex 等现代 CSS**,改完资源要加 `?v=`(R4)
3. 单文件打包后 `sys.executable` 指向启动器自身,**任何拉起子进程的代码都要校验副作用**(R6)
+155 -57
View File
@@ -1,119 +1,217 @@
# DNF90 单机模拟器 · 源码仓库
# DNF90 灵宝小竹单机版 · 源码仓库
一个 DNF90 单机模拟器(非私服)的完整源码 monorepo,包含**服务端(Go)**、**客户端补丁(C++)**、**GM 管理工具 / 启动器(Python)**、**一键换装工具(C#)**,以及**本地 PVF 解析套件(Go)**。
《地下城与勇士》90 级版本的**本地单机模拟器**(非私服、不联网、不开服)完整源码 monorepo:服务端、客户端补丁、整合启动器、游戏内 GM 控制台,以及 PVF 解析套件。
> 本仓库只含源码,不含编译产物(exe/dll)与美术/PVF 资源。clone 后需按各自子目录的说明编译。
- 当前版本:**v2026.11.05**
- 维护:汉服电脑宅团队(灵宝仙尊 联合 淡竹茹 开发)
- 官方 QQ 群:**1128511524**
- 成品下载:[百度网盘](https://pan.baidu.com/s/1j31dVPF_nPKSN2QYHU-Ygg?pwd=s33r)(提取码 `s33r`,链接已内置)
- 仓库只存源码,不含 exe/dll、美术与 PVF 资源;发布包另走网盘
> 本模拟器资源禁止倒卖,请到官方微信公众号或社群下载。
> **用 AI 接续开发?先读 [AGENTS.md](AGENTS.md)** —— 硬规则、构建命令、验证方法、代码地图一页看完。
---
## 目录结构
## 一、它由哪几块组成
| 进程 / 组件 | 位置 | 监听 | 职责 |
|------------|------|------|------|
| `DNF90启动器.exe` | `launcher/` | `127.0.0.1:19527` | 拉起与停止服务端、内置 GM 工具、换装、游戏设置、更新日志、关于页 |
| `DNF90Server.exe` | `server/` | `127.0.0.1:18111`(Admin) | 游戏逻辑、账号角色、背包拍卖行任务副本、SQLite 落库 |
| GM 控制台网页 | `gmweb/` | `127.0.0.1:18112` | 游戏内嵌页面,`/api/*` 反代 18111 并注入 admin token |
| `90CN.dll` | `client-patch/` | — | 客户端补丁,接管游戏自带面板,弹 WebView2 窗口指向 18112 |
调用关系(从玩家视角):
1. 玩家双击启动器 → 启动器拉起 `DNF90Server.exe`,并顺带起 `gmweb/serve.py`
2. 登录进游戏 → 客户端加载 `90CN.dll`,连本地服务端 `18111`(DLL 指纹不匹配就断开)
3. 点小井盖 → 补丁弹自己的窗口,打开 `http://127.0.0.1:18112/` 的 GM 控制台
4. 控制台里的所有操作,经 `serve.py` 反代到服务端 Admin 接口执行
## 二、目录结构
```
DNF90-Source/
90CNServer/
├── server/ DNF90 服务端(Go,模块 longheng.io/server)
├── client-patch/ 客户端补丁(C++,90CN.dll 源码)
│ ├── cmd/server/{dnf90,control,doctor} 主程序 / 控制台 / 自检
│ ├── internal/ pkg/ 业务与基础库
│ └── build_verify/ 构建校验脚本(不参与发布)
├── client-patch/ 客户端补丁(C++ / MSVC,含 90CN_web.ini)
├── launcher/ 整合启动器 + 内置 GM 工具(Python 3.15 + pywebview)
│ └── qr/ 关于页二维码(alipay / wechat / gzh / qq)
├── gmweb/ 游戏内 GM 控制台网页(纯静态 + serve.py 反代)
│ └── qr/ 控制台关于页二维码
├── loadout-tool/ 一键换装工具(C# / .NET)
├── launcher/ 整合启动器 + GM 管理工具(Python + pywebview)
├── gmtool/ GM 工具旧版(Python + FastAPI,已被 launcher 取代,保留参考)
├── gmtool/ GM 工具旧版(FastAPI,已被 launcher 取代,仅留参考)
├── pvf/ PVF 解析套件(Go,模块 pvfine,GPL-3.0)
│ └── cmd/pvfexport/ 物品名称表导出工具
└── docs/ 开发文档
├── docs/ 开发文档 + 发布物料模板(release/)
└── README.md
```
---
## 三、子项目速览
## 子项目速览
| 目录 | 语言 / 技术栈 | 说明 |
|------|--------------|------|
| `server/` | Go 1.26.5(`longheng.io/server`) | 服务端主体;Admin/GM HTTP 接口、拍卖行、背包、任务、副本、点券 |
| `client-patch/` | C++ / MSVC vcxproj | `90CN.dll`:面板接管、安图恩兼容、蛇腹剑修复、战斗面板布局等 |
| `launcher/` | Python 3.15(32 位)+ pywebview 6 + PyInstaller | 单 EXE 启动器,六页单窗口,内置完整 GM 工具与更新日志 |
| `gmweb/` | 纯 HTML/CSS/JS + Python 标准库 | 游戏内 GM 控制台页面,`serve.py` 反代 18111 并注入 admin token |
| `loadout-tool/` | C# / .NET | 独立换装工具,通过 Admin API 操作 |
| `gmtool/` | Python + FastAPI | GM 工具旧版,保留参考 |
| `pvf/` | Go 1.26(`pvfine`,GPL-3.0) | PVF 解析库 + `pvfexport` 物品名导出 |
| 目录 | 语言 | 模块/技术栈 | 说明 |
|------|------|------------|------|
| `server/` | Go 1.26 | `longheng.io/server` | DNF90 服务端,含 GM 拍卖行接口、自动补足上架、背包管理 |
| `client-patch/` | C++ | MSVC / vcxproj | 90CN 客户端补丁,含安图恩兼容、蛇腹剑修复等 |
| `loadout-tool/` | C# | .NET | 一键换装工具,通过 Admin API 操作 |
| `launcher/` | Python 3.15 | pywebview + PyInstaller | 单 EXE 启动器 + 内置 GM 工具(双数据源) |
| `gmtool/` | Python | FastAPI | GM 工具旧版(独立服务 + 网页) |
| `pvf/` | Go 1.26 | `pvfine`(GPL-3.0) | PVF 解析库 + `pvfexport` 物品名导出工具 |
## 四、快速开始
---
### 4.1 只想要能玩的成品
## 快速开始
1. 从[百度网盘](https://pan.baidu.com/s/1j31dVPF_nPKSN2QYHU-Ygg?pwd=s33r)(提取码 `s33r`)或官方社群下载发布包,解压到**非中文、无空格**路径,例如 `D:\DNF90`
2. 双击 `DNF90启动器.exe`
3. 点「开始」拉起服务端,等日志出现「服务已就绪」再进游戏(首次约 85 秒)
4. 进游戏后点小井盖即可打开 GM 控制台
### 1. 服务端(server/)
### 4.2 从源码编译
要求 Go ≥ 1.26(`go.mod` 声明 1.26.5)。建议配置国内镜像:
**服务端**(Go ≥ 1.26.5,建议国内镜像):
```bash
go env -w GOPROXY=https://goproxy.cn,direct
go env -w GOSUMDB=sum.golang.google.cn
cd server
go build -buildvcs=false -trimpath -o DNF90Server.exe ./cmd/server/dnf90
go build -buildvcs=false -trimpath -o DNF90Server.exe ./cmd/server/dnf90
go build -buildvcs=false -trimpath -o DNF90Control.exe ./cmd/server/control
go build -buildvcs=false -trimpath -o DNF90Doctor.exe ./cmd/server/doctor
go build -buildvcs=false -trimpath -o DNF90Doctor.exe ./cmd/server/doctor
```
详见 `server/README.md`。
### 2. GM 启动器(launcher/)
要求 Python 3.15(32 位)+ Windows。打包:
**启动器**(Python 3.15 32 位):
```bash
cd launcher
python -m pip install pywebview pythonnet clr-loader pyinstaller
python -m PyInstaller --clean --noconfirm "DNF90启动器.spec"
# 产物:launcher/dist/DNF90启动器.exe
```
产物在 `launcher/dist/DNF90启动器.exe`。详见 `docs/开发文档.md`。
> 单文件打包有代价:onefile 会先解包再启动,改完源码必须重新打包;旧进程未退出时会继续占用 19527 端口,看到旧界面先确认进程已杀干净。
### 3. PVF 解析(pvf/)
**PVF 物品名导出**:
```bash
cd pvf
go build ./cmd/pvfexport/ # 物品名称表导出工具
go build ./cmd/pvfexport/
pvfexport -pvf Script.pvf -out dnf_items.json -compact
```
详见 `pvf/README.md`。
**客户端补丁 / 换装工具**:详见 `docs/开发文档.md` 与 `client-patch/`、`loadout-tool/` 各自的工程文件。
### 4. 客户端补丁(client-patch/)与换装工具(loadout-tool/)
## 五、启动器要点
详见各自说明,编译命令在 `docs/开发文档.md` 和原 README 中。
- **单窗口六页**:开始 / GM工具 / 一键换装 / 游戏设置 / 游戏更新 / 关于我们,导航栏左侧切换、右侧状态灯
- **本地端口** 19527,被占用时自动 +1 顺延(最多 20 次);页面为本地静态资源,响应统一 `Cache-Control: no-store`,避免 WebView2 缓存旧页面
- **更新日志双来源**:优先读仓库 `Lingbao/90CNServer` 的 Release 接口,再用 EXE 同目录的 `更新日志.txt` 补齐仓库还没发布的版本,按日期去重(仓库优先);断网时只用本地记录并在页面顶部给出黄色提示
- **当前更新 / 历史更新** 分成两个标签页,历史页显示条数
- **关于我们**:四行介绍文案 + 三列二维码(赞助我们:支付宝/微信 · 官方QQ群 · 团队公众号),点任意二维码可放大
---
## 六、游戏内 GM 控制台(gmweb/)
## 关键设计说明
- 由 `serve.py` 托管在 `127.0.0.1:18112`,`/api/*` 反代到服务端 `127.0.0.1:18111` 并注入 admin token(token 不进页面 JS)
- 窗口尺寸由 `client-patch/90CN_web.ini` 决定:`width=800 height=720`
- 页面为 **IE11 兼容**写法:不用 flex,统一 `float` + `box-sizing:border-box`;关于页垂直居中用 `position:absolute; top:50%` + `-ms-transform`
- 关于页结构:介绍卡片(712 宽)+ 组合面板三列 `赞助我们 353 | 官方QQ群 178 | 团队公众号 179`,列间一条竖线
- 静态资源带 `?v=YYYYMMDDx` 版本后缀,改 CSS/JS 后**必须加一版**,否则 IE11 缓存不刷新
## 七、二维码与品牌素材替换
| 位置 | 用途 | 说明 |
|------|------|------|
| `launcher/qr/` | 启动器「关于我们」 | `alipay.png` / `wechat.png` / `gzh.jpg` / `qq.png`,同名覆盖即替换 |
| `gmweb/qr/` | 游戏内控制台「关于」 | 同上四张 |
| `launcher/sponsor.png` / `sponsor_qr.png` | 旧版赞助页素材 | 历史遗留,可忽略 |
- 图片缺失不会破图:启动器与 GM 页都会显示「二维码未配置 + 期望路径」占位
- 启动器打包时 `qr/` 四张图会进 EXE;同时支持在 EXE 同目录放 `qr\` 覆盖(优先级高于内置)
- `launcher/server.py` 的 `/qr/<name>` 是**白名单**路由,只认这四个文件名,防目录穿越
## 八、版本号与发布流程
1. **改版本号**:`launcher/server.py` 的 `CURRENT_VERSION`(当前 `v2026.11.05`),游戏内与启动器首页都读它
2. **写更新日志**:在发布包根目录的 `更新日志.txt` 追加一条(日期 + 条目),启动器会自动合并进「游戏更新」
3. **构建**:服务端三个 exe + 启动器单文件 exe
4. **打发布包**:服务端 runtime、gmweb、启动器、`90CN_web.ini` 一起收齐;**DLL 与 EXE 必须配套**(见下)
5. **发 Release**:在本仓库的 Release 里写发布说明(模板见 `docs/release/发布说明模板.md`)
6. **网盘地址**:`docs/release/网盘下载.url` 已指向百度网盘真实分享(提取码 `s33r`),作为 Release 附件上传——十几个 G 的包不适合直传,`.url` 是最省事的缓兵之计;重新分享后记得同步改模板表格
7. **验证**:启动器首页版本号、更新日志条数、关于页二维码、GM 页三列是否都正常
> ⚠️ **DLL 指纹绑定**:`90CN.dll` 的 SHA-256 在编译期编进 `DNF90Server.exe`(`boundDllSHA256`)。换 DLL 必须重编服务端,否则客户端连接会被拒绝。发布包里 DLL 与 EXE 一定要成对。
## 九、关键设计说明
### GM 工具双数据源
GM 工具(launcher)支持两种数据访问方式,服务端在线时走 Native API(事务一致),离线时走 SQLite 直读:
- **Native API**:`127.0.0.1:18111` + `X-Admin-Token`,实时操作
- **SQLite 直读**:直接读写 `Server/runtime/data/dnf90.db`
- **Native API**(服务端在线):`127.0.0.1:18111` + `X-Admin-Token`,实时操作、事务一致
- **SQLite 直读**(服务端离线):直接读写 `Server/runtime/data/dnf90.db`,可预览与批量改
- 启动器通过探测 18111 是否监听来切换,界面上以状态灯区分
### 物品名称离线显示
`pvf/cmd/pvfexport` 从 `Script.pvf` 提取 `item_id → 名称` 全量映射,导出 `dnf_items.json`。GM 工具放在 `GM配置/` 目录,启动时对比 PVF 与名称表的修改时间,PVF 更新则自动重新导出,实现离线也能显示物品中文名。
`pvf/cmd/pvfexport` 从 `Script.pvf` 提取 `item_id → 名称` 全量映射导出 `dnf_items.json`,放在 `GM配置/`;启动时对比 PVF 与名称表修改时间,PVF 更新则自动重新导出,离线也能显示中文物品名。
### GM 拍卖行
服务端已实现 GM 拍卖行接口(`/local/dnf-gm/auction/*`),支持:
- **免押金上架**(`Deposit=0`)
- **自动补足上架**(背包不足时凭空发放差额再上架)
- **列背包 / 删除背包行**(含穿戴装备)
服务端实现 `/local/dnf-gm/auction/*`:免押金上架(`Deposit=0`)、自动补足上架(背包不足时先发差额)、列背包 / 删除背包行(含穿戴装备)。
---
### 客户端自校验(CRC32)
## 许可证
`90CN.dll` 里有一个 `SelfCrcSlot` 结构(`magic=0xC0DEC0DE` + `offset` + `expected`),构建后由 `client-patch/tools/patch_dll_crc.py` 把「整个文件的 CRC32」与槽位偏移写进去;运行时读盘重算并比对,不符就弹框退出——所以「patch 掉校验分支」这条路是堵死的。
- `server/`、`client-patch/`、`loadout-tool/`、`launcher/`、`gmtool/`:**私有/闭源**,请勿对外公开。
- `pvf/`:来自开源项目 [pvfine](https://github.com/) ,遵循 **GPL-3.0**(见 `pvf/LICENSE`)。若需合并/分发,注意 GPL 传染性。
- 编译脚本 `build_90cn.ps1` 第三步会自动补 CRC,`-SkipCrc` 出来的 DLL 不要发布
- 定位槽位只认 `.data` / `.rdata`,因为 `0xC0DEC0DE` 在 `.text` 里也有一份(MSVC 的比较立即数),扫错会写坏机器码,表现为读槽位后立刻崩
- 改完 DLL 除了补 CRC,**还要重编服务端**:`DNF90Server.exe` 编译期绑定了 DLL 的 SHA-256(`boundDllSHA256`),指纹不配套会在登录阶段被踢
> ⚠️ 对外发布服务端时,不要附带本机测试用的深渊掉落修改;远程换装工具不要直接公开 Admin 端口。
详见 [docs/客户端补丁与CRC校验.md](docs/客户端补丁与CRC校验.md)。
---
## 十、常见坑
## 贡献与开发
| 现象 | 原因 | 处理 |
|------|------|------|
| 启动器还是旧版本号 / 旧品牌 | 旧进程没退,19527 被占,页面连到旧服务 | 任务管理器结束所有 `DNF90启动器.exe` 后重开 |
| 改了 CSS/JS 页面没变 | WebView2 或 IE11 缓存 | 启动器已发 `no-store`;GM 页需改 `?v=` 版本号 |
| 二维码显示空框 | 图片没放进 `qr/` 或缺文件名 | 看占位提示里的期望路径,四个文件名固定 |
| 关于页底部被日志栏盖住 | 内容高度超过可视区 | GM 页 `#body` 的 `bottom` 必须等于 `#log` 实高(含 padding/border) |
| 客户端连不上服务端 | DLL 与 EXE 指纹不匹配 | 用配套的一对重新打包,别混用 |
| 竖线不显示 | CSS 特异性:`.cols .box` 把 border 清掉了 | 分隔线写在 `.cols .box-r` / `.cols .box-m` 上 |
| 启动器窗口越开越多、里面没内容 | 打包后 `sys.executable` 就是启动器自己,`start_gmweb()` 拉起的是新窗口而不是 `serve.py` | 已有「多调用模式 + 单实例互斥 + 就绪校验」,见 [docs/启动器窗口风暴排查.md](docs/启动器窗口风暴排查.md) |
| 启动器窗口能开但一片空白 | WebView2 数据目录不可写(exe 在只读目录/网络盘) | 已固定到 `%LOCALAPPDATA%\DNF90Launcher\WebView2`;缺运行时看 `启动器日志.txt` |
1. 各子项目独立编译,无跨项目构建依赖。
2. 提交时请遵守各自目录的 `.gitignore`,勿提交编译产物与 `dnf_items.json`。
3. 服务端改动涉及 GM 接口时,同步更新 `docs/开发文档.md` 的 API 契约章节。
## 十一、文档索引
| 文档 | 内容 |
|------|------|
| [AGENTS.md](AGENTS.md) | **AI / 新人接续开发入口**:硬规则 R1–R9、构建与验证命令、代码地图、发版清单 |
| [docs/README.md](docs/README.md) | 文档总索引与上手路径 |
| [docs/开发文档.md](docs/开发文档.md) | 启动器 / GM 工具:环境、架构、API 契约、打包部署、常见问题 |
| [docs/服务端开发文档.md](docs/服务端开发文档.md) | 服务端架构、GM 接口契约、拍卖行与背包改动 |
| [docs/PVF解析开发文档.md](docs/PVF解析开发文档.md) | pvfine 与 pvfexport、离线名称集成 |
| [docs/客户端补丁与CRC校验.md](docs/客户端补丁与CRC校验.md) | 90CN.dll 自校验 CRC、`patch_dll_crc.py` 用法、与 DLL 指纹的区别 |
| [docs/启动器窗口风暴排查.md](docs/启动器窗口风暴排查.md) | 启动器套娃开窗的根因与修复、日志判读、现场取证 |
| [docs/踩坑记录.md](docs/踩坑记录.md) | **本项目踩过的坑合集**(13 条):成因、为什么没早发现、教训与发版清单 |
| `server/README.md`、`pvf/README.md`、`launcher/README.md`、`gmtool/README.md` | 各子项目说明 |
| [docs/release/](docs/release/) | 发布说明模板、网盘下载 `.url` 模板 |
## 十二、许可证与声明
- `server/`、`client-patch/`、`launcher/`、`gmweb/`、`loadout-tool/`、`gmtool/`:**私有 / 闭源**,请勿公开分发或倒卖
- `pvf/`:源自开源项目 pvfine,遵循 **GPL-3.0**(见 `pvf/LICENSE`),注意传染性
- 本模拟器仅供本地单机体验,禁止用于开服运营;资源禁止倒卖,请到官方微信公众号或社群下载
- 对外发布服务端时不要附带本机测试用的掉落改动;远程换装工具不要直接暴露 Admin 端口
## 十三、提交约定
1. 各子项目独立编译,无跨目录构建依赖
2. 不提交编译产物:`launcher/dist/`、`launcher/build/`、`server/build_verify/`、`__pycache__`、`*.exe`/`*.dll`、`dnf_items.json`、`*.pvf` 均已在 `.gitignore`
3. 提交信息用「范围:做了什么」的形式,例如 `启动器:游戏更新拆分为当前/历史双子页,新增关于我们页`
4. 服务端 GM 接口有变化时,同步更新 `docs/服务端开发文档.md` 的 API 契约章节
5. 改完源码记得回灌运行目录(`Z:\90server` 与桌面发布包),否则跑的还是旧文件
+3 -8
View File
@@ -13159,16 +13159,11 @@ static bool VerifyBoundMoviePresent()
// ── 自校验(self-integrity-crc)────────────────────────────────────────
//
// 构建后由 patch_selfcrc.py 把自身 .text 段的 CRC32 写进 g_selfCrcExpected。
// 运行时重新读取磁盘上的本 DLL,算 .text 段 CRC 并比对:
// 任何字节被改动(比如 patch 掉校验分支)→ CRC 不符 → 客户端无法启动。
// 占位初值必须是 0xC0DEC0DE,脚本靠它定位写入位置。
// ── 自校验(self-integrity-crc)────────────────────────────────────────
//
// 构建后由 patch_dll_crc.py 写入两个字段:
// 构建后由 tools/patch_dll_crc.py 写入两个字段:
// offset = 本结构 expected 字段在文件中的绝对偏移
// expected = 整个文件的 CRC32(计算时跳过 [offset, offset+4),避免自指)
// 运行时重新读盘、用同样规则重算并比对;任何字节被改动 → 不符 → 客户端无法启动。
// 占位初值必须是 0xC0DEC0DE,脚本靠它定位写入位置(详见 docs/客户端补丁与CRC校验.md)。
static struct SelfCrcSlot {
// volatile:这三个字段只由构建后脚本改文件字节,代码里从不写。
// 不加 volatile 时 /O2 会把 offset/expected 当常量折叠成 0,补丁写对了也读不到。
@@ -14996,7 +14991,7 @@ DWORD WINAPI InstallTransportWorkerInner(void*)
bool tclsInstalled = InstallTclsCompatibility();
LogLine("TCLS early bootstrap result=%d", tclsInstalled);
// 自校验:本 DLL 的 .text 段 CRC 必须与构建时写入的值一致。
// 自校验:本 DLL 整个文件的 CRC32 必须与构建时写入的值一致。
if (!VerifySelfIntegrity()) {
LogLine("self crc check failed: terminating client");
MessageBoxW(nullptr,
+25
View File
@@ -0,0 +1,25 @@
# client-patch/tools
编译与构建后处理脚本。完整说明见 [docs/客户端补丁与CRC校验.md](../../docs/客户端补丁与CRC校验.md)。
| 文件 | 用途 |
|------|------|
| `build_90cn.bat` | 双击入口,转调下面的 ps1 |
| `build_90cn.ps1` | MSBuild 编 `Release\|Win32` → 收集产物 → 调 `patch_dll_crc.py` 写自校验 CRC;`-SkipCrc` 可跳过(仅调编译用,别拿去发布) |
| `patch_dll_crc.py` | 补齐 / 校验 `90CN.dll` 自校验 CRC32 |
## 常用命令
```bash
# 编 DLL 并自动补 CRC
powershell -NoProfile -ExecutionPolicy Bypass -File build_90cn.ps1
# 只处理已有的 DLL
python patch_dll_crc.py info <dll> # 看节表与槽位
python patch_dll_crc.py verify <dll> # 校验当前 CRC
python patch_dll_crc.py patch <dll> # 重写 CRC(-o 可输出到副本)
```
退出码:`0` 通过 / `1` 校验失败或找不到槽位 / `2` 用法错误。
> ⚠️ 改完 DLL 除了补 CRC,还要**重编服务端**——`DNF90Server.exe` 在编译期绑定了 DLL 的 SHA-256,指纹不配套会被拒绝连接。
+7
View File
@@ -4,9 +4,13 @@
| 文档 | 内容 |
|------|------|
| [../AGENTS.md](../AGENTS.md) | **入口文件**:给 AI / 新人的一页纸——硬规则、构建命令、验证方法、代码地图、发版清单(开工前先读这份) |
| [开发文档.md](开发文档.md) | **GM 启动器 / 整合工具** 完整开发文档(环境搭建、架构、API 契约、打包部署、常见问题) |
| [服务端开发文档.md](服务端开发文档.md) | **服务端(Go)** 架构、GM 接口契约、拍卖行/背包改动、编译部署 |
| [PVF解析开发文档.md](PVF解析开发文档.md) | **PVF 解析** 套件(pvfine)、pvfexport 工具、离线名称显示集成 |
| [客户端补丁与CRC校验.md](客户端补丁与CRC校验.md) | **客户端补丁(90CN.dll)** 自校验机制、`patch_dll_crc.py` 用法、与「服务端 DLL 指纹」的区别、排查手册 |
| [启动器窗口风暴排查.md](启动器窗口风暴排查.md) | **启动器重复开窗口 / 页面空白** 的根因(打包后 `sys.executable` 指向自身)、复现手法、修复点与日志判读 |
| [踩坑记录.md](踩坑记录.md) | **踩坑合集**(13 条):每条只写现象 / 成因 / 为什么没早发现 / 教训,开头是一句话教训总表,结尾是发版检查清单 |
| [launcher-README.md](launcher-README.md) | 整合启动器 + GM 工具的原始 README |
其余子项目的 README 分散在各自目录:
@@ -19,7 +23,10 @@
## 快速上手路径
0. **任何人(含 AI)接手**:先读 [../AGENTS.md](../AGENTS.md),再回来按场景选文档。
1. **只想跑起来**:看根目录 [README](../README.md) 的「快速开始」。
2. **改 GM 工具(启动器)**:看 [开发文档.md](开发文档.md)。
3. **改服务端 GM 接口**:看 [服务端开发文档.md](服务端开发文档.md) 的「GM 接口契约」。
4. **改 PVF 解析 / 物品名**:看 [PVF解析开发文档.md](PVF解析开发文档.md)。
5. **改客户端补丁 / 重编 DLL**:看 [客户端补丁与CRC校验.md](客户端补丁与CRC校验.md)——改完 DLL 要补 CRC,并且要重编服务端。
6. **遇到怪问题先翻 [踩坑记录.md](踩坑记录.md)**:13 条坑的一句话教训和判断规则,可能已经记过了。
+31
View File
@@ -0,0 +1,31 @@
# 发布物料(docs/release)
每次发版要贴到仓库 Release 里的两样东西。体积十几个 G 的整包不适合直传,所以走「说明文档 + 网盘快捷方式」的组合。
| 文件 | 用途 |
|------|------|
| `发布说明模板.md` | Release 正文模板:本次更新、下载表格、安装步骤、说明 |
| `网盘下载.url` | Windows 网络快捷方式,双击直接打开网盘页面;作为附件上传 |
## 发版步骤
1. 复制 `发布说明模板.md` 的内容到 Release 描述,按本次改动填「本次更新」
2. 核对下载链接:模板里的下载表格已填当前百度网盘地址与提取码,换盘或重新分享时同步替换
3. 若换了链接,同步改 `网盘下载.url`,让 `URL=` 与表格里的链接**完全一致**:
```ini
[InternetShortcut]
URL=https://pan.baidu.com/s/1j31dVPF_nPKSN2QYHU-Ygg?pwd=s33r
IconIndex=0
```
> 提取码已经拼进 `?pwd=`,双击打开后直接进目录,玩家不用再手输。
4. 附件区上传 `网盘下载.url`(注意:不是把整个网盘文件传上来)
5. 检查版本号与更新日志:`launcher/server.py` 的 `CURRENT_VERSION`、发布包根目录的 `更新日志.txt`
## 注意
- 仓库里的 `网盘下载.url` 现在指向**真实分享**(百度网盘,提取码 `s33r`);重新分享会生成新链接,记得同步改 `.url` 与模板表格,避免玩家点开 404
- 传完自己双击验证一次,确认能打开网盘页面而不是 404
- 发布包里 `Client\90CN.dll` 与 `Server\runtime\bin\DNF90Server.exe` 必须配套(DLL 指纹绑定),缺一个客户端就连不上
+29
View File
@@ -0,0 +1,29 @@
# DNF90 灵宝小竹单机版 v2026.11.05
## 本次更新
- 修复 …
- 新增 …
## 下载
| 渠道 | 提取码 | 链接 |
| --- | --- | --- |
| 百度网盘 | `s33r` | [点击下载](https://pan.baidu.com/s/1j31dVPF_nPKSN2QYHU-Ygg?pwd=s33r) |
| 阿里云盘 | 无需提取码 | [点击下载](https://www.alipan.com/s/xxxx) |
> 百度链接已带 `?pwd=s33r`,点开即可进目录,无需再手输提取码。阿里云盘一行按需替换。
Windows 用户也可以直接点附件里的 `网盘下载.url`,会自动打开网盘页面。
## 安装
1. 解压到非中文路径(例如 `D:\DNF90`),路径不要带空格和特殊字符
2. 双击 `DNF90启动器.exe`,点「开始」按钮开服
3. 首次启动约需 85 秒,等日志出现「服务已就绪」再进游戏
## 说明
- 本模拟器资源禁止倒卖,请到官方微信公众号或社群下载
- 官方 QQ 群:1128511524
- 遇到问题请附上 `Server\runtime\logs\` 下的日志
+3
View File
@@ -0,0 +1,3 @@
[InternetShortcut]
URL=https://pan.baidu.com/s/1j31dVPF_nPKSN2QYHU-Ygg?pwd=s33r
IconIndex=0
+128
View File
@@ -0,0 +1,128 @@
# 启动器「窗口越开越多、里面一片空白」排查记录
> 版本:v2026.11.05 之后修复(启动器 15,945,162 字节)
> 现象只在部分电脑出现,开发机复现不了;本文记录根因、复现手法、修复点与现场取证方法。
>
> 想看「为什么会写出这种代码、为什么长期没被发现」的成因复盘,见 [踩坑记录.md](踩坑记录.md) 的 **P-001**。
## 一、现象
- 双击启动器后,屏幕上每隔一两秒冒出一个新的启动器窗口,都是黑底、没有界面内容
- 内存、CPU 一路涨,任务管理器里 `DNF90启动器.exe` 一片
- 游戏内嵌的 GM 页(`127.0.0.1:18112`)打不开 / 空白
- 开机第一次启动更容易出现;已经有一种「别的东西占着 18112」的电脑反而看不到
## 二、根因:启动器把自己当成了 python.exe
`launcher/server.py` 的 `start_gmweb()` 要拉起 GM 页服务,写法是:
```python
subprocess.Popen([sys.executable, script, "--port", str(GM_WEB_PORT)])
# ^^^^^^^^^^^^^^ 源码运行时是 python.exe,打包后是启动器自己
```
源码运行时 `sys.executable` 是 `python.exe`,`python.exe serve.py --port 18112` 正常。
用 PyInstaller 打成 **单文件** 后,`sys.executable` 变成 `DNF90启动器.exe`,于是实际执行的是:
```
DNF90启动器.exe Z:\90server\gmweb\serve.py --port 18112
```
单文件 bootloader 不认这种「拿参数当脚本」的用法,它只会**再跑一遍内嵌的 server.py**,而 server.py 又没有 `sys.argv` 分支处理,于是:
1. 新进程重新解包 16MB 的单文件包 → 绑定下一个空闲端口(19528)
2. 它也会调用 `start_gmweb()`,发现 18112 还是空的(因为 serve.py 从来没被真正执行过)
3. 于是**又拉起一个自己**
4. 每一代在拉完下一代的瞬间创建自己的窗口
链条是这样的(每代约 1.5 秒,端口从 19527 顺延到 19546):
```
exe(19527) ──spawn──> exe(19528) ──spawn──> exe(19529) ──> … 直到端口用尽
│ │ │
开窗口 开窗口 开窗口 ← 全是没有内容的新窗口
```
端口到 19546 用完后 `ThreadingHTTPServer` 绑定失败,`console=False` + 窗口程序会弹一个 traceback 对话框,所以有些机器上还会看到报错弹窗。
## 三、为什么开发机复现不了
`start_gmweb()` 的第一句是:
```python
if port_open(GM_WEB_PORT):
print("GM 页服务已在运行")
return
```
开发机上很早就有一个 `pythonw.exe serve.py`(或 `gmweb\start_gmweb.bat` 起的)占着 18112,所以这段直接 return,永远走不到那句错误的 spawn。实测把那个进程结束后,**6 秒内就堆出 3 个实例、端口涨到 19531,而 18112 始终没有监听**。
结论:只要一台机器满足「`gmweb\serve.py` 存在」+「18112 空闲」,就一定会套娃;反之会被掩盖。这就是「个别电脑」的来源。
## 四、复现与验证手法
```bash
# 1. 先确认 18112 是空的(否则会被 return 掩盖)
netstat -ano | findstr 127.0.0.1:18112
# 2. 直接跑打包后的 exe,观察端口是否一路顺延
DNF90启动器.exe &
sleep 6; netstat -ano | findstr 127.0.0.1:195
# 3. 反向验证「exe 会不会执行传进去的 py」
printf 'open(r"C:\\temp\\hit.txt","w").write("ok")' > probe.py
DNF90启动器.exe probe.py # 旧版:hit.txt 不生成,却开出一个窗口
```
修复后第 3 步会真的执行 `probe.py`(生成 `hit.txt`)并且**不开窗口**。
## 五、修复内容
| 改动 | 说明 |
|------|------|
| **多调用模式** `run_script_instead()` | `exe <xxx.py> [args]` 时用 `runpy.run_path` 直接执行该脚本,不开窗口、不提权、不占互斥量。打包后 `sys.executable` 拉起 `serve.py` 从此是合法的 |
| **单实例互斥** `acquire_single_instance()` | 命名互斥量 `Local\DNF90Launcher-5E9C1B7A`;重复启动时把已有窗口 `ShowWindow(SW_RESTORE)` + `SetForegroundWindow` 后退出,不再开第二个窗口 |
| **就绪校验 + 进程内兜底** | spawn 后最多等 6 秒确认 18112 真的在监听;没监听就改用 `importlib` 在本进程线程里跑 `serve.py` 的 `main()`,**任何情况下都不会再拉起一个新窗口** |
| **提权带参** `self_elevate()` | 原来 `ShellExecuteW(..., None, ...)` 提权时把参数丢了,重启出来的进程是「干干净净」的启动器;现在用 `list2cmdline(sys.argv[1:])` 原样带上,并且 gmweb 子进程(`DNF90_GMWEB_CHILD=1`)不再提权 |
| **WebView2 数据目录** | 显式指定 `%LOCALAPPDATA%\DNF90Launcher\WebView2` 并 `private_mode=False`。默认位置放在只读目录 / 网络盘 / 映射盘时建不起来,窗口能出但一片空白,且不报错 |
| **运行日志** `启动器日志.txt` | exe 同目录,超过 512KB 自动转 `.old.txt`;`console=False` 下 print 和未捕获异常全部落盘,现场才有据可查 |
### 关于「没内容」的两层含义
1. 套娃出来的窗口是**每代新进程**,它们连的是各自顺延的端口,页面还在加载就被下一代挤爆,观感就是黑框;
2. 真正属于「内容出不来」的是 WebView2 数据目录的问题(只读目录/网络盘),已按上表固定到 `LOCALAPPDATA`。
## 六、现场取证
让用户把 **exe 同目录的 `启动器日志.txt`** 发回来即可,正常启动长这样:
```
[2026-11-05 17:20:38] ===== 启动 pid=24412
[2026-11-05 17:20:38] exe=Z:\90server\DNF90启动器.exe
[2026-11-05 17:20:38] argv=['Z:\\90server\\DNF90启动器.exe']
[2026-11-05 17:20:38] SERVER_ROOT=Z:\90server
[2026-11-05 17:20:38] 启动器端口:19527
[2026-11-05 17:20:38] gmweb:已拉起子进程 pid=20260
[2026-11-05 17:20:39] ===== 启动 pid=20260
[2026-11-05 17:20:39] argv=['Z:\\90server\\DNF90启动器.exe', 'Z:\\90server\\gmweb\\serve.py', '--port', '18112']
[2026-11-05 17:20:39] 多调用模式:执行 Z:\90server\gmweb\serve.py ['--port', '18112']
[2026-11-05 17:20:39] gmweb:http://127.0.0.1:18112/ 就绪
[2026-11-05 17:20:41] WebView2 数据目录:C:\Users\...\AppData\Local\DNF90Launcher\WebView2
[2026-11-05 17:20:41] WebView2 运行时:135.0.3179.73
```
判读要点:
| 日志特征 | 结论 |
|----------|------|
| 出现多条 `===== 启动` 且 pid 各不相同,但**没有**「多调用模式」 | 又在套娃(旧版 exe,或 `serve.py` 参数被吞) |
| `已有启动器在运行,唤起原窗口后退出` | 单实例生效,属正常 |
| `gmweb:子进程未监听,改为进程内启动` | 子进程起不来,已自动兜底;看上一行 `子进程启动失败` 的原因 |
| `警告:注册表里没查到 WebView2 运行时` | 机器缺 Edge WebView2 Runtime,装一下即可(窗口会是空白) |
| 完全没有日志文件 | exe 所在目录不可写(如 Program Files),换目录或给写权限 |
## 七、相关坑(顺带记录)
- **exe 名字带中文**没问题,日志和 WebView2 目录都能正常处理;但 exe 放在**只读目录**会让日志与 WebView2 同时失效,表现就是「能开窗口但空白、也没有日志」。
- `_kill_webview2_children()` 用的是 `wmic`,Windows 11 24H2 起 `wmic` 默认已被移除、部分安全策略也会拦它;这段现在是 `try/except` 静默失败,不影响主流程,但别再依赖它清理残留进程。
- 启动器单文件每次启动都要解包约 16MB;因此「重复启动」的代价不只是多一个窗口,还会多解包一次。单实例互斥顺带把这件事省了。
+133
View File
@@ -0,0 +1,133 @@
# 客户端补丁自校验(CRC32)与 patch_dll_crc.py
`client-patch/tools/patch_dll_crc.py` 是 `90CN.dll` 的**自校验补齐工具**。DLL 编译出来自带一个 CRC 槽位(初值全 0),必须由这个脚本在构建后把「整个文件的 CRC32 + 槽位偏移」写进去;不写,客户端启动时自校验直接失败。
> 一句话:**改完 DLL 必须跑 patch,跑完 CRC 变了必须重编服务端**(服务端绑的是 DLL 的 SHA-256,见第六节)。
---
## 一、它解决什么问题
90CN 补丁是外挂式 DLL,谁都能拿十六进制编辑器改两个字节。加了自校验之后:
- DLL 每次启动先读**磁盘上自己**的字节,重算 CRC32,与构建时写入的值比对
- 不一致 → 弹「客户端补丁文件已被修改,无法启动」→ `TerminateProcess`
- 因此「patch 掉校验分支」这条路走不通:改分支就等于改 CRC,CRC 对不上直接启动失败
代价是每次重编 DLL 都要补一次 CRC,这就是本工具的职责。
## 二、两端机制
### DLL 侧(`client-patch/90CN.cpp` → `SelfCrcSlot` / `VerifySelfIntegrity`)
```c
static struct SelfCrcSlot {
volatile unsigned int magic; // 固定 0xC0DEC0DE,脚本靠它定位槽位
volatile unsigned int offset; // expected 字段在文件中的绝对偏移(脚本写入)
volatile unsigned int expected; // 整个文件的 CRC32(脚本写入)
} g_selfCrcSlot = { 0xC0DEC0DEu, 0u, 0u };
```
三个要点,都踩过坑:
1. **必须 `volatile`**:这三个字段只由脚本改文件字节,C++ 代码里从不写。不加 `volatile`,`/O2` 会把 `offset`/`expected` 当常量折叠成 0,脚本写对了运行时也读不到。
2. **用数据地址解析自身模块**:`GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, &g_selfCrcSlot, ...)`。用函数地址在部分加载方式下解析不到(实测 `err=126`)。
3. **校验发生在窗口出来之前**:`VerifySelfIntegrity()` 在 `InstallTclsCompatibility()` 之后、影片哈希之前调用,失败即弹框并退出。
CRC 计算严格按下面这套,和脚本必须逐字节一致:
```c
unsigned int crc = 0xFFFFFFFFu;
crc = Crc32Update(crc, image, slotOffset); // 槽位之前的全部字节
crc = Crc32Update(crc, image + slotOffset + 4, size - slotOffset - 4); // 跳过 expected 那 4 字节
crc ^= 0xFFFFFFFFu;
```
即标准的反射 CRC-32(poly `0xEDB88320`、init `0xFFFFFFFF`、final XOR `0xFFFFFFFF`),等价于 `zlib.crc32()` 的**默认**用法。
### 脚本侧(`patch_dll_crc.py`)
| 动作 | 做什么 |
|------|--------|
| 定位槽位 | 解析 PE 节表,只在 `.data` / `.rdata` 里找 `0xC0DEC0DE`,可执行节里的命中一律拒绝 |
| 写 `offset` | `offset = 槽位文件偏移 + 8`(指向 `expected` 自己) |
| 写 `expected` | 先写 `offset` 再算 CRC —— 因为 `offset` 本身也在被哈希的范围内 |
| 复核 | 写完立刻跑一次 `verify`,退出码即结果 |
**为什么不直接扫第一个 `0xC0DEC0DE`**:MSVC 会把 `magic != 0xC0DEC0DEu` 这个比较编译成 `.text` 里的立即数,于是镜像里有多处命中。早期脚本扫到机器码就往上写,运行时表现为「读槽位后立刻访问冲突」。现在按节属性过滤,并且只接受 `offset` 为 0(新编译出来)或已自指(已 patch 过)的候选。
**CRC 种子坑(曾上线一版错误实现)**:不要给 `binascii.crc32(data, 0xFFFFFFFF)` 传种子。那个参数是**已经过 final XOR** 的运行中 CRC,再传进去等于把 conditioning 应用两次,算出来是另一个变体,客户端会拒绝自己的 DLL。正确写法是 `zlib.crc32(part1)` 再用返回值续算 `part2`。
## 三、命令行用法
```bash
# 看节表布局与槽位现状(不改文件)
python patch_dll_crc.py info Client/90CN.dll
# 只校验:当前文件与槽位里存的 CRC 是否一致
python patch_dll_crc.py verify Client/90CN.dll
# 补齐/重写 CRC(原地写)
python patch_dll_crc.py patch Client/90CN.dll
# 写到副本,原文件不动(发布前比对用)
python patch_dll_crc.py patch Client/90CN.dll -o build/90CN_patched.dll
```
输出示例(当前发布版 `90CN.dll` 的真实结果):
```
slot : file 0x0009B2AC in .data offset=0x0009B2B4 expected=0x9AFDA31F
crc32 : computed=0x9AFDA31F stored=0x9AFDA31F
OK 90CN.dll: self-integrity CRC verified
```
**退出码**:`0` 成功 / `1` 校验失败或找不到槽位 / `2` 用法错误(参数或文件不存在)。
## 四、构建集成
`client-patch/tools/build_90cn.ps1`(双击走 `build_90cn.bat`)三步走:
1. MSBuild 编 `Release|Win32` 出 `90CN.dll`
2. 收集产物
3. 调 `patch_dll_crc.py patch <dll>` 写入 CRC(找不到 Python 且没加 `-SkipCrc` 就直接抛错,不会静默产出未 patch 的 DLL)
`-SkipCrc` 只用于「先编出来看看能不能过编译」,**不要**拿这种 DLL 发布。
## 五、手动改过 DLL 之后
任何对 DLL 的字节级改动(换图标、改资源、再打一次补丁)都会让 CRC 失效,按顺序来:
```bash
python patch_dll_crc.py verify Client/90CN.dll # 1. 先确认现在是否已经是坏的
python patch_dll_crc.py patch Client/90CN.dll # 2. 重写 CRC
python patch_dll_crc.py verify Client/90CN.dll # 3. 复核
```
然后**必须重编服务端**,见下一节。
## 六、和「服务端 DLL 指纹」是两回事
| 机制 | 在哪 | 校验什么 | 失败表现 |
|------|------|----------|----------|
| 自校验 CRC32 | `90CN.dll` 内部 | 自己文件字节没被改 | 启动即弹框「客户端补丁文件已被修改」并退出 |
| DLL 指纹 SHA-256 | `DNF90Server.exe`(编译期 `boundDllSHA256`) | 客户端上报的 DLL 哈希是否等于服务端编进去的那一个 | 能进登录,但连接被拒 / 掉线 |
所以**换 DLL 必须重编服务端**,否则 CRC 自己是对了,指纹对不上照样连不上。发布包里 `Client\90CN.dll` 与 `Server\runtime\bin\DNF90Server.exe` 必须成对。
## 七、排查手册
| 现象 | 原因 | 处理 |
|------|------|------|
| 启动弹「客户端补丁文件已被修改」 | CRC 不符,或槽位根本没 patch(`magic`/`offset` 检查不过) | 跑 `patch`;看日志 `self crc check: computed=... expected=...` 两值 |
| 读槽位后立刻访问冲突(AV) | 早期脚本把 `0xC0DEC0DE` 当普通字符串扫,写进了 `.text` 机器码 | 用当前脚本重编重 patch;`info` 确认槽位在 `.data` |
| `verify` 报 `offset does not point at itself` | 文件被别的工具改写,或用了错误版本的脚本 | `patch` 重写后复核 |
| `0xC0DEC0DE only found in an executable section` | 说明槽位没编进数据段(被优化掉/常量折叠) | 检查 `volatile` 是否还在,重编 |
| 每台机器都「校验失败」但本地 `verify` 通过 | 分发过程中文件被改(签壳、压缩包截断、杀软修复) | 对比分发前后 SHA-256,禁用会改二进制的「修复」 |
| 校验通过但登录掉线 | DLL 指纹与 `DNF90Server.exe` 不配套 | 用同一份 DLL 重编服务端 |
日志前缀统一是 `self crc `(`step=` 为流程、`check:` 为结果、`diag:` 为失败原因),排查时直接 grep 这个前缀。
## 八、为什么不用数字签名
自签名证书要装根、要处理时间戳与过期;WinVerifyTrust 还要依赖系统服务。CRC32 不防「有动机的攻击者」——他们可以直接改期望值——它防的是**误改与低级篡改**,对单机模拟器这个场景够用,且零依赖、构建期一次写入。
+316
View File
@@ -0,0 +1,316 @@
# 踩坑记录
> 记录本项目里**踩过、且容易再踩**的坑。每条只回答四个问题:现象是什么、为什么会踩、
> 为什么没早发现、以后怎么绕开。细节排查看对应文档,这里留的是**可复用的判断**。
>
> 新增一条的格式:`现象 → 一句话结论 → 成因 → 为什么没早发现 → 教训`。
| 编号 | 坑 | 一句话教训 |
|------|----|-----------|
| [P-001](#p-001-启动器越开越多窗口里面一片空白) | 启动器越开越多窗口,里面一片空白 | 同一行代码在「源码运行」和「单文件打包」下语义不同,`sys.executable` 是典型 |
| [P-002](#p-002-改了-dll-客户端连不上) | 改了 DLL,客户端连不上 | DLL 与服务端 exe 是一对,指纹不配套就是废的 |
| [P-003](#p-003-crc32-算出另一个变体) | 自校验 CRC 算出来永远对不上 | 别手动补算法里的 final XOR,库函数返回值就是可续算值 |
| [P-004](#p-004-写-crc-槽位把-dll-写坏) | 写 CRC 槽位把 DLL 写坏 | 扫特征值时先按节表限定范围,代码段里的同名立即数是陷阱 |
| [P-005](#p-005-竖线怎么都不显示) | 分隔竖线怎么都不显示 | CSS 特异性:写在低权重选择器上的声明会被高权重规则清掉 |
| [P-006](#p-006-样式在游戏里全乱) | 网页好看,游戏里全乱 | 内嵌 IE11,没有 flex、没有现代选择器 |
| [P-007](#p-007-改了代码却没生效) | 改了代码却没生效 | 单文件包 + 固定端口,旧进程不退就是连到旧服务 |
| [P-008](#p-008-页面永远是旧的) | 页面永远是旧的 | WebView2 会缓存,静态响应要 `no-store` + `?v=` |
| [P-009](#p-009-用户报错但什么都看不到) | 用户报错,但什么都看不到 | `console=False` 等于关掉全部输出,必须自己落盘日志 |
| [P-010](#p-010-缺图占位不生效) | 缺图时占位不生效、只显示破图 | 图片可能在脚本执行前就加载失败了,占位要兜住这个时序 |
| [P-011](#p-011-仓库和实际跑的不是一份) | 仓库改了,实际跑的还是旧的 | 部署目录只是副本,改完必须回灌仓库并 `diff -rq` |
| [P-012](#p-012-批量改文件改成功了其实没改) | 批量改文件「改成功了」其实没改 | 脚本里先 `assert` 再 `replace`,否则静默失败 |
| [P-013](#p-013-补丁包缺文件) | 补丁包发出去用不了 | 发版按清单勾,别凭记忆 |
---
## P-001 启动器越开越多窗口,里面一片空白
> 细节见 [启动器窗口风暴排查.md](启动器窗口风暴排查.md),这里记的是**为什么会踩**。
**现象**:双击 `DNF90启动器.exe`,每隔一两秒冒出一个新窗口,都是黑底、没有界面内容;
任务管理器里一片同名进程;游戏内 GM 页(`127.0.0.1:18112`)打不开。
**只在部分电脑出现,开发机复现不了。**
**一句话结论**:`subprocess.Popen([sys.executable, "serve.py", ...])` 在源码运行时是
「用解释器跑脚本」,打包成单文件后变成「启动器把自己再启动一次,把脚本路径当参数传进去」——
而 PyInstaller 的 bootloader 根本不理这些参数。
### 成因:三层叠加
1. **语义变了,代码没变。** `sys.executable` 在源码下是 `python.exe`,`--onefile` 打包后是
启动器自身。同一行代码,两种运行环境,两条完全不同的执行路径。
2. **`Popen` 只保证「进程创建成功」,不保证「脚本被执行」。** 它返回的 `Popen` 对象只能说明
fork/exec 成功;参数被 bootloader 吞掉这种失败没有任何回传,上一代永远不会知道下一代没干活。
3. **端口顺延把异常伪装成正常。** `while port_open(port): port += 1` 让每一代都能顺利绑定
19528、19529…… 每一代都「启动成功」了,于是每一代都能继续分出下一代。真正让它停下的是
`port < DEFAULT_PORT + 20` 这个**本来就有的**上限,不是为这种情况设计的保护。
链条:
```
exe(19527) ──spawn──> exe(19528) ──spawn──> exe(19529) ──> … 到端口用尽
│ │ │
开窗口 开窗口 开窗口 ← 全是空窗口
```
每一代的执行顺序都是**先 spawn 下一代,再创建自己的窗口**,所以窗口是「一个接一个」冒出来的,
而不是同时出现——这个细节让现象看起来更像「用户自己在反复双击」。
### 为什么长期没人发现
四个掩盖因素同时成立,每一个都足以让问题隐形:
| 掩盖因素 | 效果 |
|---|---|
| `if port_open(18112): return` | 开发机常年有 `pythonw serve.py` 占着 18112,永远走不到出错那句 |
| 开发机常跑着 gmweb 服务 | 于是「18112 被占用」成了常态,反而成了保护伞 |
| `console=False` | `print` 和 traceback 无处可去,现象只剩「窗口变多」 |
| 没有单实例锁 | 看起来像用户手抖多点了两下 |
换个角度,是**四个假设同时不成立**:
| 代码里的隐含假设 | 实际情况 |
|---|---|
| 打包后可执行文件仍是解释器 | 不是,是启动器自己 |
| spawn 成功 == 目的达成 | 只知道进程起来了,不知道它干了什么 |
| 同一时刻只有一个实例 | 想开几个开几个,每个都独立拉一遍 gmweb |
| 用户能描述故障 | 用户只会说「弹了一堆黑窗口」,`console=False` 下也没东西可给 |
前两个是技术假设,后两个是**可观测性缺失**——这才是它能活到用户机器上的真正原因。
### 怎么修的(不只是改那一行)
把四个假设逐个补上,缺一条都会复发:
| 补的假设 | 做法 |
|---|---|
| 打包后也能执行脚本 | 多调用模式 `run_script_instead()`:`exe <xxx.py> [args]` 用 `runpy` 直接跑脚本,不开窗、不提权 |
| spawn 成功 ≠ 目的达成 | spawn 后最多等 6 秒确认 18112 真的在监听,没监听就 `importlib` 在本进程线程里兜底 |
| 只应有一个实例 | 命名互斥量 `Local\DNF90Launcher-5E9C1B7A`,重复启动唤起已有窗口后退出 |
| 出问题要能取证 | exe 同目录 `启动器日志.txt`,pid / argv / 端口 / 子进程全记 |
### 教训(可迁移的部分)
- **凡是往 `subprocess` 里塞 `sys.executable` 的地方,打包前都要重新想一遍它到底指向谁。**
这不止 `Popen`:`multiprocessing`、`os.execv`、某些库的 worker 拉起,全都是同一个坑。
- **拉起子进程后要校验「副作用」,不是校验「返回值」。** 返回值只证明进程存在,端口在监听
才证明事情干了。
- **任何自增/顺延逻辑都会把「必然失败」变成「看起来正常的第 N 次尝试」。** 端口顺延、重试次数、
文件名加序号——同一个模式。加顺延的时候就要同时想「顺延到上限之后怎么报错」。
- **开发环境里「恰好被占用」的路径,等于没测过。** 想让某条分支真的被跑到,得先把常态占用的东西停掉。
- **`console=False` 的窗口程序必须自带日志**,否则所有异常都等于「用户说不行」。
---
## P-002 改了 DLL,客户端连不上
**现象**:换过 `Client\90CN.dll` 后,游戏能启动,一登录就被踢回,服务端日志没有明显报错。
**一句话结论**:`DNF90Server.exe` 编译期就把 DLL 的 SHA-256(`boundDllSHA256`)写死了,
DLL 一换指纹就不配套,服务端在登录阶段拒绝这个客户端。
**成因**:这是有意设计的防篡改绑定,不是 bug。麻烦在于它是**编译期常量**,不走配置。
**为什么没早发现**:单独测 DLL 改动时(比如只改 UI)不会重编服务端,本地能连上是因为当时
用的还是配套的那一对;一旦只更新了其中一半,问题才暴露。
**教训**:`90CN.dll` 和 `DNF90Server.exe` 永远同批构建、同批发。改 DLL 的流程固定为
「改代码 → 编译 → 补 CRC → 重编服务端 → 一起替换」。发版前用 `patch_dll_crc.py info`
和文件大小/哈希核对一遍,别单换一个。
---
## P-003 CRC32 算出另一个变体
**现象**:自校验永远不通过,客户端弹框退出;脚本自己 `verify` 也是不符。
**一句话结论**:给 `binascii.crc32(data, 0xFFFFFFFF)` 传种子等于把 conditioning 做了两次。
**成因**:`crc32` 的第二个参数是**已经过 final XOR 的运行中 CRC**,不是「初始值」。传
`0xFFFFFFFF` 相当于手工再补一次算法内部的翻转,算出来是另一个 variant。分段续算的正确写法
是直接用上一段的返回值:`crc = zlib.crc32(part1)` 然后 `zlib.crc32(part2, crc)`。
**为什么没早发现**:单段数据算出来的值看起来「像那么回事」(是合法 CRC32,只是变体不同),
肉眼比对不出来,只有客户端实际拒绝才暴露。
**教训**:不要「手动补」标准算法里的常量。库函数文档写「可续算」就说明它的返回值已经是可以
直接喂回去的形态。写完先拿一个已知答案的向量自测(比如 CRC32("123456789") = `0xCBF43926`)。
---
## P-004 写 CRC 槽位把 DLL 写坏
**现象**:补完 CRC 后 DLL 一加载就 AV,或者读槽位那行立刻崩。
**一句话结论**:特征值 `0xC0DEC0DE` 在 `.text` 段里也有一份(MSVC 把 `magic != 0xC0DEC0DEu`
编译成立即数),扫到第一个命中就写,把机器码改坏了。
**成因**:定位槽位用的是「全文搜 magic」,但 magic 作为**比较立即数**会出现在代码里。
`.text` 通常是只读的,写进去要么不生效,要么直接破坏指令。
**为什么没早发现**:不同编译器/优化级别下 `.text` 里那份立即数时有时无,本地构建能过,
换个编译参数就崩,看着像随机故障。
**教训**:按 PE 节表限定扫描范围,只在 `.data` / `.rdata` 这类数据节里找;写到 `expected`
字段时把槽位自身 4 字节排除掉,否则「算出来的值」和「写进去的值」互相影响,永远不自洽。
---
## P-005 竖线怎么都不显示
**现象**:关于页左右两栏之间的分隔竖线死活不出来,改 `.box-r` 的 `border-left` 没有任何反应。
**一句话结论**:`.cols .box { border: 0 }` 的权重是 (0,2,0),比 `.box-r` 的 (0,1,0) 高,把边框清掉了。
**成因**:为了去掉分组框默认边框,给 `.cols` 下的 `.box` 统一清零;单类选择器写了
`border-left`,权重不够,被覆盖。
**为什么没早发现**:Inspector 里看到的是「computed border = 0」,很容易以为是没写进去,
反复改同一条规则。
**教训**:排查「样式没生效」先看**哪条规则赢了**(DevTools 里划线的那条),而不是反复重写自己的。
修的时候用同权重选择器(`.cols .box-r`),别靠 `!important` 硬压。
---
## P-006 样式在游戏里全乱
**现象**:浏览器里排版正常,内嵌到游戏里就错位、堆叠、居中失效。
**一句话结论**:游戏内嵌的是 IE11 内核(WebView 老控件),没有 flex、没有 CSS 变量、
没有 `transform`(只有 `-ms-transform`)。
**成因**:GM 页跑在游戏客户端内嵌窗口里,UA 是 IE11。用现代布局等于没写。
**为什么没早发现**:本地调试常用 Edge/Chrome 打开,看起来一切正常。
**教训**:gmweb 的布局规则固定为 `float + box-sizing:border-box`;垂直居中用
`position:absolute; top:50%; -ms-transform:translateY(-50%)`。改完**必须在 800×720 的真实
内嵌尺寸下截图确认**,别只看浏览器。同理,JS 也要避开 IE11 不认的语法(箭头函数、
`let/const` 在部分内核上仍有问题)。
---
## P-007 改了代码却没生效
**现象**:重新打包、替换了 exe,双击后界面还是老样子、旧版本号。
**一句话结论**:单文件包固定用 19527 端口,**旧进程没退出**时新 exe 起不来新服务,
窗口连到的还是旧进程。
**成因**:`--onefile` 每次启动解包一份到临时目录,但端口是固定的;旧进程占着端口,
新进程只能顺延或失败,用户看到的窗口可能来自旧实例。
**为什么没早发现**:现象和「打包没成功」「缓存」完全一样,容易往错的方向查。
**教训**:改完先 `tasklist | findstr DNF90` 确认旧进程真的退了再验证;替换文件前先把运行中的
关掉,否则 `Copy-Item` 可能静默失败(文件被占用)。验证时看日志里的 `pid` 和「启动器端口」,
别用肉眼判断版本。
---
## P-008 页面永远是旧的
**现象**:改了 CSS/JS,重启启动器后页面样式没变。
**一句话结论**:WebView2 会缓存本地页面,静态响应必须显式禁缓存。
**成因**:内嵌浏览器按 URL 缓存,`127.0.0.1` 的静态资源同样会被缓存,重启进程不一定清。
**为什么没早发现**:清一次缓存/换个端口就「好了」,于是误判成偶发。
**教训**:静态响应统一加 `Cache-Control: no-store`;引用资源时再叠一层
`?v=YYYYMMDDx`(改一次资源就换一个字母后缀)。两层都要,一层不够。
---
## P-009 用户报错,但什么都看不到
**现象**:用户说「打不开」「闪一下就没了」,让他截图,什么都截不到。
**一句话结论**:`console=False` 的窗口程序 `sys.stdout` 是 `None`,`print` 和 traceback 全部丢失。
**成因**:PyInstaller 打成无控制台窗口的程序后,标准输出没有载体;未捕获异常也不会落盘。
**为什么没早发现**:开发时始终有控制台,报错看得一清二楚。
**教训**:窗口程序必须自带日志文件(exe 同目录、带时间戳、超限转 `.old.txt`),并且在最开头
就把 `pid`、`argv`、关键路径写进去——**这些在事后无法补录**。装好 tee 之后注意别再重复写:
自己 `print` 一遍又直接写文件一遍,日志会变成双份。
---
## P-010 缺图占位不生效
**现象**:二维码图片缺失时,本该显示「未配置」占位,实际显示的是破图图标。
**一句话结论**:`img` 的 `onerror` 绑定晚于图片加载失败,事件早就过去了。
**成因**:脚本在 `DOMContentLoaded` 之后绑定;图片在 HTML 解析阶段就请求并 404 了,
`onerror` 那时候还不存在。
**为什么没早发现**:本地图片都在,只有用户机器上缺图才复现。
**教训**:绑定后立刻补一次兜底判断——`img.complete && img.naturalWidth === 0` 就当失败处理。
「事件绑定晚于事件发生」这个模式在前端反复出现,凡是依赖 `load`/`error` 的都要补这一手。
---
## P-011 仓库和实际跑的不是一份
**现象**:仓库里明明改了,实际跑的还是旧行为;或者提交时发现少文件。
**一句话结论**:`Z:\90server` 只是**部署副本**,不是工作区;改完必须回灌仓库。
**成因**:调试时常直接在部署目录改,改完忘了同步;或者只提交了源码,漏了配置文件/静态资源。
**为什么没早发现**:两边看起来「都能跑」,直到下次从仓库重新构建才发现差异。
**教训**:改完用 `diff -rq <仓库目录> <部署目录>` 核对,**排除故意的差异**(比如 `serve.py`
只在仓库里);提交前 `git status` 看有没有该进的 ini / 静态资源。曾漏过一次
`90CN_web.ini` 和整个 `gmweb` 目录,教训是「配置文件和代码一样是交付物」。
---
## P-012 批量改文件「改成功了」其实没改
**现象**:脚本打印「patched」,重新打包后行为没变。
**一句话结论**:`str.replace()` 找不到目标时**不报错**,静默返回原串。
**成因**:待匹配文本里空格、引号、换行只要差一个字符,替换就整体失效,而脚本照样往下走、
照样打印成功。
**为什么没早发现**:输出一片「ok」,没人会去 diff 结果。
**教训**:每处替换前 `assert old in content`,替换后再 `assert new in content`;脚本结束打印
实际改动条数(`n` 为 0 就该警惕)。改完务必看一眼 `git diff --stat`,行数对不上就是没改成。
---
## P-013 补丁包缺文件
**现象**:补丁包发出去,用户装上后回退成 1040 宽窗口、GM 页打不开、或者登录被踢。
**一句话结论**:发版是**清单活**,不是「把改过的拖进去」。
**成因**:一次发版踩了三个:DLL 与 exe 指纹不配套、漏了 `gmweb` 整个目录、漏了
`Client\90CN_web.ini`(缺它就回退默认宽度和 `close_action=0`)。
**为什么没早发现**:本地测试用的是完整目录,只验证「能跑」,没验证「只装补丁包能不能跑」。
**教训**:发版固定清单——① `Client\90CN.dll` 与 `Server\runtime\bin\DNF90Server.exe` 成对且
指纹一致;② `gmweb\`(含 `qr\` 四张图);③ `Client\90CN_web.ini`;④ 启动器 exe 与
`更新日志.txt`;⑤ 版本号三处一致(`CURRENT_VERSION`、客户端显示、更新日志)。装完在
**干净目录**里实跑一遍,再对外发。
---
## 从这些坑里提炼的通用规则
1. **打包会改变语义。** 任何依赖 `sys.executable`、`__file__`、工作目录、相对路径的代码,
打包前后都要重新推一遍。
2. **校验副作用,不校验返回值。** 进程起来了 ≠ 事情干了;文件写了 ≠ 内容对了。
3. **顺延/重试会把确定性失败伪装成正常。** 加顺延就同时加「上限之后怎么报错」。
4. **开发环境的常态占用会掩护 bug。** 想测某条分支,先把它前面的短路条件拆掉。
5. **没有输出的程序等于没有可维护性。** 窗口程序一律自带日志,开头就写 pid/argv/路径。
6. **「改完没生效」先怀疑三类**:旧进程没退、缓存没清、文件没同步到实际运行的位置。
7. **成对的东西不能分开动**:DLL 与 exe、`.url` 与发布说明、仓库与部署目录。
8. **发版是清单活。** 清单写下来,别凭记忆。
## 相关文档
| 文档 | 内容 |
|------|------|
| [启动器窗口风暴排查.md](启动器窗口风暴排查.md) | P-001 的完整排查过程、复现步骤、日志判读表 |
| [客户端补丁与CRC校验.md](客户端补丁与CRC校验.md) | P-002 / P-003 / P-004 的机制细节与工具用法 |
| [开发文档.md](开发文档.md) | 启动器与 GM 工具的开发、打包、部署 |
| [服务端开发文档.md](服务端开发文档.md) | 服务端 GM 接口与 DLL 指纹绑定 |
+4
View File
@@ -67,6 +67,9 @@
panes[j].className = 'pane' + (panes[j].id === 'pane-' + name ? ' on' : '');
}
try { if (location.hash !== '#' + name) location.hash = '#' + name; } catch (e) { }
if (name === 'loadout' && typeof window.LoadoutTab === 'function') {
window.LoadoutTab();
}
}
function bindTabs() {
@@ -514,6 +517,7 @@
bindQr('qr-alipay');
bindQr('qr-wechat');
bindQr('qr-gzh');
bindQr('qr-qq');
renderPicked();
forceIME();
+54 -2
View File
@@ -4,13 +4,14 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8">
<title>GM 控制台</title>
<link rel="stylesheet" href="/style.css?v=20261105B">
<link rel="stylesheet" href="/style.css?v=20261105C">
</head>
<body>
<div id="topbar">
<div id="tabs">
<a class="tab on" data-tab="home" href="javascript:void(0)">状态</a>
<a class="tab" data-tab="char" href="javascript:void(0)">角色</a>
<a class="tab" data-tab="loadout" href="javascript:void(0)">一键换装</a>
<a class="tab" data-tab="item" href="javascript:void(0)">发物品</a>
<a class="tab" data-tab="about" href="javascript:void(0)">关于</a>
</div>
@@ -129,6 +130,43 @@
<div class="hint">强化 0-31、增幅等级 0-31、锻造 0-8。增幅填了就直接覆盖强化(红字装不能同时白字强化),非装备行忽略这些值。</div>
</div>
<div class="pane" id="pane-loadout">
<div class="row">
<label class="lb">角色</label>
<select id="lo-char" class="inp sm"></select>
<button id="lo-refresh" class="btn">刷新</button>
<span id="lo-msg" class="dim"></span>
</div>
<div class="lo-wrap">
<div class="lo-col lo-left">
<div class="sec">换装页</div>
<div class="row">
<select id="lo-page" class="inp sm"></select>
<button id="lo-page-add" class="btn sm">新增</button>
<button id="lo-page-ren" class="btn sm">重命名</button>
<button id="lo-page-del" class="btn sm">删除</button>
</div>
<div class="sec">方案</div>
<div id="lo-schemes" class="list fixed"></div>
<div class="row">
<button id="lo-save" class="btn pri">保存当前装备为方案</button>
</div>
<div class="hint">保存后会给当前角色创建一套换装方案。</div>
<div id="lo-hotkeys" class="hint"></div>
</div>
<div class="lo-col lo-right">
<div class="sec">方案详情</div>
<div id="lo-detail" class="cur">未选择方案</div>
<div id="lo-slots" class="lo-slots"></div>
<div class="row">
<button id="lo-apply" class="btn pri">应用方案</button>
<span id="lo-cooldown" class="dim"></span>
</div>
<div class="hint">每次换装完成后进入 <b style="color:#e8c979">10 秒冷却</b>;冷却期间点击按钮或按全局热键都会被服务端拒绝。</div>
</div>
</div>
</div>
<div class="pane" id="pane-about">
<div class="about-inner">
<div class="about-card">
@@ -158,6 +196,19 @@
</div>
</div>
<div class="box box-m">
<div class="box-hd">官方QQ群</div>
<div class="box-bd">
<div class="qr-card">
<div class="qr-title">QQ群</div>
<div class="qr-box"><img id="qr-qq" src="/qr/qq.png" alt="官方QQ群二维码">
<div class="qr-miss">二维码未配置<div class="qr-path">gmweb\qr\qq.png</div></div>
</div>
</div>
<div class="box-note">群号 1128511524</div>
</div>
</div>
<div class="box box-r">
<div class="box-hd">团队公众号</div>
<div class="box-bd">
@@ -179,6 +230,7 @@
<div id="log"></div>
<script src="/app.js?v=20261105B"></script>
<script src="/app.js?v=20261105C"></script>
<script src="/loadout.js?v=20261105A"></script>
</body>
</html>
+386
View File
@@ -0,0 +1,386 @@
/* 一键换装(游戏内 GM 窗口)
* IE11 兼容:ES5 + XMLHttpRequest,不使用箭头函数/模板字符串/flex。
* 数据:/api/loadout/config(本地 config.json),角色/装备/应用走 serve.py 代理到 18111。
*/
(function () {
'use strict';
var S = {
cfg: null,
chars: [],
charName: '',
page: 0,
scheme: -1,
equipment: [],
cooldown: {},
timer: null
};
function keyText(code) {
if (code >= 112 && code <= 123) { return 'F' + (code - 111); }
if (code >= 96 && code <= 105) { return 'Num' + (code - 96); }
if (code >= 48 && code <= 57) { return String.fromCharCode(code); }
if (code >= 65 && code <= 90) { return String.fromCharCode(code); }
if (code === 13) { return 'Enter'; }
if (code === 32) { return 'Space'; }
return 'VK' + code;
}
function modText(mods) {
var out = [];
if (mods & 2) { out.push('Ctrl'); }
if (mods & 1) { out.push('Alt'); }
if (mods & 4) { out.push('Shift'); }
return out;
}
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function log(text, cls) {
var box = $('log');
if (!box) { return; }
var d = document.createElement('div');
if (cls) { d.className = cls; }
var t = new Date();
var p = function (n) { return (n < 10 ? '0' : '') + n; };
d.innerHTML = '[' + p(t.getHours()) + ':' + p(t.getMinutes()) + ':' + p(t.getSeconds()) + '] ' + esc(text);
box.appendChild(d);
box.scrollTop = box.scrollHeight;
while (box.childNodes.length > 120) { box.removeChild(box.firstChild); }
}
function api(path, method, body, cb) {
var xhr = new XMLHttpRequest();
xhr.open(method || 'GET', '/api' + path, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) { return; }
if (xhr.status === 0) {
log('连不上本地代理(gmweb/serve.py 未启动?)', 'err');
if (cb) { cb(null); }
return;
}
var data = null;
try { data = JSON.parse(xhr.responseText); } catch (e) { data = null; }
if (!data) {
log('响应不是 JSON:' + String(xhr.responseText || '').substring(0, 120), 'err');
if (cb) { cb(null); }
return;
}
if (data.ok === false || data.success === false) {
log('失败:' + (data.error || data.message || '未知错误'), 'err');
if (cb) { cb(null); }
return;
}
if (cb) { cb(data); }
};
xhr.send(body ? JSON.stringify(body) : null);
}
function msg(text) {
var el = $('lo-msg');
if (el) { el.innerHTML = esc(text || ''); }
}
function currentPage() {
if (!S.cfg || !S.cfg.Pages || !S.cfg.Pages.length) { return null; }
if (S.page < 0 || S.page >= S.cfg.Pages.length) { S.page = 0; }
return S.cfg.Pages[S.page];
}
function normalizeConfig(data) {
if (!data || !data.Pages || !data.Pages.length) {
data = { ServerUrl: 'http://127.0.0.1:18111', AdminToken: '', ActivePage: 0,
Pages: [{ Name: '默认页', Schemes: [] }] };
}
if (!data.Pages[0].Schemes) { data.Pages[0].Schemes = []; }
S.page = parseInt(data.ActivePage, 10) || 0;
if (S.page < 0 || S.page >= data.Pages.length) { S.page = 0; }
return data;
}
function loadHotkeys() {
api('/loadout/hotkeys', 'GET', null, function (d) {
var el = $('lo-hotkeys');
if (!el) { return; }
if (!d || !d.data || !(d.data.schemes || []).length) {
el.innerHTML = '全局热键:当前页没有配置快捷键';
return;
}
var parts = [];
var schemes = d.data.schemes;
for (var i = 0; i < schemes.length; i++) {
var sc = schemes[i];
var mods = modText(sc.modifiers || 0);
mods.push(keyText(sc.keycode));
parts.push(esc(sc.name || '') + ' → ' + esc(mods.join('+')));
}
el.innerHTML = '全局热键(当前页):' + parts.join(' ');
});
}
function loadConfig(cb) {
api('/loadout/config', 'GET', null, function (d) {
if (!d) { msg('读取换装配置失败'); if (cb) { cb(false); } return; }
S.cfg = normalizeConfig(d.data || {});
renderPages();
renderSchemes();
loadHotkeys();
if (cb) { cb(true); }
});
}
function saveConfig(cb) {
if (!S.cfg) { return; }
S.cfg.ActivePage = S.page;
api('/loadout/config', 'POST', S.cfg, function (d) {
if (d) { msg('换装配置已保存'); log('换装配置已保存', 'ok'); loadHotkeys(); }
if (cb) { cb(!!d); }
});
}
function renderPages() {
var sel = $('lo-page');
if (!sel) { return; }
var pages = (S.cfg && S.cfg.Pages) || [];
var h = [];
for (var i = 0; i < pages.length; i++) {
h.push('<option value="' + i + '">' + esc(pages[i].Name || ('页 ' + (i + 1))) + '</option>');
}
sel.innerHTML = h.join('');
sel.value = String(S.page);
}
function renderSchemes() {
var box = $('lo-schemes');
if (!box) { return; }
var page = currentPage();
var schemes = (page && page.Schemes) || [];
if (!schemes.length) {
box.innerHTML = '<div class="lo-empty">当前页还没有方案</div>';
S.scheme = -1;
renderDetail();
return;
}
var h = [];
for (var i = 0; i < schemes.length; i++) {
var sc = schemes[i];
h.push('<div class="li' + (i === S.scheme ? ' on' : '') + '" data-i="' + i + '">' +
'<span class="nm">' + esc(sc.Name || ('方案 ' + (i + 1))) + '</span>' +
'<span class="lo-scheme-meta">' + esc(sc.CharacterName || '') +
' · ' + ((sc.Slots || []).length) + ' 件</span></div>');
}
box.innerHTML = h.join('');
var lis = box.getElementsByClassName('li');
for (var k = 0; k < lis.length; k++) {
lis[k].onclick = function () {
S.scheme = parseInt(this.getAttribute('data-i'), 10);
renderSchemes();
};
}
if (S.scheme < 0 || S.scheme >= schemes.length) { S.scheme = 0; }
var active = box.getElementsByClassName('li')[S.scheme];
if (active) { active.className = 'li on'; }
renderDetail();
}
function renderDetail() {
var page = currentPage();
var schemes = (page && page.Schemes) || [];
var detail = $('lo-detail');
var slotsBox = $('lo-slots');
if (!detail || !slotsBox) { return; }
var sc = schemes[S.scheme];
if (!sc) {
detail.innerHTML = '未选择方案';
slotsBox.innerHTML = '';
return;
}
detail.innerHTML = '<b>' + esc(sc.Name || '') + '</b>' +
'<span class="lo-scheme-meta">' + esc(sc.CharacterName || '未绑定角色') + '</span>';
var slots = sc.Slots || [];
if (!slots.length) {
slotsBox.innerHTML = '<div class="lo-empty">该方案没有保存装备</div>';
return;
}
var h = [];
for (var i = 0; i < slots.length; i++) {
h.push('<div class="lo-slot">部位 <b>' + esc(slots[i].slot) + '</b>' +
'<span class="id">ID ' + esc(slots[i].item_id) + '</span></div>');
}
slotsBox.innerHTML = h.join('');
}
function loadChars() {
api('/loadout/characters', 'GET', null, function (d) {
if (!d) { msg('角色读取失败,确认服务端已启动'); return; }
S.chars = d.data || [];
var sel = $('lo-char');
if (!sel) { return; }
var old = S.charName;
var h = ['<option value="">选择角色</option>'];
for (var i = 0; i < S.chars.length; i++) {
var c = S.chars[i];
h.push('<option value="' + esc(c.name) + '">' + esc(c.name) +
'(Lv.' + esc(c.level) + (c.online ? ' · 在线' : '') + ')</option>');
}
sel.innerHTML = h.join('');
if (old) { sel.value = old; }
if (!sel.value && S.chars.length === 1) { sel.value = S.chars[0].name; }
S.charName = sel.value || '';
msg('角色 ' + S.chars.length + ' 个');
});
}
function fetchEquipment(characterName, cb) {
api('/local/dnf-gm/loadout/equipment?character_id=' + encodeURIComponent(characterName), 'GET', null, function (d) {
if (!d) { cb(null); return; }
cb((d.data && d.data.slots) || []);
});
}
function saveCurrent() {
if (!S.charName) { msg('请先选择角色'); return; }
if (!S.cfg) { return; }
msg('正在读取当前装备…');
fetchEquipment(S.charName, function (slots) {
if (!slots) { msg('读取当前装备失败'); return; }
if (!slots.length) { msg('当前没有可保存的装备'); return; }
var name = window.prompt('方案名称', S.charName + ' 当前装备');
if (!name) { msg('已取消'); return; }
var page = currentPage();
if (!page) { return; }
if (!page.Schemes) { page.Schemes = []; }
page.Schemes.push({
Name: String(name),
CharacterName: S.charName,
Modifiers: 0,
KeyCode: 0,
Slots: slots
});
S.scheme = page.Schemes.length - 1;
saveConfig(function (ok) {
if (ok) { renderSchemes(); msg('已保存方案:' + name); }
});
});
}
function cooldownRemaining(name) {
var until = S.cooldown[name] || 0;
return Math.max(0, Math.ceil((until - Date.now()) / 1000));
}
function tickCooldown() {
var el = $('lo-cooldown');
var page = currentPage();
var schemes = (page && page.Schemes) || [];
var sc = schemes[S.scheme];
if (!el) { return; }
if (!sc) { el.innerHTML = ''; return; }
var remain = cooldownRemaining(sc.CharacterName || '');
el.innerHTML = remain > 0 ? ('换装冷却 ' + remain + ' 秒') : '';
}
function applyScheme() {
var page = currentPage();
var schemes = (page && page.Schemes) || [];
var sc = schemes[S.scheme];
if (!sc) { msg('请先选择方案'); return; }
if (!sc.CharacterName || !(sc.Slots || []).length) { msg('方案没有角色或装备'); return; }
var remain = cooldownRemaining(sc.CharacterName);
if (remain > 0) { msg('换装冷却中,剩余 ' + remain + ' 秒'); return; }
msg('正在应用方案…');
api('/local/dnf-gm/loadout/apply', 'POST', { character_id: sc.CharacterName, slots: sc.Slots }, function (d) {
if (!d) { msg('应用失败'); return; }
var r = d.data || {};
var applied = (r.applied || []).length;
var skipped = (r.skipped || []).length;
var sec = parseInt(r.cooldown_seconds, 10) || 0;
if (sec > 0) {
S.cooldown[sc.CharacterName] = Date.now() + sec * 1000;
}
msg('应用完成:成功 ' + applied + ' 件,跳过 ' + skipped + ' 件' +
(sec > 0 ? (',冷却 ' + sec + ' 秒') : ''));
log('换装 ' + sc.Name + ':成功 ' + applied + ',跳过 ' + skipped, 'ok');
tickCooldown();
});
}
function addPage() {
var name = window.prompt('新换装页名称', '新页面');
if (!name || !S.cfg) { return; }
S.cfg.Pages.push({ Name: String(name), Schemes: [] });
S.page = S.cfg.Pages.length - 1;
S.scheme = -1;
saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } });
}
function renamePage() {
var page = currentPage();
if (!page || !S.cfg) { return; }
var name = window.prompt('重命名换装页', page.Name || '');
if (!name) { return; }
page.Name = String(name);
saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } });
}
function deletePage() {
if (!S.cfg || S.cfg.Pages.length <= 1) { msg('至少保留一个换装页'); return; }
var page = currentPage();
if (!page) { return; }
if (!window.confirm('删除换装页「' + page.Name + '」及其方案?')) { return; }
S.cfg.Pages.splice(S.page, 1);
S.page = Math.max(0, S.page - 1);
S.scheme = -1;
saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } });
}
function bind() {
var sel = $('lo-page');
if (sel) {
sel.onchange = function () {
S.page = parseInt(this.value, 10) || 0;
S.scheme = -1;
renderSchemes();
loadHotkeys();
};
}
var ch = $('lo-char');
if (ch) { ch.onchange = function () { S.charName = this.value; }; }
var b;
if ((b = $('lo-refresh'))) { b.onclick = function () { loadChars(); }; }
if ((b = $('lo-save'))) { b.onclick = saveCurrent; }
if ((b = $('lo-apply'))) { b.onclick = applyScheme; }
if ((b = $('lo-page-add'))) { b.onclick = addPage; }
if ((b = $('lo-page-ren'))) { b.onclick = renamePage; }
if ((b = $('lo-page-del'))) { b.onclick = deletePage; }
}
function loadTab() {
msg('正在读取换装配置…');
loadConfig(function (ok) {
if (!ok) { return; }
loadChars();
if (!S.timer) { S.timer = setInterval(tickCooldown, 500); }
});
}
window.LoadoutTab = loadTab;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bind);
} else {
bind();
}
// 通过 #loadout 直接打开时,app.js 可能已经在 loadout.js 之前切过页
if ((location.hash || '').replace('#', '') === 'loadout') {
setTimeout(loadTab, 0);
}
})();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+145 -3
View File
@@ -115,6 +115,125 @@ def proxy(method: str, path: str, body: bytes | None) -> tuple[int, bytes, str]:
return 502, msg, "application/json; charset=utf-8"
# --------------------------------------------------------------------- 换装配置
def _loadout_config_path() -> Path:
"""LoadoutTool/config.json,优先部署根目录,其次运行目录。"""
roots = [HERE.parent]
env = os.environ.get("DNF90_SERVER_ROOT")
if env:
roots.insert(0, Path(env))
for root in roots:
p = root / "LoadoutTool" / "config.json"
if p.is_file():
return p
return roots[0] / "LoadoutTool" / "config.json"
def _default_loadout_config() -> dict:
return {
"ServerUrl": "http://127.0.0.1:18111",
"AdminToken": "",
"Pages": [{"Name": "默认页", "Schemes": []}],
"ActivePage": 0,
}
def _read_loadout_config() -> dict:
p = _loadout_config_path()
try:
if p.is_file():
data = json.loads(p.read_text(encoding="utf-8-sig"))
if isinstance(data, dict):
if not isinstance(data.get("Pages"), list) or not data["Pages"]:
data["Pages"] = [{"Name": "默认页", "Schemes": []}]
data["ActivePage"] = max(0, min(int(data.get("ActivePage") or 0), len(data["Pages"]) - 1))
# AdminToken 只留在本地文件,网页端永远拿不到。
data["AdminToken"] = ""
return data
except (OSError, ValueError, TypeError):
pass
return _default_loadout_config()
def _write_loadout_config(data: dict) -> tuple[int, bytes, str]:
if not isinstance(data, dict):
return 400, b'{"ok":false,"error":"config must be an object"}', "application/json"
pages = data.get("Pages")
if not isinstance(pages, list) or not pages:
return 400, b'{"ok":false,"error":"Pages must be a non-empty list"}', "application/json"
p = _loadout_config_path()
try:
p.parent.mkdir(parents=True, exist_ok=True)
# 网页提交的配置里没有 token;保留磁盘上原来的 token。
existing = {}
if p.is_file():
try:
existing = json.loads(p.read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
existing = {}
data = dict(data)
data["AdminToken"] = existing.get("AdminToken", "") if isinstance(existing, dict) else ""
tmp = p.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(p)
except OSError as exc:
return 500, json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False).encode(), "application/json"
return 200, b'{"ok":true}', "application/json"
def _loadout_hotkeys() -> dict:
"""Return the active page's configured global hotkeys for the UI hint."""
cfg = _read_loadout_config()
pages = cfg.get("Pages") or []
try:
page = pages[int(cfg.get("ActivePage") or 0)]
except (IndexError, TypeError, ValueError):
page = pages[0] if pages else {}
out = []
for scheme in (page.get("Schemes") or []):
keycode = int(scheme.get("KeyCode") or 0)
if keycode <= 0:
continue
out.append({
"name": scheme.get("Name") or "",
"character": scheme.get("CharacterName") or "",
"keycode": keycode,
"modifiers": int(scheme.get("Modifiers") or 0),
})
return {"page": page.get("Name") or "", "schemes": out}
def _loadout_characters() -> tuple[int, bytes, str]:
"""Character list for the loadout page.
The admin endpoint without a selector is bound to the logged-in account and
can be empty when the game client is still at the login screen. The
launcher's local DB API already exposes the full local character list, so
reuse it here and keep the loadout page usable before entering the game.
"""
try:
req = request.Request("http://127.0.0.1:19527/api/characters",
headers={"Accept": "application/json"})
with OPENER.open(req, timeout=8) as resp:
data = json.loads(resp.read().decode("utf-8"))
chars = data.get("data") if isinstance(data, dict) else None
out = []
for c in chars or []:
if not isinstance(c, dict):
continue
out.append({
"character_id": str(c.get("character_id") or ""),
"name": c.get("name") or "",
"level": c.get("level") or 0,
"online": bool(c.get("online")),
})
return 200, json.dumps({"ok": True, "data": out}, ensure_ascii=False).encode(), "application/json; charset=utf-8"
except (OSError, ValueError) as exc:
return 502, json.dumps({"ok": False, "error": "读取角色失败:%s" % exc},
ensure_ascii=False).encode(), "application/json; charset=utf-8"
# --------------------------------------------------------------------- HTTP
class Handler(BaseHTTPRequestHandler):
@@ -146,18 +265,41 @@ class Handler(BaseHTTPRequestHandler):
self._send(200, target.read_bytes(), MIME.get(target.suffix.lower(), "application/octet-stream"))
def do_GET(self) -> None:
route = self.path.split("?", 1)[0]
if route == "/api/loadout/config":
payload = json.dumps({"ok": True, "data": _read_loadout_config()}, ensure_ascii=False).encode()
self._send(200, payload, "application/json; charset=utf-8")
return
if route == "/api/loadout/characters":
code, payload, ctype = _loadout_characters()
self._send(code, payload, ctype)
return
if route == "/api/loadout/hotkeys":
payload = json.dumps({"ok": True, "data": _loadout_hotkeys()}, ensure_ascii=False).encode()
self._send(200, payload, "application/json; charset=utf-8")
return
if self.path.startswith("/api/"):
code, data, ctype = proxy("GET", self.path[4:], None)
self._send(code, data, ctype)
return
self._static(self.path.split("?")[0])
self._static(route)
def do_POST(self) -> None:
route = self.path.split("?", 1)[0]
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b"{}"
if route == "/api/loadout/config":
try:
data = json.loads(body.decode("utf-8"))
except ValueError:
self._send(400, b'{"ok":false,"error":"invalid json"}', "application/json")
return
code, payload, ctype = _write_loadout_config(data)
self._send(code, payload, ctype)
return
if not self.path.startswith("/api/"):
self._send(404, b"not found", "text/plain")
return
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b"{}"
code, data, ctype = proxy("POST", self.path[4:], body)
self._send(code, data, ctype)
+18 -4
View File
@@ -112,13 +112,26 @@ select.inp { padding: 3px 2px; }
#log .tip { color: #e8c979; }
.hint { color: #666e80; font-size: 11px; margin-top: 6px; }
/* 一键换装:IE11 兼容,float 两栏 */
.lo-wrap { margin-top: 4px; overflow: hidden; }
.lo-col { float: left; box-sizing: border-box; }
.lo-left { width: 42%; padding-right: 10px; }
.lo-right { width: 58%; border-left: 1px solid #2b3040; padding-left: 10px; min-height: 260px; }
.lo-slots { margin-top: 6px; border: 1px solid #2b3040; background: #101319; max-height: 166px; overflow: auto; }
.lo-slot { padding: 3px 8px; border-bottom: 1px solid #1c212b; font-size: 11px; color: #9aa2b4; }
.lo-slot b { color: #dfe3ea; font-weight: normal; }
.lo-slot .id { color: #e8c979; margin-left: 8px; }
.lo-scheme-meta { color: #7c8496; font-size: 11px; margin-left: 6px; }
.lo-empty { padding: 10px; color: #666e80; text-align: center; }
#lo-cooldown { margin-left: 8px; }
/* 背包页:刚发进去那一行的「新」标记 */
.li .new {
font-style: normal; font-size: 11px; padding: 0 4px; margin-left: 4px;
background: #e8c979; color: #1a1e27; border-radius: 2px;
}
/* 关于页:整体是一列居中内容(介绍卡片 + 组合面板),宽度固定 534 */
/* 关于页:整体是一列居中内容(介绍卡片 + 组合面板),宽度固定 692 */
/* 关于页整块在可视区里垂直居中:绝对定位 + translateY(IE11 支持 transform) */
#pane-about.on { display: block; position: relative; height: 100%; box-sizing: border-box; }
#pane-about .about-inner {
@@ -126,20 +139,21 @@ select.inp { padding: 3px 2px; }
-ms-transform: translateY(-50%); transform: translateY(-50%);
}
.about-card {
width: 534px; margin: 0 auto; padding: 10px 12px; box-sizing: border-box;
width: 712px; margin: 0 auto; padding: 10px 12px; box-sizing: border-box;
background: #1a1e27; border: 1px solid #2b3040;
}
.about-name { color: #e8c979; font-size: 14px; letter-spacing: 1px; }
.about-line { color: #8d94a6; margin-top: 4px; }
.box { margin-top: 12px; border: 1px solid #2b3040; background: #171b23; }
/* 两个分组框合成一个整体面板:外框统一,中间一条竖线分隔 */
/* 三个分组框合成一个整体面板:外框统一,中间竖线分隔 */
.cols {
width: 534px; margin: 12px auto 0;
width: 712px; margin: 12px auto 0;
border: 1px solid #2b3040; background: #171b23; overflow: hidden;
}
.cols .box { margin-top: 0; min-height: 264px; float: left; border: 0; background: transparent; box-sizing: border-box; }
.cols .box-l { width: 353px; }
.cols .box-m { width: 178px; border-left: 1px solid #3f4759; background: #14171d; }
.cols .box-r { width: 179px; border-left: 1px solid #3f4759; background: #14171d; }
.box-hd {
height: 26px; line-height: 26px; padding: 0 10px;
+3 -1
View File
@@ -1,7 +1,9 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
datas = [('index.html', '.'), ('style.css', '.'), ('shell.css', '.'), ('app.js', '.'), ('shell.js', '.'), ('sponsor.png', '.'), ('db.py', '.'), ('native.py', '.'), ('launcher.ico', '.')]
datas = [('index.html', '.'), ('style.css', '.'), ('shell.css', '.'), ('app.js', '.'), ('shell.js', '.'), ('sponsor.png', '.'), ('db.py', '.'), ('native.py', '.'), ('launcher.ico', '.'),
('qr/alipay.png', 'qr'), ('qr/wechat.png', 'qr'), ('qr/gzh.jpg', 'qr'),
('qr/qq.png', 'qr')]
binaries = []
hiddenimports = ['clr_loader', 'pythonnet']
tmp_ret = collect_all('webview')
+10
View File
@@ -18,6 +18,16 @@
| `index.html` / `style.css` / `app.js` | 双视图前端 |
| `DNF90启动器.spec` | PyInstaller 打包配置 |
| `sponsor.png` / `sponsor_qr.png` | 赞助二维码 |
| `qr/` | 关于页二维码(alipay / wechat / gzh / qq,可被 exe 同目录的同名文件覆盖) |
| `启动器日志.txt` | 运行日志(运行时生成,exe 同目录,超过 512KB 转 `.old.txt`) |
## 运行时约定
- **单实例**:命名互斥量 `Local\DNF90Launcher-5E9C1B7A`,重复启动只会把已有窗口提到前台
- **多调用模式**:`DNF90启动器.exe <xxx.py> [args]` 会用 `runpy` 直接执行该脚本。打包后 `sys.executable` 就是 exe 自己,`start_gmweb()` 正是靠这条路径拉起 `gmweb/serve.py` 的
- **提权**:UAC 自提权重启会带上原始参数;`DNF90_GMWEB_CHILD=1` 的子进程不再提权
- **WebView2 数据目录**:固定在 `%LOCALAPPDATA%\DNF90Launcher\WebView2`,避免 exe 在只读目录/网络盘时窗口空白
- 排障与现场取证见 [`docs/启动器窗口风暴排查.md`](../docs/启动器窗口风暴排查.md)
## 打包
+62 -1
View File
@@ -30,6 +30,7 @@
<button class="shell-nav-item" data-shell-page="gm"><span class="nav-ico">⚙</span>GM工具</button>
<button class="shell-nav-item" data-shell-page="loadout"><span class="nav-ico">🔄</span>一键换装</button>
<button class="shell-nav-item" data-shell-page="gamesettings"><span class="nav-ico">🎮</span>游戏设置</button>
<button class="shell-nav-item" data-shell-page="about"><span class="nav-ico">💝</span>关于我们</button>
<div class="shell-nav-gap"></div>
</nav>
<div class="shell-win-controls">
@@ -127,7 +128,65 @@
</section>
<section class="shell-page" id="page-changelog">
<div class="page-pad"><h2>游戏更新</h2><p class="muted">从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。</p><div id="changelogBody"></div></div>
<div class="page-pad">
<div class="page-heading"><div><h2>游戏更新</h2><p class="muted">优先读取仓库(Lingbao/90CNServer)的发布记录,并用本地「更新日志.txt」补齐仓库还没发布的版本;断网时只显示本地记录。</p></div><button class="shell-btn" id="updRefresh">刷新</button></div>
<div class="st-tabs" id="updTabs">
<button class="st-tab active" data-upd="latest">当前更新</button>
<button class="st-tab" data-upd="history">历史更新 <span class="upd-count" id="updHistoryCount"></span></button>
</div>
<div id="updLatestPane"><div class="upd-body" id="updLatest"><div class="empty-box">正在获取更新日志…</div></div></div>
<div id="updHistoryPane" style="display:none"><div class="upd-body" id="updHistory"></div></div>
</div>
</section>
<section class="shell-page" id="page-about">
<div class="page-pad about-pad">
<div class="about-hero">
<h2>地下城与勇士 · 九零轮回单机</h2>
<p class="about-line">汉服电脑宅 · 灵宝小竹模拟器</p>
<p class="about-line">由灵宝仙尊联合淡竹茹开发,由汉服电脑宅团队开发维护</p>
<p class="about-line">本模拟器资源禁止倒卖,请到官方微信公众号或社群下载</p>
</div>
<div class="about-panel">
<div class="about-col about-col-l">
<div class="about-col-hd">赞助我们</div>
<div class="about-col-bd">
<figure class="about-qr" data-qr="支付宝">
<img src="/qr/alipay.png" alt="支付宝收款码">
<figcaption>支付宝</figcaption>
</figure>
<figure class="about-qr" data-qr="微信">
<img src="/qr/wechat.png" alt="微信收款码">
<figcaption>微信</figcaption>
</figure>
</div>
<div class="about-col-note">金额随意,感谢支持;扫码后备注游戏名,方便记录。</div>
</div>
<div class="about-col about-col-m">
<div class="about-col-hd">官方QQ群</div>
<div class="about-col-bd">
<figure class="about-qr" data-qr="官方QQ群">
<img src="/qr/qq.png" alt="官方QQ群二维码">
<figcaption>QQ群</figcaption>
</figure>
</div>
<div class="about-col-note">群号 1128511524</div>
</div>
<div class="about-col about-col-r">
<div class="about-col-hd">团队公众号</div>
<div class="about-col-bd">
<figure class="about-qr" data-qr="团队公众号">
<img src="/qr/gzh.jpg" alt="团队公众号二维码">
<figcaption>扫码关注</figcaption>
</figure>
</div>
<div class="about-col-note">更新公告 · 开服时间 · 玩法说明</div>
</div>
</div>
<p class="about-tip">点击任意二维码可放大查看;把同名图片放进启动器目录下的 qr 文件夹即可替换。</p>
</div>
</section>
<section class="shell-page" id="page-gm">
@@ -165,6 +224,8 @@
<div class="win-resize sw" data-edge="bottomleft"></div><div class="win-resize se" data-edge="bottomright"></div>
<div class="modal-mask hidden" id="sponsorModal"><div class="modal"><div class="modal-head"><span>赞助我们</span><button class="modal-close" id="btnCloseSponsor">×</button></div><div class="modal-body"><p class="sponsor-tip">感谢支持 DNF90 灵宝小竹单机版!</p><div class="qrcode-box" id="qrcodeBox"></div></div></div></div>
<div class="qr-lightbox hidden" id="qrLightbox"><img id="qrLightboxImg" src="" alt=""><div class="qr-lightbox-tip" id="qrLightboxTip"></div></div>
<div class="modal-mask hidden" id="accountsModal"><div class="modal" style="width:520px"><div class="modal-head"><span>账号管理</span><button class="modal-close" id="btnCloseAccounts" onclick="document.getElementById('accountsModal').classList.add('hidden')">×</button></div><div class="modal-body" style="text-align:left"><div id="accountsList" style="margin-bottom:16px"></div><div class="accounts-add"><div class="accounts-add-title">添加新账号</div><div class="gm-form-row"><div class="gm-field"><label>用户名</label><input class="gm-input" id="newAccUser"></div><div class="gm-field"><label>密码</label><input class="gm-input" id="newAccPass" type="password"></div></div><button class="gm-btn gm-btn-primary" id="btnAddAccount">+ 添加账号</button></div></div></div></div>
<script src="/app.js"></script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+422 -28
View File
@@ -16,10 +16,12 @@ import ctypes
import json
import os
import queue
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import uuid
@@ -145,6 +147,17 @@ SPONSOR_IMG = os.path.join(BASE_DIR, "sponsor.png")
if not os.path.exists(SPONSOR_IMG):
SPONSOR_IMG = os.path.join(RES_DIR, "sponsor.png")
# 关于我们页的三张二维码:优先 EXE 同目录 qr\(可自行替换),其次打包内资源
QR_FILES = {
"alipay.png": "image/png",
"wechat.png": "image/png",
"gzh.jpg": "image/jpeg",
"qq.png": "image/png",
}
# 断网时更新日志的兜底来源(EXE 同目录 / 服务端根目录的 更新日志.txt)
LOCAL_CHANGELOG_NAME = "更新日志.txt"
# 数据库与实例配置候选路径(基于运行目录解析出的 SERVER_ROOT)
def _instance_candidates():
out = []
@@ -180,18 +193,197 @@ def port_open(port, host="127.0.0.1"):
s.close()
# ------------------------------------------------------------------ 日志
LOG_FILE_NAME = "启动器日志.txt"
LOG_MAX_BYTES = 512 * 1024
_log_stream = None
class _TeeStream:
"""把 print / 异常输出同时写进日志文件。
打包成 console=False 的窗口程序后 sys.stdout 是 None,出问题时用户
什么也看不到;这里留一份日志,个别电脑上的怪问题才有据可查。
"""
def __init__(self, *streams):
self._streams = [x for x in streams if x is not None]
def write(self, data):
for x in self._streams:
try:
x.write(data)
x.flush()
except Exception:
pass
return len(data)
def flush(self):
for x in self._streams:
try:
x.flush()
except Exception:
pass
def isatty(self):
return False
def fileno(self):
# 没有真实文件描述符;第三方库探测到 OSError 会自行降级
raise OSError("no fileno")
@property
def encoding(self):
return "utf-8"
def _logf(msg):
"""带时间戳写一行日志(源码运行时同时打到控制台)。
名字不能叫 log —— 文件后半段 GM 流程日志也叫 log,会把这个覆盖掉。
"""
line = "[%s] %s" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg)
st = _log_stream
if st is not None:
# 已经装过 tee:直接写文件,print 会经 tee 再写一遍变成双份
try:
st.write(line + "\n")
st.flush()
except Exception:
pass
return
try:
print(line)
except Exception:
pass
def _install_file_log():
"""冻结运行时把 stdout/stderr 落盘到 exe 同目录,方便回收现场。"""
global _log_stream
if not getattr(sys, "frozen", False):
return
path = os.path.join(BASE_DIR, LOG_FILE_NAME)
try:
if os.path.exists(path) and os.path.getsize(path) > LOG_MAX_BYTES:
try:
os.replace(path, path[:-4] + ".old.txt")
except OSError:
pass
st = open(path, "a", encoding="utf-8", errors="replace")
except Exception:
return
_log_stream = st
sys.stdout = _TeeStream(sys.stdout, st)
sys.stderr = _TeeStream(sys.stderr, st)
def _hook(etype, value, tb):
import traceback
_logf("未捕获异常:\n" + "".join(traceback.format_exception(etype, value, tb)))
sys.excepthook = _hook
_logf("===== 启动 pid=%s" % os.getpid())
_logf("exe=%s" % sys.executable)
_logf("argv=%r" % (sys.argv,))
_logf("SERVER_ROOT=%s" % SERVER_ROOT)
# ------------------------------------------------------------------ 单实例
INSTANCE_MUTEX = "Local\\DNF90Launcher-5E9C1B7A"
WINDOW_TITLE = "DNF90 灵宝小竹单机版"
_mutex_handle = None
def acquire_single_instance():
"""同一会话只允许一个启动器。
没有这道闸,双击几次就开出几个窗口;更糟的是每个实例都会去拉 gmweb,
端口被抢着顺延,看起来就像「窗口越开越多」。
"""
global _mutex_handle
if os.name != "nt":
return True
try:
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.CreateMutexW.argtypes = [ctypes.c_void_p, ctypes.c_bool, ctypes.c_wchar_p]
k32.CreateMutexW.restype = ctypes.c_void_p
handle = k32.CreateMutexW(None, False, INSTANCE_MUTEX)
if not handle:
return True # 创建失败不阻断启动
if ctypes.get_last_error() == 183: # ERROR_ALREADY_EXISTS
k32.CloseHandle(handle)
return False
_mutex_handle = handle
return True
except Exception:
return True
def focus_existing_window(wait_seconds=6.0):
"""把已经在跑的那个窗口提到前台(新实例的窗口可能还在解包)。"""
try:
u32 = ctypes.WinDLL("user32", use_last_error=True)
u32.FindWindowW.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p]
u32.FindWindowW.restype = ctypes.c_void_p
deadline = time.time() + wait_seconds
while time.time() < deadline:
hwnd = u32.FindWindowW(None, WINDOW_TITLE)
if hwnd:
u32.ShowWindow(ctypes.c_void_p(hwnd), 9) # SW_RESTORE
u32.SetForegroundWindow(ctypes.c_void_p(hwnd))
return True
time.sleep(0.3)
except Exception:
pass
return False
def run_script_instead():
"""多调用模式:本 exe 带 <xxx.py> 参数启动时,直接执行那个脚本。
打包成单文件后 sys.executable 就是启动器自身,拉起 gmweb/serve.py
只能走这条路 —— 旧版没做这件事,结果 `exe serve.py` 又开了一个启动器,
新启动器又去拉 serve.py …… 一路开出十几个空窗口。
"""
if len(sys.argv) < 2:
return False
target = sys.argv[1]
if not target.lower().endswith(".py"):
return False
path = target if os.path.isabs(target) else os.path.abspath(target)
if not os.path.exists(path):
return False
sys.argv = [path] + sys.argv[2:]
sys.path.insert(0, os.path.dirname(path))
_logf("多调用模式:执行 %s %r" % (path, sys.argv[1:]))
try:
import runpy
runpy.run_path(path, run_name="__main__")
except SystemExit:
pass
except BaseException as exc: # noqa: BLE001
_logf("多调用模式执行失败:%r" % exc)
return True
def start_gmweb():
"""拉起本地 GM 页服务(127.0.0.1:18112),供游戏内嵌网页窗口显示。
页面与代理脚本在 <90server>/gmweb/,它把 /api/* 转发到 admin 口并注入 token,
这样 IE 内核不用处理跨域,页面里也不会出现明文 token。失败只提示,不阻断启动。
这样 IE 内核不用处理跨域,页面里也不会出现明文 token。
子进程用 sys.executable 启动:源码运行时是 python.exe,打包后则是启动器
自身,靠 run_script_instead() 的多调用模式去执行 serve.py。子进程起来后
必须确认端口真的在监听——没监听就退回进程内启动,绝不再拉起一个新窗口。
"""
script = os.path.join(SERVER_ROOT, "gmweb", "serve.py")
if not os.path.exists(script):
_logf("gmweb:未找到 %s,跳过" % script)
return
if port_open(GM_WEB_PORT):
print("GM 页服务已在运行:http://127.0.0.1:%d/" % GM_WEB_PORT)
_logf("gmweb:%d 已在监听,跳过启动" % GM_WEB_PORT)
return
try:
kwargs = {"cwd": os.path.dirname(script),
"stdout": subprocess.DEVNULL,
@@ -199,10 +391,42 @@ def start_gmweb():
if os.name == "nt":
# CREATE_NO_WINDOW:不要给用户弹一个黑框
kwargs["creationflags"] = 0x08000000
subprocess.Popen([sys.executable, script, "--port", str(GM_WEB_PORT)], **kwargs)
print("GM 页服务:http://127.0.0.1:%d/" % GM_WEB_PORT)
env = os.environ.copy()
env["DNF90_GMWEB_CHILD"] = "1" # 标记:这是执行 serve.py 的子进程
kwargs["env"] = env
proc = subprocess.Popen(
[sys.executable, script, "--port", str(GM_WEB_PORT)], **kwargs)
_logf("gmweb:已拉起子进程 pid=%s" % proc.pid)
except Exception as exc: # noqa: BLE001 - 启动器不能因此退出
print("[提示] GM 页服务启动失败:%s" % exc)
_logf("gmweb:子进程启动失败:%r" % exc)
for _ in range(30): # 最多等 6 秒(本机被拒是立刻返回的)
if port_open(GM_WEB_PORT):
_logf("gmweb:http://127.0.0.1:%d/ 就绪" % GM_WEB_PORT)
return
time.sleep(0.2)
_logf("gmweb:子进程未监听,改为进程内启动")
start_gmweb_inproc(script)
def start_gmweb_inproc(script):
"""兜底:在本进程里起一条线程跑 serve.py 的 main()(不新增窗口)。"""
def worker():
try:
import importlib.util
spec = importlib.util.spec_from_file_location("dnf90_gmweb_serve", script)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
_logf("gmweb:进程内启动,脚本已加载")
mod.main(["--port", str(GM_WEB_PORT)])
except SystemExit:
pass
except BaseException as exc: # noqa: BLE001
_logf("gmweb:进程内启动失败:%r" % exc)
threading.Thread(target=worker, daemon=True, name="gmweb-inproc").start()
def _read_json_file(path, default):
@@ -373,6 +597,93 @@ def _release_summary(rel):
}
def _local_changelog():
"""断网时的兜底:解析本地 更新日志.txt,按日期切成若干条(最新在前)。
文件长这样(日期行之间是正文):
2026.11.05 更新:
1.xxx
2.xxx
"""
path = ""
for d in (BASE_DIR, SERVER_ROOT):
if not d:
continue
p = os.path.join(d, LOCAL_CHANGELOG_NAME)
if os.path.exists(p):
path = p
break
if not path:
return []
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
lines = f.read().splitlines()
except Exception:
return []
items = []
cur = None
for ln in lines:
s = ln.strip()
m = re.match(r"^(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})", s)
if m:
if cur:
items.append(cur)
date = "%s.%s.%s" % (m.group(1), m.group(2).zfill(2), m.group(3).zfill(2))
cur = {
"tag": date,
"name": date + " 更新",
"body": "",
"published_at": "",
"prerelease": False,
"html_url": UPDATE_REPO_WEB,
"assets": [],
}
continue
if cur is not None and s:
cur["body"] = (cur["body"] + "\n" + s).strip()
if cur:
items.append(cur)
items.reverse() # 文件里是旧→新,接口要新→旧
return items
def _changelog_date_key(rel):
"""从 tag / name / 发布时间里抽 20261105 这样的排序键;取不到返回空串。"""
s = " ".join([rel.get("tag") or "", rel.get("name") or "", rel.get("published_at") or ""])
m = re.search(r"(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})", s)
if not m:
return ""
return "%s%02d%02d" % (m.group(1), int(m.group(2)), int(m.group(3)))
def _merge_changelog(remote, local):
"""仓库发布记录 + 本地更新日志合并:按日期去重(仓库优先,内容更全),新的在前。
仓库没跟上发版时,本地 更新日志.txt 里的新条目会自动出现在「当前更新」。
"""
out, seen = [], set()
for rel in remote or []:
k = _changelog_date_key(rel)
if k:
if k in seen:
continue
seen.add(k)
out.append(rel)
for rel in local or []:
k = _changelog_date_key(rel)
if k:
if k in seen:
continue
seen.add(k)
out.append(rel)
dated = [r for r in out if _changelog_date_key(r)]
undated = [r for r in out if not _changelog_date_key(r)]
dated.sort(key=_changelog_date_key, reverse=True)
return dated + undated
def flow_start_game(account_username=None):
"""一键开始游戏;account_username 为空时使用默认账号。"""
log("== 开始游戏 ==")
@@ -590,6 +901,8 @@ class Handler(BaseHTTPRequestHandler):
self._send_file(SPONSOR_IMG, "image/png")
else:
self._json({"error": "赞助二维码尚未提供"}, 404)
elif path.startswith("/qr/"):
self._send_qr(path[len("/qr/"):])
# 启动器接口
elif path == "/api/status":
self._api_status()
@@ -792,19 +1105,39 @@ class Handler(BaseHTTPRequestHandler):
})
def _api_update_changelog(self, qs):
"""获取所有发布的更新日志。"""
"""获取所有发布的更新日志;连不上仓库时退回本地 更新日志.txt。"""
force = qs.get("force", ["0"])[0] == "1"
releases, err = _fetch_releases(force=force)
if err:
self._json({"ok": False, "error": err, "releases": []})
remote = [] if err else [_release_summary(r) for r in (releases or [])]
local = _local_changelog()
merged = _merge_changelog(remote, local)
if not merged:
self._json({"ok": False, "error": err or "没有任何更新记录", "releases": []})
return
self._json({
"ok": True,
"current": CURRENT_VERSION,
"releases": [_release_summary(r) for r in (releases or [])],
"releases": merged,
# local = 完全没连上仓库;merged = 仓库记录 + 本地补齐
"source": "remote" if remote else "local",
"local_count": len(local),
"warning": err,
"web_url": UPDATE_REPO_WEB,
})
def _send_qr(self, name):
"""关于我们页的二维码图片:EXE 同目录 qr\\ 优先,其次打包内资源。"""
ctype = QR_FILES.get(name)
if not ctype:
self._json({"error": "not found"}, 404)
return
for d in (os.path.join(BASE_DIR, "qr"), os.path.join(RES_DIR, "qr")):
p = os.path.join(d, name)
if os.path.exists(p):
self._send_file(p, ctype)
return
self._json({"error": "二维码未配置"}, 404)
def _api_logs(self):
logs = []
while True:
@@ -2117,32 +2450,49 @@ def _apply_loadout_scheme(scheme):
# ------------------------------------------------------------------ 自提权
def self_elevate():
"""非管理员时通过 UAC 自提权重启自身。"""
"""非管理员时通过 UAC 自提权重启自身。
注意两点:
1. gmweb 子进程(serve.py)不需要管理员,直接跳过,避免又弹一次 UAC;
2. 提权重启必须把原始参数带上,否则子进程会丢掉 `serve.py` 这类参数,
变成一个「干干净净」的新启动器窗口。
"""
if os.environ.get("DNF90_GMWEB_CHILD") == "1":
return
if is_admin() or os.environ.get("DNF90_NO_ELEVATE") == "1":
return
exe = sys.executable
if getattr(sys, "frozen", False):
exe = sys.executable
try:
rc = ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, None, None, 1)
if rc > 32:
sys.exit(0)
except Exception:
pass
params = subprocess.list2cmdline(sys.argv[1:])
else:
exe = sys.executable
args = '"%s" "%s"' % (exe, os.path.abspath(__file__))
try:
rc = ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, args, None, 1)
if rc > 32:
sys.exit(0)
except Exception:
pass
params = subprocess.list2cmdline([os.path.abspath(__file__)] + sys.argv[1:])
_logf("尝试 UAC 自提权:%s %s" % (exe, params))
try:
rc = ctypes.windll.shell32.ShellExecuteW(
None, "runas", exe, params or None, None, 1)
if rc > 32:
sys.exit(0)
_logf("自提权被拒或失败:rc=%s(继续以当前权限运行)" % rc)
except Exception as exc: # noqa: BLE001
_logf("自提权异常:%r" % exc)
# ------------------------------------------------------------------ 主入口
def main():
_install_file_log()
# 子进程模式:exe 带着某个 .py 启动 → 只执行脚本,不开窗口、不提权、不占互斥量
if run_script_instead():
return
self_elevate()
# 单实例:第二个实例把已有窗口唤到前台后退出,避免窗口越开越多
if not acquire_single_instance():
_logf("已有启动器在运行,唤起原窗口后退出")
focus_existing_window()
return
if not os.path.exists(CONTROL):
print("[警告] 未找到服务端控制器,请将启动器放在 90server 根目录旁。")
print(" 期望路径:%s" % CONTROL)
@@ -2156,6 +2506,7 @@ def main():
port = DEFAULT_PORT
while port_open(port) and port < DEFAULT_PORT + 20:
port += 1
_logf("启动器端口:%d" % port)
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
url = "http://127.0.0.1:%d/" % port
@@ -2322,6 +2673,27 @@ class WindowApi:
print("[窗口] 移动失败:%s" % e)
def webview2_installed():
"""注册表里查 WebView2 运行时版本(查不到就返回 False)。"""
try:
import winreg
sub = (r"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\\"
r"{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}")
for root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
for path in (sub, sub.replace("WOW6432Node\\", "")):
try:
with winreg.OpenKey(root, path) as k:
ver, _ = winreg.QueryValueEx(k, "pv")
if ver and ver != "0.0.0.0":
_logf("WebView2 运行时:%s" % ver)
return True
except OSError:
continue
except Exception:
pass
return False
def show_window(url):
"""用 pywebview 内嵌窗口显示界面(独立原生窗口,而非浏览器)。
@@ -2349,7 +2721,7 @@ def show_window(url):
# 主窗口:启动器(无边框,屏幕居中)
window = webview.create_window(
"DNF90 灵宝小竹单机版",
WINDOW_TITLE,
url,
width=1204,
height=717,
@@ -2362,9 +2734,31 @@ def show_window(url):
js_api=launcher_api,
)
webview.start()
# WebView2 的用户数据目录必须可写。exe 放在只读目录、网络盘或某些
# 映射盘下时,默认位置建不起来 → 窗口能出来但里面一片空白,
# 而且不会有任何报错。统一放到 LOCALAPPDATA 下最稳。
storage = None
try:
storage = os.path.join(
os.environ.get("LOCALAPPDATA") or tempfile.gettempdir(),
"DNF90Launcher", "WebView2")
os.makedirs(storage, exist_ok=True)
except Exception as exc: # noqa: BLE001
_logf("WebView2 数据目录创建失败:%r" % exc)
storage = None
_logf("WebView2 数据目录:%s" % storage)
if not webview2_installed():
_logf("警告:注册表里没查到 WebView2 运行时,页面可能空白;"
"装一下 Edge WebView2 Runtime 即可")
try:
webview.start(private_mode=False, storage_path=storage)
except TypeError:
# 老版本 pywebview 不认识这两个参数,退回默认调用
webview.start()
except Exception as e:
print("[警告] 内嵌窗口启动失败,降级为浏览器:%s" % e)
_logf("[警告] 内嵌窗口启动失败,降级为浏览器:%r" % e)
_fallback_browser(url)
# 内嵌窗口失败时保持服务运行,避免立即退出
try:
+52
View File
@@ -393,3 +393,55 @@ body.gm-fullscreen .win-resize{display:none}
/* 简易 GM 控制台运行时,完整 GM 工具入口置灰不可点 */
.shell-nav-item.nav-disabled{opacity:.38;cursor:not-allowed;filter:grayscale(1)}
.shell-nav-item.nav-disabled:hover{background:transparent;color:inherit}
/* ================= 游戏更新:当前 / 历史 两个子页 ================= */
.upd-body{display:flex;flex-direction:column;gap:12px;margin-top:14px}
.upd-count{color:#8d91a8;font-size:12px;margin-left:2px}
.upd-source{margin-bottom:10px;padding:8px 12px;font-size:12px;color:#ffc96b;
background:rgba(255,201,77,.08);border:1px solid rgba(255,201,77,.28);border-radius:8px}
.changelog-item{border:1px solid rgba(255,255,255,.09);background:rgba(20,20,36,.62);
border-radius:10px;padding:16px 18px}
.changelog-item.featured{border-color:rgba(124,92,255,.5);
background:linear-gradient(160deg,rgba(124,92,255,.16),rgba(20,20,36,.7));
box-shadow:0 6px 22px rgba(124,92,255,.15)}
.changelog-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px}
.changelog-tag{font-family:Consolas,monospace;font-size:12px;color:#c9beff;background:rgba(124,92,255,.16);
padding:2px 8px;border-radius:5px;border:1px solid rgba(124,92,255,.32)}
.changelog-name{font-size:14px;font-weight:600;color:#e2e4f2}
.changelog-time{font-size:11px;color:#6d7188;margin-left:auto}
.changelog-body{font-size:13px;color:#b8bcd0;line-height:1.9;word-break:break-word}
.changelog-assets{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.changelog-asset{font-size:11px;color:#9fe6c4;text-decoration:none;border:1px solid rgba(255,255,255,.12);
padding:3px 10px;border-radius:6px;background:rgba(255,255,255,.04)}
.changelog-asset:hover{background:rgba(255,255,255,.1)}
.changelog-link{display:inline-block;margin-top:12px;font-size:11px;color:#8d91a8;text-decoration:none}
.changelog-link:hover{color:#c9beff}
/* ================= 关于我们 ================= */
.about-pad{max-width:880px;margin:0 auto;padding-top:12px}
.about-hero{padding:14px 20px;border:1px solid rgba(255,255,255,.09);border-radius:12px;
background:linear-gradient(160deg,rgba(124,92,255,.18),rgba(20,20,36,.6))}
.about-hero h2{margin:0 0 10px;font-size:21px;color:#f2f3f8;letter-spacing:.5px}
.about-line{margin:3px 0;color:#b8bcd0;font-size:13px;line-height:1.9}
.about-panel{display:flex;margin-top:12px;border:1px solid rgba(255,255,255,.09);border-radius:12px;
background:rgba(20,20,36,.6);overflow:hidden}
.about-col{padding:16px 20px}
.about-col-l{flex:1 1 auto}
.about-col-r{width:224px;flex:none;border-left:1px solid rgba(255,255,255,.09);background:rgba(0,0,0,.2)}
.about-col-m{width:184px;flex:none;border-left:1px solid rgba(255,255,255,.09)}
.about-col-hd{color:var(--gold);font-size:14px;letter-spacing:1px;margin-bottom:12px}
.about-col-bd{display:flex;gap:16px;justify-content:center}
.about-qr{margin:0;cursor:zoom-in}
.about-qr img{display:block;width:136px;height:136px;background:#fff;border-radius:8px;padding:4px;
transition:transform .15s,box-shadow .15s}
.about-qr:hover img{transform:translateY(-2px);box-shadow:0 6px 18px rgba(124,92,255,.35)}
.about-qr figcaption{margin-top:7px;text-align:center;font-size:12px;color:#9a9eb5}
.about-col-note{margin-top:12px;font-size:11px;color:#6d7188;line-height:1.8}
.about-tip{margin-top:10px;font-size:11px;color:#5d6178}
/* 二维码放大 */
.qr-lightbox{position:fixed;inset:0;z-index:300;background:rgba(5,5,12,.85);
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;cursor:zoom-out}
.qr-lightbox img{width:min(340px,56vh);height:auto;background:#fff;padding:10px;border-radius:12px;
box-shadow:0 12px 40px rgba(0,0,0,.55)}
.qr-lightbox-tip{color:#b8bcd0;font-size:12px}
+82 -30
View File
@@ -1,13 +1,13 @@
/* 90轮回启动器 · 单窗口页面与工具整合 */
(function () {
const $ = (id) => document.getElementById(id);
const pages = ['start','changelog','gm','loadout','gamesettings'];
const pages = ['start','changelog','gm','loadout','gamesettings','about'];
let shellGmInit = false;
let loConfig = null, loPage = 0, loScheme = -1;
let loCooldownUntil = 0, loCooldownCharacter = '', loCooldownTimer = null;
let hotkeyCapture = false;
const crumb = {start:'开始 / 本地服务端',changelog:'游戏更新',gm:'GM工具',loadout:'工具 / 一键换装',gamesettings:'工具 / 游戏设置'};
const crumb = {start:'开始 / 本地服务端',changelog:'游戏更新',gm:'GM工具',loadout:'工具 / 一键换装',gamesettings:'工具 / 游戏设置',about:'关于我们'};
function shellGo(page) {
if (!pages.includes(page)) page = 'start';
@@ -258,43 +258,60 @@
}
}
// 更新日志页面:拉取所有 Release 的 changelog
async function loadChangelog() {
const box = $('changelogBody');
if (!box) return;
box.innerHTML = '<div class="empty-box">正在获取更新日志…</div>';
// 更新日志:当前更新 = 最新一条;历史更新 = 其余。优先 Gitea,断网退回本地 更新日志.txt
function updDate(rel) {
return rel.published_at ? rel.published_at.replace('T', ' ').slice(0, 16) : '';
}
function updCard(rel, featured) {
const badge = rel.prerelease ? '<span class="gm-tag gm-tag-gray">预发布</span>' : '';
const body = (rel.body || '').replace(/\r?\n/g, '<br>');
const assets = (rel.assets || []).map(a =>
`<a class="changelog-asset" href="${escapeHtml(a.download_url)}" target="_blank">⬇ ${escapeHtml(a.name)} (${fmt(a.size)} B)</a>`
).join('');
const when = updDate(rel);
return `<div class="changelog-item${featured ? ' featured' : ''}">
<div class="changelog-head">
<span class="changelog-tag">${escapeHtml(rel.tag || rel.name || '')}</span>
<span class="changelog-name">${escapeHtml(rel.name || '')}</span>
${badge}
<span class="changelog-time">${escapeHtml(when)}</span>
</div>
<div class="changelog-body">${body || '<span class="muted">(这次没有填写更新说明)</span>'}</div>
${assets ? '<div class="changelog-assets">' + assets + '</div>' : ''}
${featured ? `<a class="changelog-link" href="${escapeHtml(rel.html_url)}" target="_blank">在浏览器中查看 ↗</a>` : ''}
</div>`;
}
async function loadChangelog(force) {
const latestBox = $('updLatest'), historyBox = $('updHistory');
if (!latestBox || !historyBox) return;
latestBox.innerHTML = '<div class="empty-box">正在获取更新日志…</div>';
historyBox.innerHTML = '';
try {
const r = await api('/api/update/changelog');
const r = await api('/api/update/changelog' + (force ? '?force=1' : ''));
if (!r.ok) {
box.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(r.error || '未知错误') + '</div>';
latestBox.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(r.error || '未知错误') + '</div>';
if ($('updHistoryCount')) $('updHistoryCount').textContent = '';
return;
}
const rels = r.releases || [];
if (!rels.length) {
box.innerHTML = '<div class="empty-box">暂无发布记录。<br>在 Gitea 仓库「发布 Release」并填写更新说明后,这里会自动显示。</div>';
latestBox.innerHTML = '<div class="empty-box">暂无更新记录。<br>在仓库发布 Release,或把「更新日志.txt」放在启动器目录,这里就会显示。</div>';
if ($('updHistoryCount')) $('updHistoryCount').textContent = '';
return;
}
box.innerHTML = rels.map(rel => {
const badge = rel.prerelease ? '<span class="gm-tag gm-tag-gray">预发布</span>' : '';
const body = (rel.body || '').replace(/\r?\n/g, '<br>');
const assets = (rel.assets || []).map(a =>
`<a class="changelog-asset" href="${escapeHtml(a.download_url)}" target="_blank">⬇ ${escapeHtml(a.name)} (${fmt(a.size)} B)</a>`
).join('');
const when = rel.published_at ? rel.published_at.replace('T', ' ').slice(0, 16) : '';
return `<div class="changelog-item">
<div class="changelog-head">
<span class="changelog-tag">${escapeHtml(rel.tag || rel.name || '')}</span>
<span class="changelog-name">${escapeHtml(rel.name || '')}</span>
${badge}
<span class="changelog-time">${escapeHtml(when)}</span>
</div>
<div class="changelog-body">${body}</div>
${assets ? '<div class="changelog-assets">' + assets + '</div>' : ''}
<a class="changelog-link" href="${escapeHtml(rel.html_url)}" target="_blank">在浏览器中查看 ↗</a>
</div>`;
}).join('');
const source = r.source === 'local'
? '<div class="upd-source">未连接更新仓库,以下内容读取自本地「更新日志.txt」。</div>'
: '';
latestBox.innerHTML = source + updCard(rels[0], true);
const rest = rels.slice(1);
historyBox.innerHTML = rest.length
? rest.map(rel => updCard(rel, false)).join('')
: '<div class="empty-box">还没有更早的更新记录。</div>';
if ($('updHistoryCount')) $('updHistoryCount').textContent = rest.length ? '(' + rest.length + ')' : '';
} catch (e) {
box.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(e && e.message ? e.message : e) + '</div>';
latestBox.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(e && e.message ? e.message : e) + '</div>';
}
}
@@ -745,6 +762,41 @@
});
});
// ---------------- 游戏更新:当前 / 历史 两个子页 ----------------
(function bindUpdTabs() {
const tabs = document.querySelectorAll('#updTabs .st-tab');
tabs.forEach(btn => {
btn.addEventListener('click', () => {
const which = btn.dataset.upd;
tabs.forEach(b => b.classList.toggle('active', b === btn));
$('updLatestPane').style.display = which === 'latest' ? '' : 'none';
$('updHistoryPane').style.display = which === 'history' ? '' : 'none';
});
});
const refresh = $('updRefresh');
if (refresh) refresh.addEventListener('click', () => loadChangelog(true));
})();
// ---------------- 关于我们:二维码放大 ----------------
(function bindQrLightbox() {
const box = $('qrLightbox'), img = $('qrLightboxImg'), tip = $('qrLightboxTip');
if (!box || !img) return;
document.querySelectorAll('#page-about .about-qr').forEach(fig => {
fig.addEventListener('click', () => {
const src = fig.querySelector('img');
if (!src) return;
img.src = src.getAttribute('src');
img.alt = src.getAttribute('alt') || '';
tip.textContent = (fig.dataset.qr || '') + ' · 点击任意处关闭';
box.classList.remove('hidden');
});
});
box.addEventListener('click', () => box.classList.add('hidden'));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') box.classList.add('hidden');
});
})();
// 兼容旧 GM 页面里的返回
window.shellGo = shellGo;
loadStart();