Compare commits
12
Commits
v2026.11.05
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc546031b0 | ||
|
|
3b240ec176 | ||
|
|
adcc5b0f3a | ||
|
|
c6587d88ee | ||
|
|
da502b3524 | ||
|
|
9bdaf28683 | ||
|
|
904f8a45d0 | ||
|
|
607d705908 | ||
|
|
65169f5e81 | ||
|
|
fb3ec9f777 | ||
|
|
cc6beb0a71 | ||
|
|
6a4b7e58d3 |
No files matched your search
@@ -53,3 +53,8 @@ launcher/dnf_items.json
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 启动器运行日志(exe 同目录)
|
||||
launcher/启动器日志.txt
|
||||
启动器日志.txt
|
||||
启动器日志.old.txt
|
||||
@@ -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 约束)
|
||||
@@ -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)
|
||||
@@ -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` 与桌面发布包),否则跑的还是旧文件
|
||||
@@ -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,
|
||||
|
||||
@@ -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,指纹不配套会被拒绝连接。
|
||||
@@ -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 条坑的一句话教训和判断规则,可能已经记过了。
|
||||
@@ -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 指纹绑定),缺一个客户端就连不上
|
||||
@@ -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\` 下的日志
|
||||
@@ -0,0 +1,3 @@
|
||||
[InternetShortcut]
|
||||
URL=https://pan.baidu.com/s/1j31dVPF_nPKSN2QYHU-Ygg?pwd=s33r
|
||||
IconIndex=0
|
||||
@@ -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;因此「重复启动」的代价不只是多一个窗口,还会多解包一次。单实例互斥顺带把这件事省了。
|
||||
@@ -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
@@ -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 指纹绑定 |
|
||||
@@ -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
@@ -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>
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
+145
-3
@@ -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
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user