Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b240ec176 | ||
|
|
adcc5b0f3a | ||
|
|
c6587d88ee | ||
|
|
da502b3524 | ||
|
|
9bdaf28683 | ||
|
|
904f8a45d0 | ||
|
|
607d705908 | ||
|
|
65169f5e81 | ||
|
|
fb3ec9f777 | ||
|
|
cc6beb0a71 | ||
|
|
6a4b7e58d3 | ||
|
|
1dd3775510 | ||
|
|
040f6fbd93 | ||
|
|
ee3e8d0ced | ||
|
|
06558415da | ||
|
|
7f41f9797a | ||
|
|
0c7ef6d645 | ||
|
|
32469e4341 | ||
|
|
0a6baf20e9 | ||
|
|
b22579658e | ||
|
|
e9a3952a9d | ||
|
|
c4ffa57bf2 | ||
|
|
18e98ca7c9 | ||
|
|
6e570fe820 | ||
|
|
2ca06e113a | ||
|
|
c58168406b | ||
|
|
0ced464168 | ||
|
|
977a4a1196 | ||
|
|
56236496a0 | ||
|
|
70b3c5a6dc |
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` 与桌面发布包),否则跑的还是旧文件
|
||||
+33
-11
@@ -38,6 +38,7 @@
|
||||
#include <algorithm>
|
||||
|
||||
extern "C" void InstallRaidProbe();
|
||||
extern "C" void InstallWebRedirect();
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -13158,20 +13159,17 @@ 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 {
|
||||
unsigned int magic; // 固定 0xC0DEC0DE
|
||||
unsigned int offset; // expected 字段的文件偏移(脚本写入)
|
||||
unsigned int expected; // 文件 CRC32(脚本写入)
|
||||
// volatile:这三个字段只由构建后脚本改文件字节,代码里从不写。
|
||||
// 不加 volatile 时 /O2 会把 offset/expected 当常量折叠成 0,补丁写对了也读不到。
|
||||
volatile unsigned int magic; // 固定 0xC0DEC0DE
|
||||
volatile unsigned int offset; // expected 字段的文件偏移(脚本写入)
|
||||
volatile unsigned int expected; // 文件 CRC32(脚本写入)
|
||||
} g_selfCrcSlot = { 0xC0DEC0DEu, 0u, 0u };
|
||||
|
||||
static unsigned int Crc32Update(unsigned int crc, const unsigned char* data, size_t length)
|
||||
@@ -13189,6 +13187,8 @@ static unsigned int Crc32Update(unsigned int crc, const unsigned char* data, siz
|
||||
static bool VerifySelfIntegrity()
|
||||
{
|
||||
wchar_t path[MAX_PATH] = {};
|
||||
// 崩溃定位断点:worker 在 TCLS 之后踩 AV 时靠这些行判断停在哪一步。
|
||||
LogLine("self crc step=enter slot=%p", (void*)&g_selfCrcSlot);
|
||||
// 用数据变量地址解析自身模块;函数地址在某些构建/加载方式下解析不到(实测 err=126)。
|
||||
HMODULE selfModule = nullptr;
|
||||
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
@@ -13197,10 +13197,12 @@ static bool VerifySelfIntegrity()
|
||||
LogLine("self crc diag: GetModuleHandleEx failed err=%u", GetLastError());
|
||||
return false;
|
||||
}
|
||||
LogLine("self crc step=module module=0x%p", (void*)selfModule);
|
||||
if (!GetModuleFileNameW(selfModule, path, MAX_PATH)) {
|
||||
LogLine("self crc diag: GetModuleFileName failed err=%u", GetLastError());
|
||||
return false;
|
||||
}
|
||||
LogLine("self crc step=path");
|
||||
FILE* file = nullptr;
|
||||
if (_wfopen_s(&file, path, L"rb") != 0 || !file) {
|
||||
LogLine("self crc diag: open failed");
|
||||
@@ -13209,6 +13211,7 @@ static bool VerifySelfIntegrity()
|
||||
fseek(file, 0, SEEK_END);
|
||||
const long size = ftell(file);
|
||||
fseek(file, 0, SEEK_SET);
|
||||
LogLine("self crc step=size size=%ld", size);
|
||||
if (size < 4096) {
|
||||
LogLine("self crc diag: file too small size=%ld", size);
|
||||
fclose(file);
|
||||
@@ -13221,6 +13224,7 @@ static bool VerifySelfIntegrity()
|
||||
}
|
||||
const size_t read = fread(image, 1, static_cast<size_t>(size), file);
|
||||
fclose(file);
|
||||
LogLine("self crc step=read read=%u image=%p", (unsigned int)read, (void*)image);
|
||||
if (read != static_cast<size_t>(size)) {
|
||||
free(image);
|
||||
return false;
|
||||
@@ -13228,6 +13232,7 @@ static bool VerifySelfIntegrity()
|
||||
|
||||
const unsigned int slotOffset = g_selfCrcSlot.offset;
|
||||
const unsigned int expected = g_selfCrcSlot.expected;
|
||||
LogLine("self crc step=slot offset=0x%X expected=0x%08X", slotOffset, expected);
|
||||
bool ok = false;
|
||||
if (g_selfCrcSlot.magic != 0xC0DEC0DEu || slotOffset < 16 ||
|
||||
static_cast<size_t>(slotOffset) + 4 > static_cast<size_t>(size)) {
|
||||
@@ -14986,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,
|
||||
@@ -14995,7 +15000,9 @@ DWORD WINAPI InstallTransportWorkerInner(void*)
|
||||
TerminateProcess(GetCurrentProcess(), 1);
|
||||
}
|
||||
// 影片绑定校验:文件缺失/损坏直接终止,客户端无法启动。
|
||||
LogLine("self crc verified, entering movie binding check");
|
||||
EnforceBoundMovieOrExit();
|
||||
LogLine("movie binding check passed");
|
||||
// 攻坚队创建入口默认写死 owner 0x76(RaidCreateWindow),把该立即数改成
|
||||
// 0x7A 后打开原生 RaidSelectSingleModeWindow(团队/单人选择),单人按钮
|
||||
// 再把 mode 0 传回 0x76 的创建窗口。窗口与按钮回调全部是客户端原生实现。
|
||||
@@ -15475,6 +15482,9 @@ DWORD WINAPI InstallTransportWorkerInner(void*)
|
||||
InstallRaidProbe();
|
||||
LogLine("raid probe integrated normal/single mode compatibility");
|
||||
|
||||
// 内嵌网页窗口(小助手/小井盖)跳到本地 GM 页。只起一个轮询线程,不碰游戏逻辑。
|
||||
InstallWebRedirect();
|
||||
|
||||
LogLine("transport DLL installed codec=1 decode=1 route=%d dproto=%d party_ui=%d town_copresence=%d dungeon_pickup=%d creature_rename=%d rare_avatar_ticket=%d premium_state=%d contract_use=%d wait_ms=%d",
|
||||
enableSceneRouteCompat ? 1 : 0,
|
||||
enableDprotoCompat ? 1 : 0,
|
||||
@@ -15501,6 +15511,18 @@ DWORD WINAPI InstallWorker(void*)
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// 其它翻译单元(如 90CN_web_redirect.cpp)要写同一条 90CN_trace.log,但 LogLine
|
||||
// 在匿名 namespace 里只有内部链接,所以这里开一个 C 链接的转发出口。
|
||||
extern "C" void __cdecl CnmLogLine(const char* format, ...)
|
||||
{
|
||||
char buffer[1024];
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
_vsnprintf_s(buffer, sizeof(buffer), _TRUNCATE, format, args);
|
||||
va_end(args);
|
||||
LogLine("%s", buffer);
|
||||
}
|
||||
|
||||
#pragma comment(linker, "/EXPORT:Queue90CNClientNotice=_Queue90CNClientNotice@8")
|
||||
#pragma comment(linker, "/EXPORT:Dequeue90CNClientEvent=_Dequeue90CNClientEvent@8")
|
||||
#pragma comment(linker, "/EXPORT:Query90CNCharacterStatSnapshot=_Query90CNCharacterStatSnapshot@8")
|
||||
|
||||
@@ -261,6 +261,14 @@ bool CompanionEquipmentProbeEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// 支援兵普攻开关:启动器在客户端目录放置 90CN_basic_attack.disabled。
|
||||
// 只影响 CompanionBasicAttack 回退程序,12 格技能程序不变。
|
||||
bool CompanionBasicAttackEnabled() {
|
||||
static const bool disabled =
|
||||
CompanionFlagFilePresent(L"90CN_basic_attack.disabled");
|
||||
return !disabled;
|
||||
}
|
||||
|
||||
bool CompanionEquipmentApplyEnabled() {
|
||||
// Production default: the mode1 equipment rows are replayed for every
|
||||
// companion APC. A disable flag exists for a one-restart rollback.
|
||||
@@ -1924,6 +1932,42 @@ bool ApplyDynamicRuntimeKeyPrograms(
|
||||
return false;
|
||||
}
|
||||
|
||||
// 关闭普攻时把 CompanionBasicAttack 的技能码改成空占位码,
|
||||
// 只禁用技能冷却后的普攻回退,不触碰 0..11 的 12 格技能程序。
|
||||
if (!CompanionBasicAttackEnabled()) {
|
||||
void* basicProgram =
|
||||
programs[Contract::kBasicAttackProgramIndex];
|
||||
std::uint8_t* basic =
|
||||
reinterpret_cast<std::uint8_t*>(basicProgram);
|
||||
if (basic) {
|
||||
std::uintptr_t basicBegin =
|
||||
*reinterpret_cast<std::uintptr_t*>(
|
||||
basic +
|
||||
g_native.kRuntimeKeyProgramBytecodeBeginOffset);
|
||||
std::uintptr_t basicEnd =
|
||||
*reinterpret_cast<std::uintptr_t*>(
|
||||
basic +
|
||||
g_native.kRuntimeKeyProgramBytecodeEndOffset);
|
||||
std::uintptr_t basicCapacity =
|
||||
*reinterpret_cast<std::uintptr_t*>(
|
||||
basic +
|
||||
g_native.kRuntimeKeyProgramBytecodeCapacityOffset);
|
||||
if (basicBegin && basicEnd >= basicBegin &&
|
||||
basicCapacity >= basicEnd &&
|
||||
basicEnd - basicBegin >=
|
||||
g_native.kRuntimeKeySkillCodeOffset + 4) {
|
||||
*reinterpret_cast<std::uint32_t*>(
|
||||
basicBegin +
|
||||
g_native.kRuntimeKeySkillCodeOffset) =
|
||||
Contract::kSkillCodeBias +
|
||||
Contract::kPlaceholderSkillId;
|
||||
Log("companion basic attack disabled actor=%p program=%p",
|
||||
actor,
|
||||
basicProgram);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::uint8_t* directBytecode[
|
||||
Contract::kQuickSlotCount] = {};
|
||||
std::size_t directBytecodeSize[
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
// ============================================================================
|
||||
// 90CN_gm_window.cpp -- 我们自己绘制的 GM 窗口(自绘外壳 + Edge 内核内容)
|
||||
//
|
||||
// 为什么要自己画:游戏内嵌的 IE 控件在这个进程里输入法候选框出不来,
|
||||
// 弹一个浏览器窗口又太“外部”。这里自己建一个无系统边框的窗口,标题栏、
|
||||
// 边框、按钮全部由 GDI 绘制成和游戏一致的深色风格;内容区放一个 WebView2
|
||||
// 控件(Edge 内核),中文输入由内核处理,候选框正常。
|
||||
//
|
||||
// 窗口生命周期:
|
||||
// GmWindowShow() 第一次调用时创建窗口并初始化内嵌浏览器,之后复用
|
||||
// GmWindowHide() 只是隐藏,不销毁,下次打开秒开
|
||||
// GmPumpMessages() 由重定向线程每轮调用,负责把消息泵喂给窗口和 WebView2
|
||||
//
|
||||
// 失败不影响游戏:WebView2 初始化失败会把 g_failed 置位,由调用方回退到
|
||||
// Edge 应用窗口或内嵌 IE 方案。
|
||||
// ============================================================================
|
||||
|
||||
#include <windows.h>
|
||||
#include <windowsx.h>
|
||||
#include <wrl.h>
|
||||
#include <string>
|
||||
|
||||
#include "WebView2.h"
|
||||
|
||||
extern "C" void __cdecl CnmLogLine(const char* format, ...);
|
||||
#define LogLine CnmLogLine
|
||||
|
||||
using Microsoft::WRL::Callback;
|
||||
using Microsoft::WRL::ComPtr;
|
||||
|
||||
namespace {
|
||||
|
||||
const wchar_t kWindowClass[] = L"90CN_GMHostWindow";
|
||||
const wchar_t kTitleText[] = L"GM 控制台";
|
||||
|
||||
const int kTitleH = 38; // 自绘标题栏高度
|
||||
const int kBtnW = 44; // 标题栏按钮宽度
|
||||
const int kEdge = 5; // 拉伸手柄厚度
|
||||
const int kMinW = 760;
|
||||
const int kMinH = 520;
|
||||
|
||||
HWND g_hwnd = nullptr;
|
||||
HFONT g_fontTitle = nullptr;
|
||||
HFONT g_fontSmall = nullptr;
|
||||
HBRUSH g_brBg = nullptr;
|
||||
|
||||
ComPtr<ICoreWebView2Environment> g_env;
|
||||
ComPtr<ICoreWebView2Controller> g_ctrl;
|
||||
ComPtr<ICoreWebView2> g_web;
|
||||
|
||||
std::wstring g_url;
|
||||
std::wstring g_userDataDir;
|
||||
bool g_envStarted = false;
|
||||
bool g_failed = false;
|
||||
bool g_trackingLeave = false;
|
||||
int g_hover = 0; // 1 = 关闭按钮, 2 = 刷新按钮
|
||||
|
||||
// 颜色:和 gmweb 的深色主题对齐
|
||||
const COLORREF kColBg = RGB(0x12, 0x15, 0x1c);
|
||||
const COLORREF kColBar = RGB(0x1a, 0x1e, 0x27);
|
||||
const COLORREF kColLine = RGB(0x2f, 0x35, 0x47);
|
||||
const COLORREF kColTitle = RGB(0xe8, 0xc9, 0x79);
|
||||
const COLORREF kColMuted = RGB(0x8d, 0x94, 0xa6);
|
||||
|
||||
std::wstring EnvValue(const wchar_t* name)
|
||||
{
|
||||
wchar_t buf[MAX_PATH] = {};
|
||||
const DWORD got = GetEnvironmentVariableW(name, buf, MAX_PATH);
|
||||
return got ? std::wstring(buf, got) : std::wstring();
|
||||
}
|
||||
|
||||
void ResizeWebView()
|
||||
{
|
||||
if (!g_ctrl || !g_hwnd) return;
|
||||
RECT rc = {};
|
||||
GetClientRect(g_hwnd, &rc);
|
||||
RECT bounds = { 0, kTitleH, rc.right, rc.bottom };
|
||||
if (bounds.bottom < bounds.top) bounds.bottom = bounds.top;
|
||||
g_ctrl->put_Bounds(bounds);
|
||||
}
|
||||
|
||||
void LayoutCenterOnOwner()
|
||||
{
|
||||
RECT work = {};
|
||||
SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0);
|
||||
RECT rc = {};
|
||||
GetWindowRect(g_hwnd, &rc);
|
||||
const int w = rc.right - rc.left;
|
||||
const int h = rc.bottom - rc.top;
|
||||
const int x = work.left + (work.right - work.left - w) / 2;
|
||||
const int y = work.top + (work.bottom - work.top - h) / 2;
|
||||
SetWindowPos(g_hwnd, nullptr, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 跟随游戏窗口
|
||||
//
|
||||
// 需求:控制台要“长”在游戏画面里。全屏(无边框最大化)时用配置里的目标尺寸,
|
||||
// 窗口模式下按游戏窗口的实际客户区等比收小并居中;玩家改分辨率、切全屏/窗口,
|
||||
// 都跟着重新摆一次。
|
||||
|
||||
int g_desiredW = 1040;
|
||||
int g_desiredH = 720;
|
||||
RECT g_lastGameRect = { 0, 0, 0, 0 }; // 上次布局时游戏客户区的位置
|
||||
bool g_fitValid = false;
|
||||
|
||||
struct GameSearch {
|
||||
DWORD pid;
|
||||
HWND best;
|
||||
LONG area;
|
||||
};
|
||||
|
||||
BOOL CALLBACK OnGameCandidate(HWND hwnd, LPARAM param)
|
||||
{
|
||||
GameSearch* s = reinterpret_cast<GameSearch*>(param);
|
||||
DWORD pid = 0;
|
||||
GetWindowThreadProcessId(hwnd, &pid);
|
||||
if (pid != s->pid) return TRUE;
|
||||
if (!IsWindowVisible(hwnd)) return TRUE;
|
||||
if (GetWindow(hwnd, GW_OWNER)) return TRUE;
|
||||
|
||||
wchar_t cls[128] = {};
|
||||
if (GetClassNameW(hwnd, cls, 128)) {
|
||||
if (_wcsicmp(cls, kWindowClass) == 0) return TRUE; // 我们自己
|
||||
if (_wcsicmp(cls, L"Browser Example") == 0) return TRUE; // 游戏自带面板
|
||||
if (_wcsicmp(cls, L"Internet Explorer_Server") == 0) return TRUE;
|
||||
}
|
||||
|
||||
RECT rc = {};
|
||||
if (!GetWindowRect(hwnd, &rc)) return TRUE;
|
||||
const LONG area = (rc.right - rc.left) * (rc.bottom - rc.top);
|
||||
if (area > s->area) {
|
||||
s->area = area;
|
||||
s->best = hwnd;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// 游戏主窗口 —— 本进程里可见、没有 owner、面积最大的那个顶层窗口。
|
||||
HWND FindGameWindow()
|
||||
{
|
||||
GameSearch s = { GetCurrentProcessId(), nullptr, 0 };
|
||||
EnumWindows(OnGameCandidate, reinterpret_cast<LPARAM>(&s));
|
||||
return s.best;
|
||||
}
|
||||
|
||||
// 算一个「装得进游戏窗口」的矩形。gameOut 回传游戏客户区,供变化检测用。
|
||||
bool FitInsideGame(int desiredW, int desiredH, RECT* out, RECT* gameOut)
|
||||
{
|
||||
const HWND game = FindGameWindow();
|
||||
if (!game) return false;
|
||||
|
||||
RECT client = {};
|
||||
if (!GetClientRect(game, &client)) return false;
|
||||
POINT tl = { 0, 0 };
|
||||
if (!ClientToScreen(game, &tl)) return false;
|
||||
RECT g = { client.left + tl.x, client.top + tl.y, client.right + tl.x, client.bottom + tl.y };
|
||||
|
||||
const int gw = g.right - g.left;
|
||||
const int gh = g.bottom - g.top;
|
||||
if (gw < 320 || gh < 240) return false; // 还没进游戏/窗口还没建好
|
||||
|
||||
int margin = gw / 60;
|
||||
if (margin < 12) margin = 12;
|
||||
if (margin > 28) margin = 28;
|
||||
|
||||
int w = desiredW > 0 ? desiredW : gw;
|
||||
int h = desiredH > 0 ? desiredH : gh;
|
||||
if (w > gw - 2 * margin) w = gw - 2 * margin;
|
||||
if (h > gh - 2 * margin) h = gh - 2 * margin;
|
||||
// 窗口小到一定程度就别再缩了,否则控件挤得没法用;宁可稍微溢出一点。
|
||||
if (w < 720) w = 720;
|
||||
if (h < 500) h = 500;
|
||||
if (w > gw) w = gw;
|
||||
if (h > gh) h = gh;
|
||||
|
||||
out->left = g.left + (gw - w) / 2;
|
||||
out->top = g.top + (gh - h) / 2;
|
||||
out->right = out->left + w;
|
||||
out->bottom = out->top + h;
|
||||
if (gameOut) *gameOut = g;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 游戏窗口变了(切全屏/改分辨率/挪窗口)就重新摆一次。玩家自己拖过窗口、
|
||||
// 游戏没变的情况下不动它,避免跟玩家较劲。
|
||||
void FollowGameWindow()
|
||||
{
|
||||
if (!g_hwnd || !IsWindow(g_hwnd) || !IsWindowVisible(g_hwnd) || !g_fitValid) return;
|
||||
|
||||
RECT fit = {};
|
||||
RECT game = {};
|
||||
if (!FitInsideGame(g_desiredW, g_desiredH, &fit, &game)) return;
|
||||
|
||||
RECT cur = {};
|
||||
GetWindowRect(g_hwnd, &cur);
|
||||
|
||||
const bool gameChanged = !EqualRect(&game, &g_lastGameRect);
|
||||
const bool outside = cur.left < game.left - 4 || cur.top < game.top - 4 ||
|
||||
cur.right > game.right + 4 || cur.bottom > game.bottom + 4;
|
||||
if (!gameChanged && !outside) return;
|
||||
|
||||
SetWindowPos(g_hwnd, HWND_TOPMOST, fit.left, fit.top,
|
||||
fit.right - fit.left, fit.bottom - fit.top,
|
||||
SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||||
ResizeWebView();
|
||||
InvalidateRect(g_hwnd, nullptr, FALSE);
|
||||
g_lastGameRect = game;
|
||||
LogLine("gm window: refit to game %ldx%ld -> %dx%d at %ld,%ld",
|
||||
game.right - game.left, game.bottom - game.top,
|
||||
static_cast<int>(fit.right - fit.left), static_cast<int>(fit.bottom - fit.top),
|
||||
static_cast<long>(fit.left), static_cast<long>(fit.top));
|
||||
}
|
||||
|
||||
void CloseButtonRect(RECT* out)
|
||||
{
|
||||
RECT rc = {};
|
||||
GetClientRect(g_hwnd, &rc);
|
||||
out->right = rc.right;
|
||||
out->left = rc.right - kBtnW;
|
||||
out->top = 0;
|
||||
out->bottom = kTitleH;
|
||||
}
|
||||
|
||||
void RefreshButtonRect(RECT* out)
|
||||
{
|
||||
RECT close = {};
|
||||
CloseButtonRect(&close);
|
||||
out->right = close.left;
|
||||
out->left = close.left - kBtnW;
|
||||
out->top = 0;
|
||||
out->bottom = kTitleH;
|
||||
}
|
||||
|
||||
void PaintWindow(HWND hwnd)
|
||||
{
|
||||
PAINTSTRUCT ps = {};
|
||||
HDC dc = BeginPaint(hwnd, &ps);
|
||||
if (!dc) return;
|
||||
|
||||
RECT rc = {};
|
||||
GetClientRect(hwnd, &rc);
|
||||
|
||||
// 整窗底色
|
||||
HBRUSH bg = g_brBg ? g_brBg : static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH));
|
||||
FillRect(dc, &rc, bg);
|
||||
|
||||
// 标题栏
|
||||
RECT bar = { 0, 0, rc.right, kTitleH };
|
||||
HBRUSH barBrush = CreateSolidBrush(kColBar);
|
||||
FillRect(dc, &bar, barBrush);
|
||||
DeleteObject(barBrush);
|
||||
|
||||
// 金色小竖条 + 标题
|
||||
RECT accent = { 14, 11, 17, kTitleH - 11 };
|
||||
HBRUSH accentBrush = CreateSolidBrush(kColTitle);
|
||||
FillRect(dc, &accent, accentBrush);
|
||||
DeleteObject(accentBrush);
|
||||
|
||||
SetBkMode(dc, TRANSPARENT);
|
||||
SetTextColor(dc, kColTitle);
|
||||
HFONT old = static_cast<HFONT>(SelectObject(dc, g_fontTitle ? g_fontTitle : GetStockObject(DEFAULT_GUI_FONT)));
|
||||
RECT textRect = { 24, 0, rc.right - 2 * kBtnW - 8, kTitleH };
|
||||
DrawTextW(dc, kTitleText, -1, &textRect, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
|
||||
|
||||
// 刷新按钮
|
||||
RECT refresh = {};
|
||||
RefreshButtonRect(&refresh);
|
||||
if (g_hover == 2) {
|
||||
HBRUSH hb = CreateSolidBrush(RGB(0x26, 0x2c, 0x3a));
|
||||
FillRect(dc, &refresh, hb);
|
||||
DeleteObject(hb);
|
||||
}
|
||||
SetTextColor(dc, kColMuted);
|
||||
SelectObject(dc, g_fontSmall ? g_fontSmall : GetStockObject(DEFAULT_GUI_FONT));
|
||||
DrawTextW(dc, L"刷新", -1, &refresh, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
||||
|
||||
// 关闭按钮(自己画 ×,不依赖字体里有没有这个字形)
|
||||
RECT close = {};
|
||||
CloseButtonRect(&close);
|
||||
if (g_hover == 1) {
|
||||
HBRUSH hb = CreateSolidBrush(RGB(0xc0, 0x39, 0x2b));
|
||||
FillRect(dc, &close, hb);
|
||||
DeleteObject(hb);
|
||||
}
|
||||
const int cx = (close.left + close.right) / 2;
|
||||
const int cy = (close.top + close.bottom) / 2;
|
||||
const int r = 6;
|
||||
HPEN pen = CreatePen(PS_SOLID, 2, g_hover == 1 ? RGB(0xff, 0xff, 0xff) : kColMuted);
|
||||
HPEN oldPen = static_cast<HPEN>(SelectObject(dc, pen));
|
||||
MoveToEx(dc, cx - r, cy - r, nullptr);
|
||||
LineTo(dc, cx + r + 1, cy + r + 1);
|
||||
MoveToEx(dc, cx + r, cy - r, nullptr);
|
||||
LineTo(dc, cx - r - 1, cy + r + 1);
|
||||
SelectObject(dc, oldPen);
|
||||
DeleteObject(pen);
|
||||
|
||||
// 分隔线与外框
|
||||
HPEN linePen = CreatePen(PS_SOLID, 1, kColLine);
|
||||
HPEN prevPen = static_cast<HPEN>(SelectObject(dc, linePen));
|
||||
MoveToEx(dc, 0, kTitleH - 1, nullptr);
|
||||
LineTo(dc, rc.right, kTitleH - 1);
|
||||
HBRUSH hollow = static_cast<HBRUSH>(GetStockObject(NULL_BRUSH));
|
||||
HBRUSH prevBrush = static_cast<HBRUSH>(SelectObject(dc, hollow));
|
||||
Rectangle(dc, 0, 0, rc.right, rc.bottom);
|
||||
SelectObject(dc, prevBrush);
|
||||
SelectObject(dc, prevPen);
|
||||
DeleteObject(linePen);
|
||||
|
||||
if (g_failed) {
|
||||
SetTextColor(dc, kColMuted);
|
||||
RECT tip = { 0, kTitleH, rc.right, rc.bottom };
|
||||
DrawTextW(dc, L"内嵌浏览器初始化失败,请检查 WebView2 运行时是否安装。",
|
||||
-1, &tip, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
||||
} else if (!g_web) {
|
||||
SetTextColor(dc, kColMuted);
|
||||
RECT tip = { 0, kTitleH, rc.right, rc.bottom };
|
||||
DrawTextW(dc, L"正在加载 GM 页面…", -1, &tip, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
||||
}
|
||||
|
||||
SelectObject(dc, old);
|
||||
EndPaint(hwnd, &ps);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- WebView2
|
||||
|
||||
std::wstring UserDataDir()
|
||||
{
|
||||
const std::wstring base = EnvValue(L"LOCALAPPDATA");
|
||||
if (base.empty()) return std::wstring();
|
||||
const std::wstring root = base + L"\\90CN";
|
||||
CreateDirectoryW(root.c_str(), nullptr);
|
||||
const std::wstring dir = root + L"\\wv2";
|
||||
CreateDirectoryW(dir.c_str(), nullptr);
|
||||
return dir;
|
||||
}
|
||||
|
||||
void StartWebView()
|
||||
{
|
||||
if (g_envStarted || g_failed || !g_hwnd) return;
|
||||
g_envStarted = true;
|
||||
|
||||
g_userDataDir = UserDataDir();
|
||||
if (g_userDataDir.empty()) {
|
||||
g_failed = true;
|
||||
LogLine("gm window: LOCALAPPDATA missing, webview cannot start");
|
||||
InvalidateRect(g_hwnd, nullptr, FALSE);
|
||||
return;
|
||||
}
|
||||
|
||||
const HRESULT hr = CreateCoreWebView2EnvironmentWithOptions(nullptr, g_userDataDir.c_str(), nullptr,
|
||||
Callback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>(
|
||||
[](HRESULT result, ICoreWebView2Environment* env) -> HRESULT {
|
||||
if (FAILED(result) || !env) {
|
||||
g_failed = true;
|
||||
LogLine("gm window: environment failed hr=0x%08X", (unsigned)result);
|
||||
if (g_hwnd) InvalidateRect(g_hwnd, nullptr, FALSE);
|
||||
return S_OK;
|
||||
}
|
||||
g_env = env;
|
||||
g_env->CreateCoreWebView2Controller(g_hwnd,
|
||||
Callback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>(
|
||||
[](HRESULT result2, ICoreWebView2Controller* controller) -> HRESULT {
|
||||
if (FAILED(result2) || !controller) {
|
||||
g_failed = true;
|
||||
LogLine("gm window: controller failed hr=0x%08X", (unsigned)result2);
|
||||
if (g_hwnd) InvalidateRect(g_hwnd, nullptr, FALSE);
|
||||
return S_OK;
|
||||
}
|
||||
g_ctrl = controller;
|
||||
g_ctrl->get_CoreWebView2(&g_web);
|
||||
if (g_web) {
|
||||
// 关掉右键菜单和开发者工具入口,看起来更像应用而不是网页
|
||||
ComPtr<ICoreWebView2Settings> settings;
|
||||
if (SUCCEEDED(g_web->get_Settings(&settings)) && settings) {
|
||||
settings->put_AreDefaultContextMenusEnabled(FALSE);
|
||||
settings->put_IsStatusBarEnabled(FALSE);
|
||||
settings->put_AreDevToolsEnabled(FALSE);
|
||||
}
|
||||
if (!g_url.empty()) g_web->Navigate(g_url.c_str());
|
||||
}
|
||||
ResizeWebView();
|
||||
if (g_hwnd) InvalidateRect(g_hwnd, nullptr, FALSE);
|
||||
LogLine("gm window: webview ready");
|
||||
return S_OK;
|
||||
}).Get());
|
||||
return S_OK;
|
||||
}).Get());
|
||||
|
||||
if (FAILED(hr)) {
|
||||
g_failed = true;
|
||||
LogLine("gm window: CreateCoreWebView2EnvironmentWithOptions hr=0x%08X (runtime missing?)", (unsigned)hr);
|
||||
if (g_hwnd) InvalidateRect(g_hwnd, nullptr, FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 窗口过程
|
||||
|
||||
LRESULT HitTest(HWND hwnd)
|
||||
{
|
||||
POINT pt = {};
|
||||
GetCursorPos(&pt);
|
||||
ScreenToClient(hwnd, &pt);
|
||||
|
||||
RECT rc = {};
|
||||
GetClientRect(hwnd, &rc);
|
||||
|
||||
RECT close = {};
|
||||
CloseButtonRect(&close);
|
||||
RECT refresh = {};
|
||||
RefreshButtonRect(&refresh);
|
||||
if (PtInRect(&close, pt) || PtInRect(&refresh, pt)) return HTCLIENT;
|
||||
|
||||
// 先判边缘,方便拉伸
|
||||
const bool left = pt.x < kEdge;
|
||||
const bool right = pt.x >= rc.right - kEdge;
|
||||
const bool top = pt.y < kEdge;
|
||||
const bool bottom = pt.y >= rc.bottom - kEdge;
|
||||
if (top && left) return HTTOPLEFT;
|
||||
if (top && right) return HTTOPRIGHT;
|
||||
if (bottom && left) return HTBOTTOMLEFT;
|
||||
if (bottom && right) return HTBOTTOMRIGHT;
|
||||
if (left) return HTLEFT;
|
||||
if (right) return HTRIGHT;
|
||||
if (top) return HTTOP;
|
||||
if (bottom) return HTBOTTOM;
|
||||
|
||||
if (pt.y < kTitleH) return HTCAPTION;
|
||||
return HTCLIENT;
|
||||
}
|
||||
|
||||
LRESULT CALLBACK WindowProc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam)
|
||||
{
|
||||
switch (msg) {
|
||||
case WM_NCCALCSIZE:
|
||||
if (wparam) return 0; // 去掉系统非客户区,边框和标题栏由我们自己画
|
||||
break;
|
||||
|
||||
case WM_NCHITTEST:
|
||||
return HitTest(hwnd);
|
||||
|
||||
case WM_ERASEBKGND:
|
||||
return 1;
|
||||
|
||||
case WM_PAINT:
|
||||
PaintWindow(hwnd);
|
||||
return 0;
|
||||
|
||||
case WM_SIZE:
|
||||
ResizeWebView();
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
return 0;
|
||||
|
||||
case WM_MOUSEMOVE: {
|
||||
if (!g_trackingLeave) {
|
||||
TRACKMOUSEEVENT tme = { sizeof(tme), TME_LEAVE, hwnd, 0 };
|
||||
TrackMouseEvent(&tme);
|
||||
g_trackingLeave = true;
|
||||
}
|
||||
POINT pt = { GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam) };
|
||||
RECT close = {};
|
||||
CloseButtonRect(&close);
|
||||
RECT refresh = {};
|
||||
RefreshButtonRect(&refresh);
|
||||
int hover = 0;
|
||||
if (PtInRect(&close, pt)) hover = 1;
|
||||
else if (PtInRect(&refresh, pt)) hover = 2;
|
||||
if (hover != g_hover) {
|
||||
g_hover = hover;
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
case WM_MOUSELEAVE:
|
||||
g_trackingLeave = false;
|
||||
if (g_hover != 0) {
|
||||
g_hover = 0;
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
}
|
||||
return 0;
|
||||
|
||||
case WM_LBUTTONDOWN: {
|
||||
POINT pt = { GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam) };
|
||||
RECT close = {};
|
||||
CloseButtonRect(&close);
|
||||
RECT refresh = {};
|
||||
RefreshButtonRect(&refresh);
|
||||
if (PtInRect(&close, pt)) {
|
||||
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
||||
return 0;
|
||||
}
|
||||
if (PtInRect(&refresh, pt)) {
|
||||
if (g_web) g_web->Reload();
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case WM_CLOSE:
|
||||
ShowWindow(hwnd, SW_HIDE);
|
||||
SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
|
||||
LogLine("gm window: hidden by user");
|
||||
return 0;
|
||||
|
||||
case WM_GETMINMAXINFO: {
|
||||
MINMAXINFO* info = reinterpret_cast<MINMAXINFO*>(lparam);
|
||||
info->ptMinTrackSize.x = kMinW;
|
||||
info->ptMinTrackSize.y = kMinH;
|
||||
return 0;
|
||||
}
|
||||
|
||||
case WM_DESTROY:
|
||||
g_hwnd = nullptr;
|
||||
return 0;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return DefWindowProcW(hwnd, msg, wparam, lparam);
|
||||
}
|
||||
|
||||
bool EnsureWindow()
|
||||
{
|
||||
if (g_hwnd && IsWindow(g_hwnd)) return true;
|
||||
|
||||
WNDCLASSEXW wc = {};
|
||||
wc.cbSize = sizeof(wc);
|
||||
wc.style = CS_DROPSHADOW;
|
||||
wc.lpfnWndProc = WindowProc;
|
||||
wc.hInstance = GetModuleHandleW(nullptr);
|
||||
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||
wc.hbrBackground = nullptr;
|
||||
wc.lpszClassName = kWindowClass;
|
||||
if (!RegisterClassExW(&wc) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) {
|
||||
LogLine("gm window: RegisterClassEx failed err=%u", GetLastError());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!g_brBg) g_brBg = CreateSolidBrush(kColBg);
|
||||
if (!g_fontTitle) {
|
||||
g_fontTitle = CreateFontW(-16, 0, 0, 0, FW_SEMIBOLD, FALSE, FALSE, FALSE,
|
||||
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
|
||||
DEFAULT_PITCH | FF_DONTCARE, L"Microsoft YaHei UI");
|
||||
}
|
||||
if (!g_fontSmall) {
|
||||
g_fontSmall = CreateFontW(-13, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
|
||||
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
|
||||
DEFAULT_PITCH | FF_DONTCARE, L"Microsoft YaHei UI");
|
||||
}
|
||||
|
||||
g_hwnd = CreateWindowExW(WS_EX_TOOLWINDOW, kWindowClass, kTitleText,
|
||||
WS_POPUP | WS_THICKFRAME | WS_CLIPCHILDREN,
|
||||
CW_USEDEFAULT, CW_USEDEFAULT, 1040, 720,
|
||||
nullptr, nullptr, wc.hInstance, nullptr);
|
||||
if (!g_hwnd) {
|
||||
LogLine("gm window: CreateWindowEx failed err=%u", GetLastError());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------- 对外接口
|
||||
|
||||
extern "C" bool GmWindowShow(const wchar_t* url, int width, int height)
|
||||
{
|
||||
if (!EnsureWindow()) return false;
|
||||
|
||||
if (url && *url) g_url = url;
|
||||
if (width > 0) g_desiredW = width;
|
||||
if (height > 0) g_desiredH = height;
|
||||
|
||||
// 优先让窗口落在游戏画面里:全屏时就是配置的目标尺寸,窗口模式下自动收小。
|
||||
RECT fit = {};
|
||||
RECT game = {};
|
||||
const bool fitted = FitInsideGame(g_desiredW, g_desiredH, &fit, &game);
|
||||
|
||||
if (!IsWindowVisible(g_hwnd)) {
|
||||
if (fitted) {
|
||||
SetWindowPos(g_hwnd, HWND_TOPMOST, fit.left, fit.top,
|
||||
fit.right - fit.left, fit.bottom - fit.top,
|
||||
SWP_SHOWWINDOW | SWP_NOACTIVATE);
|
||||
g_lastGameRect = game;
|
||||
g_fitValid = true;
|
||||
LogLine("gm window: show inside game %ldx%ld -> %dx%d at %ld,%ld",
|
||||
game.right - game.left, game.bottom - game.top,
|
||||
static_cast<int>(fit.right - fit.left), static_cast<int>(fit.bottom - fit.top),
|
||||
static_cast<long>(fit.left), static_cast<long>(fit.top));
|
||||
} else {
|
||||
SetWindowPos(g_hwnd, HWND_TOPMOST, 0, 0,
|
||||
g_desiredW, g_desiredH, SWP_NOMOVE | SWP_SHOWWINDOW | SWP_NOACTIVATE);
|
||||
g_fitValid = false;
|
||||
LogLine("gm window: game window not found, show at %dx%d", g_desiredW, g_desiredH);
|
||||
}
|
||||
} else {
|
||||
SetWindowPos(g_hwnd, HWND_TOPMOST, 0, 0, 0, 0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
|
||||
if (fitted) {
|
||||
SetWindowPos(g_hwnd, HWND_TOPMOST, fit.left, fit.top,
|
||||
fit.right - fit.left, fit.bottom - fit.top, SWP_NOACTIVATE);
|
||||
g_lastGameRect = game;
|
||||
g_fitValid = true;
|
||||
ResizeWebView();
|
||||
}
|
||||
}
|
||||
|
||||
if (!g_envStarted) {
|
||||
if (!fitted) LayoutCenterOnOwner();
|
||||
StartWebView();
|
||||
} else if (g_web && !g_url.empty()) {
|
||||
// 页面服务可能重启过,重新打开时刷一次,避免停在错误页
|
||||
g_web->Reload();
|
||||
}
|
||||
|
||||
ShowWindow(g_hwnd, SW_SHOW);
|
||||
SetForegroundWindow(g_hwnd);
|
||||
ResizeWebView();
|
||||
InvalidateRect(g_hwnd, nullptr, FALSE);
|
||||
return true;
|
||||
}
|
||||
|
||||
extern "C" void GmWindowHide()
|
||||
{
|
||||
if (g_hwnd && IsWindow(g_hwnd) && IsWindowVisible(g_hwnd)) {
|
||||
ShowWindow(g_hwnd, SW_HIDE);
|
||||
SetWindowPos(g_hwnd, HWND_NOTOPMOST, 0, 0, 0, 0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" bool GmWindowIsVisible()
|
||||
{
|
||||
return g_hwnd && IsWindow(g_hwnd) && IsWindowVisible(g_hwnd);
|
||||
}
|
||||
|
||||
// 游戏有时会在切图、恢复全屏时把窗口栈重排一遍,把我们的窗口压回普通层。
|
||||
// 主循环每轮调一次,发现置顶丢失就重新提上去(不抢焦点)。
|
||||
extern "C" void GmWindowAssertTop()
|
||||
{
|
||||
if (!g_hwnd || !IsWindow(g_hwnd)) return;
|
||||
const LONG_PTR ex = GetWindowLongPtrW(g_hwnd, GWL_EXSTYLE);
|
||||
if ((ex & WS_EX_TOPMOST) == 0) {
|
||||
SetWindowPos(g_hwnd, HWND_TOPMOST, 0, 0, 0, 0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
|
||||
LogLine("gm window: re-asserted topmost");
|
||||
}
|
||||
}
|
||||
|
||||
// 游戏窗口换了尺寸/位置(切全屏、改分辨率、拖窗口)时把控制台重新摆回游戏画面里。
|
||||
// 主循环每轮调一次,内部只在游戏矩形变化或窗口超出游戏范围时才真正动窗口。
|
||||
extern "C" void GmWindowFollowGame()
|
||||
{
|
||||
FollowGameWindow();
|
||||
}
|
||||
|
||||
extern "C" bool GmWindowFailed()
|
||||
{
|
||||
return g_failed;
|
||||
}
|
||||
|
||||
extern "C" void GmPumpMessages(unsigned timeoutMs)
|
||||
{
|
||||
MsgWaitForMultipleObjects(0, nullptr, FALSE, timeoutMs, QS_ALLINPUT);
|
||||
MSG msg = {};
|
||||
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Included inside 90CN.cpp's namespace after its existing patch helpers.
|
||||
// Only the evidenced UI update iterator call-site is patched, never the CRT.
|
||||
uintptr_t g_uiIteratorNativeAdvance = 0;
|
||||
uintptr_t g_uiIteratorAdvanceResume = 0;
|
||||
uintptr_t g_uiIteratorNextBucket = 0;
|
||||
volatile LONG g_uiIteratorInvalidations = 0;
|
||||
int __stdcall CanAdvanceUiUpdateIterator(unsigned int bucket,
|
||||
const uintptr_t* owner, const uintptr_t* iterator)
|
||||
{
|
||||
dnf90::UiIteratorSnapshot s = {};
|
||||
__try {
|
||||
s.owner = reinterpret_cast<uintptr_t>(owner);
|
||||
if (owner && iterator) {
|
||||
s.proxy = owner[0]; s.iteratorProxy = iterator[0];
|
||||
s.begin = owner[1]; s.end = owner[2]; s.current = iterator[2];
|
||||
if (s.proxy && s.iteratorProxy == s.proxy)
|
||||
s.proxyOwner = *reinterpret_cast<const uintptr_t*>(s.proxy);
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { s.proxyOwner = 0; }
|
||||
if (dnf90::CanAdvanceUiIterator(s)) return 1;
|
||||
LONG n = InterlockedIncrement(&g_uiIteratorInvalidations);
|
||||
if (n <= 16) LogLine("ui-update iterator invalidated bucket=%u owner=%08X "
|
||||
"proxy=%08X iter_proxy=%08X begin=%08X end=%08X current=%08X "
|
||||
"action=defer_remaining_bucket_until_next_frame hit=%ld",
|
||||
bucket, s.owner, s.proxy, s.iteratorProxy, s.begin, s.end, s.current, n);
|
||||
return 0;
|
||||
}
|
||||
__declspec(naked) void ProxyUiUpdateIteratorAdvance()
|
||||
{
|
||||
__asm {
|
||||
// Entry is a JMP replacing CALL; result pointer and dummy are on stack.
|
||||
push eax
|
||||
push ecx
|
||||
push edx
|
||||
push ecx
|
||||
push dword ptr [ebp - 14h]
|
||||
push dword ptr [ebp - 1Ch]
|
||||
call CanAdvanceUiUpdateIterator
|
||||
test eax, eax
|
||||
pop edx
|
||||
pop ecx
|
||||
pop eax
|
||||
jz invalidated
|
||||
call dword ptr [g_uiIteratorNativeAdvance]
|
||||
jmp dword ptr [g_uiIteratorAdvanceResume]
|
||||
invalidated:
|
||||
// Native ++ would have consumed two arguments (ret 8). Do not compare
|
||||
// an invalid iterator again; the next bucket constructs a fresh one.
|
||||
add esp, 8
|
||||
jmp dword ptr [g_uiIteratorNextBucket]
|
||||
}
|
||||
}
|
||||
bool InstallUiIteratorAdvanceCompatibility()
|
||||
{
|
||||
const uintptr_t site = g_dnfBase + 0x02BF2732;
|
||||
static const unsigned char kLoopContract[] = {
|
||||
0x8B,0x0F,0x8B,0x11,0x8B,0x42,0x10,0xFF,0xD0,0x6A,0x00,
|
||||
0x8D,0x4D,0xB4,0x51,0x8D,0x4D,0xD4,0xE8,0x19,0x02,0x7F,0xFD,
|
||||
0xE9,0x77,0xFF,0xFF,0xFF,0x8B,0x45,0xE4,0x83,0x45,0xEC,0x14,
|
||||
0x40,0x89,0x45,0xE4,0x3D,0x87,0x04,0x00,0x00,0x0F,0x8C,0x55,0xFF,0xFF,0xFF
|
||||
};
|
||||
static const unsigned char kBeginContract[] = {
|
||||
0x55,0x8B,0xEC,0x8B,0x45,0x08,0x8B,0x51,0x04,0x8B,0x09,
|
||||
0xC7,0x40,0x04,0,0,0,0,0x89,0x50,0x08,0x89,0x08,0x5D,0xC2,0x04,0
|
||||
};
|
||||
static const unsigned char kAdvanceContract[] = {
|
||||
0x55,0x8B,0xEC,0x8B,0x45,0x08,0x8B,0x11,0x56,0xC7,0x00,0,0,0,0
|
||||
};
|
||||
if (!BytesMatch(reinterpret_cast<unsigned char*>(g_dnfBase+0x02BF2720),
|
||||
kLoopContract,sizeof(kLoopContract)) ||
|
||||
!BytesMatch(reinterpret_cast<unsigned char*>(g_dnfBase+0x00A44ED0),
|
||||
kBeginContract,sizeof(kBeginContract)) ||
|
||||
!BytesMatch(reinterpret_cast<unsigned char*>(g_dnfBase+0x003E2950),
|
||||
kAdvanceContract,sizeof(kAdvanceContract))) {
|
||||
LogLine("ui-update iterator compatibility disabled: native signature mismatch");
|
||||
return false;
|
||||
}
|
||||
g_uiIteratorNativeAdvance = g_dnfBase + 0x003E2950;
|
||||
g_uiIteratorAdvanceResume = g_dnfBase + 0x02BF2737;
|
||||
g_uiIteratorNextBucket = g_dnfBase + 0x02BF273C;
|
||||
DWORD oldProtection = 0;
|
||||
if (!VirtualProtect(reinterpret_cast<void*>(site),5,PAGE_EXECUTE_READWRITE,&oldProtection)) {
|
||||
LogLine("ui-update iterator compatibility protection failed error=%lu",GetLastError());
|
||||
return false;
|
||||
}
|
||||
unsigned char jump[5] = {0xE9};
|
||||
*reinterpret_cast<int32_t*>(jump+1) = static_cast<int32_t>(
|
||||
reinterpret_cast<intptr_t>(&ProxyUiUpdateIteratorAdvance)-static_cast<intptr_t>(site+5));
|
||||
memcpy(reinterpret_cast<void*>(site),jump,sizeof(jump));
|
||||
DWORD ignored = 0;
|
||||
VirtualProtect(reinterpret_cast<void*>(site),5,oldProtection,&ignored);
|
||||
FlushInstructionCache(GetCurrentProcess(),reinterpret_cast<void*>(site),5);
|
||||
LogLine("ui-update mutation-safe iterator installed site=%08X next_bucket=%08X",site,g_uiIteratorNextBucket);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[web]
|
||||
; 1 = 接管小井盖窗口, 0 = 完全不管
|
||||
enabled=1
|
||||
; 1 = 点小井盖时弹出我们自己的窗口(推荐,中文输入正常)
|
||||
; 0 = 老行为:仍然用游戏内嵌的 IE,只把页面跳转到下面的 url
|
||||
external=1
|
||||
; 原面板全程压制:只要它露头就立刻藏起来,玩家永远看不到游戏自带的旧页面
|
||||
suppress=1
|
||||
; 控制台默认置顶显示。本服客户端全屏=无边框窗口最大化,已实测置顶窗口能正常盖在
|
||||
; 游戏画面上,所以一般不用开。
|
||||
; 只有换成真·独占全屏(DirectX 独占、窗口压在画面下看不见)时才改成 1:开控制台前先把
|
||||
; 游戏最小化,关掉控制台再自动还原全屏。
|
||||
minimize_game=0
|
||||
; 关掉我们自己的窗口时:1 = 直接关闭游戏原面板,并在之后 2.5 秒内继续压制它
|
||||
; (推荐,原面板不会残留、也不会再露面);0 = 把面板放回去(老行为,会看到旧 IE 页面)
|
||||
close_action=1
|
||||
; 我们自己的窗口尺寸
|
||||
width=800
|
||||
height=720
|
||||
; 目标页面。必须是本地 http,避免浏览器弹证书提示。
|
||||
url=http://127.0.0.1:18112/
|
||||
; 备用浏览器路径(仅当 WebView2 不可用、退回 Edge 应用窗口时用),留空自动找
|
||||
browser=
|
||||
; external=0 时:如果页面被跳到别处,是否拉回来
|
||||
force=0
|
||||
; external=0 时:IE 控件出现后等多久再跳转(毫秒)
|
||||
delay=1200
|
||||
@@ -0,0 +1,792 @@
|
||||
// ============================================================================
|
||||
// 90CN_web_redirect.cpp -- 把小井盖(小助手)变成我们自己的 GM 窗口
|
||||
//
|
||||
// 背景:DNF 客户端里那个「小井盖 / 小助手」窗口是 IE 内核(窗口类链为
|
||||
// Browser Example -> Shell DocObject View -> Internet Explorer_Server)。
|
||||
// 往这个控件里塞我们的页面可以显示,但中文输入法候选框死活不出来
|
||||
// (MSHTML 的 TSF 通道在这个进程里接不上),所以不再折腾它。
|
||||
//
|
||||
// 两种模式(90CN_web.ini 的 external 开关):
|
||||
// external=1(默认) 点小井盖 → 游戏面板立刻隐藏 → 弹出我们自己的独立窗口
|
||||
// (Edge/Chrome 的 app 模式,无地址栏,标题栏是我们的
|
||||
// 页面标题)。输入法由系统正常处理,中文没问题。
|
||||
// 关掉独立窗口后把游戏面板放回去,游戏状态保持原样。
|
||||
// external=0 老行为:仍用内嵌 IE,等控件创建出来再把文档导航到
|
||||
// location.replace(我们的页面)。
|
||||
//
|
||||
// 配置:DLL 同目录的 90CN_web.ini,没有配置文件时用内置默认值。
|
||||
//
|
||||
// 失败不影响游戏:找不到浏览器时自动退回内嵌 IE 模式,任何一步取不到对象
|
||||
// 都只写日志,不会终止进程。
|
||||
// ============================================================================
|
||||
|
||||
#include <windows.h>
|
||||
#include <imm.h>
|
||||
#include <ole2.h>
|
||||
#include <oleacc.h>
|
||||
#include <mshtml.h>
|
||||
#include <string>
|
||||
|
||||
// 90CN.cpp 里的 LogLine 在匿名 namespace 内(内部链接),所以走它导出的
|
||||
// C 链接转发口,保证本模块的日志和安装流程写进同一个 90CN_trace.log。
|
||||
extern "C" void __cdecl CnmLogLine(const char* format, ...);
|
||||
#define LogLine CnmLogLine
|
||||
|
||||
// 自绘窗口模块(90CN_gm_window.cpp)。WebView2 需要由同一个线程持续泵消息,
|
||||
// 所以这里只做“开/关/查询”,消息泵由下面的 WorkerThread 统一喂。
|
||||
extern "C" bool GmWindowShow(const wchar_t* url, int width, int height);
|
||||
extern "C" void GmWindowHide();
|
||||
extern "C" bool GmWindowIsVisible();
|
||||
extern "C" bool GmWindowFailed();
|
||||
extern "C" void GmPumpMessages(unsigned timeoutMs);
|
||||
extern "C" void GmWindowFollowGame();
|
||||
extern "C" void GmWindowAssertTop();
|
||||
|
||||
namespace {
|
||||
|
||||
const wchar_t kIniName[] = L"90CN_web.ini";
|
||||
const wchar_t kIeServerClass[] = L"Internet Explorer_Server";
|
||||
// 我们自己的窗口标题,必须和 gmweb/index.html 的 <title> 完全一致。
|
||||
// 用它来判断窗口是否还开着,比跟踪进程句柄可靠(Chromium 会转手进程)。
|
||||
const wchar_t kOwnWindowTitle[] = L"GM 控制台";
|
||||
|
||||
struct WebConfig {
|
||||
bool enabled;
|
||||
bool force;
|
||||
bool external; // true = 用我们自己的窗口,false = 老的内嵌 IE 跳转
|
||||
bool suppress; // true = 我们的窗口开着时隐藏游戏内嵌面板
|
||||
bool closePanel; // true = 关闭我们的窗口时直接把面板关掉,false = 放回去
|
||||
bool minimizeGame; // true = 全屏游戏时先把游戏最小化,否则窗口会被盖住
|
||||
int delayMs;
|
||||
int width;
|
||||
int height;
|
||||
std::wstring url;
|
||||
std::wstring browser; // 空 = 自动查找 Edge/Chrome
|
||||
};
|
||||
|
||||
WebConfig g_cfg = { true, false, true, true, false, false, 1200, 1040, 720,
|
||||
L"http://127.0.0.1:18112/", std::wstring() };
|
||||
|
||||
DWORD g_threadId = 0;
|
||||
volatile LONG g_stop = 0;
|
||||
|
||||
std::wstring WideFromAnsi(const char* s)
|
||||
{
|
||||
if (!s || !*s) return std::wstring();
|
||||
int need = MultiByteToWideChar(CP_UTF8, 0, s, -1, nullptr, 0);
|
||||
if (need <= 1) {
|
||||
need = MultiByteToWideChar(CP_ACP, 0, s, -1, nullptr, 0);
|
||||
if (need <= 1) return std::wstring();
|
||||
std::wstring out(static_cast<size_t>(need - 1), L'\0');
|
||||
MultiByteToWideChar(CP_ACP, 0, s, -1, &out[0], need);
|
||||
return out;
|
||||
}
|
||||
std::wstring out(static_cast<size_t>(need - 1), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, s, -1, &out[0], need);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::wstring ModuleDirectory()
|
||||
{
|
||||
wchar_t path[MAX_PATH] = {};
|
||||
HMODULE self = nullptr;
|
||||
// 用数据地址反查自身模块:函数地址在某些加载方式下解析不到。
|
||||
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
reinterpret_cast<LPCWSTR>(&g_cfg), &self) ||
|
||||
!self) {
|
||||
return std::wstring();
|
||||
}
|
||||
if (!GetModuleFileNameW(self, path, MAX_PATH)) return std::wstring();
|
||||
std::wstring full(path);
|
||||
const size_t slash = full.find_last_of(L"\\/");
|
||||
return slash == std::wstring::npos ? std::wstring() : full.substr(0, slash + 1);
|
||||
}
|
||||
|
||||
void LoadConfig()
|
||||
{
|
||||
const std::wstring dir = ModuleDirectory();
|
||||
if (dir.empty()) {
|
||||
LogLine("web redirect: module dir unresolved, using built-in defaults");
|
||||
return;
|
||||
}
|
||||
const std::wstring ini = dir + kIniName;
|
||||
if (GetFileAttributesW(ini.c_str()) == INVALID_FILE_ATTRIBUTES) {
|
||||
LogLine("web redirect: %ls not found, defaults enabled=1 url=%ls", ini.c_str(), g_cfg.url.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
const UINT enabled = GetPrivateProfileIntW(L"web", L"enabled", 1, ini.c_str());
|
||||
const UINT force = GetPrivateProfileIntW(L"web", L"force", 0, ini.c_str());
|
||||
const UINT delay = GetPrivateProfileIntW(L"web", L"delay", 1200, ini.c_str());
|
||||
const UINT external = GetPrivateProfileIntW(L"web", L"external", 1, ini.c_str());
|
||||
const UINT suppress = GetPrivateProfileIntW(L"web", L"suppress", 1, ini.c_str());
|
||||
const UINT closeAction = GetPrivateProfileIntW(L"web", L"close_action", 0, ini.c_str());
|
||||
const UINT minimizeGame = GetPrivateProfileIntW(L"web", L"minimize_game", 0, ini.c_str());
|
||||
const UINT width = GetPrivateProfileIntW(L"web", L"width", 1040, ini.c_str());
|
||||
const UINT height = GetPrivateProfileIntW(L"web", L"height", 720, ini.c_str());
|
||||
|
||||
wchar_t buf[1024] = {};
|
||||
GetPrivateProfileStringW(L"web", L"url", L"http://127.0.0.1:18112/", buf,
|
||||
static_cast<DWORD>(sizeof(buf) / sizeof(buf[0])), ini.c_str());
|
||||
|
||||
wchar_t browser[1024] = {};
|
||||
GetPrivateProfileStringW(L"web", L"browser", L"", browser,
|
||||
static_cast<DWORD>(sizeof(browser) / sizeof(browser[0])), ini.c_str());
|
||||
|
||||
g_cfg.enabled = enabled != 0;
|
||||
g_cfg.force = force != 0;
|
||||
g_cfg.external = external != 0;
|
||||
g_cfg.suppress = suppress != 0;
|
||||
g_cfg.closePanel = closeAction != 0;
|
||||
g_cfg.minimizeGame = minimizeGame != 0;
|
||||
g_cfg.delayMs = delay > 200 ? static_cast<int>(delay) : 200;
|
||||
g_cfg.width = width >= 640 ? static_cast<int>(width) : 1040;
|
||||
g_cfg.height = height >= 480 ? static_cast<int>(height) : 720;
|
||||
if (buf[0]) g_cfg.url = buf;
|
||||
if (browser[0]) g_cfg.browser = browser;
|
||||
|
||||
LogLine("web redirect: config enabled=%d external=%d suppress=%d force=%d delay=%d size=%dx%d url=%ls",
|
||||
g_cfg.enabled ? 1 : 0, g_cfg.external ? 1 : 0, g_cfg.suppress ? 1 : 0,
|
||||
g_cfg.force ? 1 : 0, g_cfg.delayMs, g_cfg.width, g_cfg.height, g_cfg.url.c_str());
|
||||
}
|
||||
|
||||
bool IsLocalUrl(const std::wstring& url)
|
||||
{
|
||||
// 只认 "http://127.0.0.1:<port>/" 前缀,用来判断当前文档是否已经在我们的页面上。
|
||||
if (g_cfg.url.empty() || url.size() < g_cfg.url.size()) return false;
|
||||
return _wcsnicmp(url.c_str(), g_cfg.url.c_str(), g_cfg.url.size()) == 0;
|
||||
}
|
||||
|
||||
struct FindContext {
|
||||
DWORD pid;
|
||||
HWND found;
|
||||
};
|
||||
|
||||
BOOL CALLBACK OnChildWindow(HWND hwnd, LPARAM param)
|
||||
{
|
||||
FindContext* ctx = reinterpret_cast<FindContext*>(param);
|
||||
wchar_t cls[128] = {};
|
||||
if (GetClassNameW(hwnd, cls, 128) && _wcsicmp(cls, kIeServerClass) == 0) {
|
||||
DWORD pid = 0;
|
||||
GetWindowThreadProcessId(hwnd, &pid);
|
||||
if (pid == ctx->pid) {
|
||||
ctx->found = hwnd;
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
BOOL CALLBACK OnTopWindow(HWND hwnd, LPARAM param)
|
||||
{
|
||||
FindContext* ctx = reinterpret_cast<FindContext*>(param);
|
||||
DWORD pid = 0;
|
||||
GetWindowThreadProcessId(hwnd, &pid);
|
||||
if (pid != ctx->pid) return TRUE;
|
||||
EnumChildWindows(hwnd, OnChildWindow, param);
|
||||
return ctx->found ? FALSE : TRUE;
|
||||
}
|
||||
|
||||
HWND FindIeServerWindow()
|
||||
{
|
||||
FindContext ctx = { GetCurrentProcessId(), nullptr };
|
||||
EnumWindows(OnTopWindow, reinterpret_cast<LPARAM>(&ctx));
|
||||
return ctx.found;
|
||||
}
|
||||
|
||||
// 小井盖面板的顶层窗口。类名是 "Browser Example",但不同版本可能换壳,
|
||||
// 所以退一步:只要能包含 Internet Explorer_Server 子窗口,也算。
|
||||
BOOL CALLBACK OnPanelWindow(HWND hwnd, LPARAM param)
|
||||
{
|
||||
FindContext* ctx = reinterpret_cast<FindContext*>(param);
|
||||
DWORD pid = 0;
|
||||
GetWindowThreadProcessId(hwnd, &pid);
|
||||
if (pid != ctx->pid) return TRUE;
|
||||
|
||||
wchar_t cls[128] = {};
|
||||
if (GetClassNameW(hwnd, cls, 128) && _wcsicmp(cls, L"Browser Example") == 0) {
|
||||
ctx->found = hwnd;
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
FindContext sub = { ctx->pid, nullptr };
|
||||
EnumChildWindows(hwnd, OnChildWindow, reinterpret_cast<LPARAM>(&sub));
|
||||
if (sub.found) {
|
||||
ctx->found = hwnd;
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
HWND FindWebPanelWindow()
|
||||
{
|
||||
FindContext ctx = { GetCurrentProcessId(), nullptr };
|
||||
EnumWindows(OnPanelWindow, reinterpret_cast<LPARAM>(&ctx));
|
||||
return ctx.found;
|
||||
}
|
||||
|
||||
// 游戏主窗口:本进程里「可见 + 基本铺满整个显示器」的那个顶层窗口。
|
||||
// 用来判断是不是独占全屏 —— 那种情况下普通窗口会被游戏画面压住,看不见。
|
||||
BOOL CALLBACK OnGameWindow(HWND hwnd, LPARAM param)
|
||||
{
|
||||
FindContext* ctx = reinterpret_cast<FindContext*>(param);
|
||||
DWORD pid = 0;
|
||||
GetWindowThreadProcessId(hwnd, &pid);
|
||||
if (pid != ctx->pid) return TRUE;
|
||||
if (!IsWindowVisible(hwnd)) return TRUE;
|
||||
if (GetWindow(hwnd, GW_OWNER)) return TRUE;
|
||||
|
||||
wchar_t cls[128] = {};
|
||||
if (GetClassNameW(hwnd, cls, 128)) {
|
||||
if (_wcsicmp(cls, L"90CN_GMHostWindow") == 0) return TRUE;
|
||||
if (_wcsicmp(cls, L"Browser Example") == 0) return TRUE;
|
||||
}
|
||||
|
||||
RECT rc = {};
|
||||
if (!GetWindowRect(hwnd, &rc)) return TRUE;
|
||||
MONITORINFO mi = {};
|
||||
mi.cbSize = sizeof(mi);
|
||||
if (!GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &mi)) return TRUE;
|
||||
|
||||
const int mw = mi.rcMonitor.right - mi.rcMonitor.left;
|
||||
const int mh = mi.rcMonitor.bottom - mi.rcMonitor.top;
|
||||
const int w = rc.right - rc.left;
|
||||
const int h = rc.bottom - rc.top;
|
||||
if (mw <= 0 || mh <= 0) return TRUE;
|
||||
if (w * 10 >= mw * 9 && h * 10 >= mh * 9) {
|
||||
ctx->found = hwnd;
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
HWND FindGameWindow()
|
||||
{
|
||||
FindContext ctx = { GetCurrentProcessId(), nullptr };
|
||||
EnumWindows(OnGameWindow, reinterpret_cast<LPARAM>(&ctx));
|
||||
return ctx.found;
|
||||
}
|
||||
|
||||
// 独占全屏时普通窗口被压在游戏画面下面,先把游戏最小化,控制台才真的看得见。
|
||||
// 关掉控制台再还原,游戏回到原来的全屏状态。
|
||||
HWND g_minimizedGame = nullptr;
|
||||
|
||||
void MakeRoomForConsole()
|
||||
{
|
||||
if (!g_cfg.minimizeGame) return;
|
||||
HWND game = FindGameWindow();
|
||||
if (!game) return;
|
||||
if (g_minimizedGame == game) return;
|
||||
ShowWindow(game, SW_MINIMIZE);
|
||||
g_minimizedGame = game;
|
||||
LogLine("web window: game minimized for console hwnd=%p (fullscreen)", (void*)game);
|
||||
}
|
||||
|
||||
void RestoreGameFromConsole()
|
||||
{
|
||||
if (!g_minimizedGame) return;
|
||||
if (IsWindow(g_minimizedGame)) {
|
||||
ShowWindow(g_minimizedGame, SW_RESTORE);
|
||||
SetForegroundWindow(g_minimizedGame);
|
||||
LogLine("web window: game restored hwnd=%p", (void*)g_minimizedGame);
|
||||
}
|
||||
g_minimizedGame = nullptr;
|
||||
}
|
||||
|
||||
// 我们自己的窗口:Chromium 的 app 窗口类名固定是 Chrome_WidgetWin_1,
|
||||
// 靠「类名 + 标题」双条件识别,避免和游戏内嵌面板(标题同为页面标题)撞车。
|
||||
BOOL CALLBACK OnOwnWindow(HWND hwnd, LPARAM param)
|
||||
{
|
||||
HWND* out = reinterpret_cast<HWND*>(param);
|
||||
if (!IsWindowVisible(hwnd)) return TRUE;
|
||||
|
||||
wchar_t cls[128] = {};
|
||||
if (!GetClassNameW(hwnd, cls, 128)) return TRUE;
|
||||
if (_wcsnicmp(cls, L"Chrome_WidgetWin", 16) != 0) return TRUE;
|
||||
|
||||
wchar_t title[256] = {};
|
||||
if (!GetWindowTextW(hwnd, title, 256)) return TRUE;
|
||||
if (_wcsicmp(title, kOwnWindowTitle) != 0) return TRUE;
|
||||
|
||||
*out = hwnd;
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
HWND FindOwnWindow()
|
||||
{
|
||||
HWND found = nullptr;
|
||||
EnumWindows(OnOwnWindow, reinterpret_cast<LPARAM>(&found));
|
||||
return found;
|
||||
}
|
||||
|
||||
std::wstring EnvValue(const wchar_t* name)
|
||||
{
|
||||
wchar_t buf[MAX_PATH] = {};
|
||||
const DWORD got = GetEnvironmentVariableW(name, buf, MAX_PATH);
|
||||
return got ? std::wstring(buf, got) : std::wstring();
|
||||
}
|
||||
|
||||
bool FileExists(const std::wstring& path)
|
||||
{
|
||||
return !path.empty() && GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES;
|
||||
}
|
||||
|
||||
// 优先用 Edge(Win10/11 自带),没有再找 Chrome。
|
||||
std::wstring FindBrowser()
|
||||
{
|
||||
if (!g_cfg.browser.empty() && FileExists(g_cfg.browser)) return g_cfg.browser;
|
||||
|
||||
const std::wstring pf = EnvValue(L"ProgramFiles");
|
||||
const std::wstring pf86 = EnvValue(L"ProgramFiles(x86)");
|
||||
const std::wstring lad = EnvValue(L"LOCALAPPDATA");
|
||||
|
||||
const std::wstring candidates[] = {
|
||||
pf86 + L"\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
pf + L"\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
pf86 + L"\\Google\\Chrome\\Application\\chrome.exe",
|
||||
pf + L"\\Google\\Chrome\\Application\\chrome.exe",
|
||||
lad + L"\\Google\\Chrome\\Application\\chrome.exe",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
|
||||
if (FileExists(candidates[i])) return candidates[i];
|
||||
}
|
||||
return std::wstring();
|
||||
}
|
||||
|
||||
// 给独立窗口单独开一个浏览器配置目录,避免和玩家日常浏览器的进程/配置打架:
|
||||
// 只有独立目录,才能真正意义上“关窗口 = 进程退出”,也才不会占用他的登录态。
|
||||
std::wstring WindowProfileDir()
|
||||
{
|
||||
const std::wstring base = EnvValue(L"LOCALAPPDATA");
|
||||
if (base.empty()) return std::wstring();
|
||||
const std::wstring root = base + L"\\90CN";
|
||||
CreateDirectoryW(root.c_str(), nullptr);
|
||||
const std::wstring dir = root + L"\\gmwin";
|
||||
CreateDirectoryW(dir.c_str(), nullptr);
|
||||
return dir;
|
||||
}
|
||||
|
||||
bool LaunchOwnWindow()
|
||||
{
|
||||
const std::wstring browser = FindBrowser();
|
||||
if (browser.empty()) {
|
||||
LogLine("web window: no Edge/Chrome found");
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::wstring profile = WindowProfileDir();
|
||||
if (profile.empty()) {
|
||||
LogLine("web window: LOCALAPPDATA missing, cannot create profile dir");
|
||||
return false;
|
||||
}
|
||||
|
||||
wchar_t sizeArg[64] = {};
|
||||
wsprintfW(sizeArg, L"--window-size=%d,%d", g_cfg.width, g_cfg.height);
|
||||
|
||||
std::wstring cmd = L"\"";
|
||||
cmd += browser;
|
||||
cmd += L"\" --app=";
|
||||
cmd += g_cfg.url;
|
||||
cmd += L" ";
|
||||
cmd += sizeArg;
|
||||
cmd += L" --user-data-dir=\"";
|
||||
cmd += profile;
|
||||
cmd += L"\" --no-first-run --no-default-browser-check --disable-sync"
|
||||
L" --disable-background-mode --lang=zh-CN";
|
||||
|
||||
STARTUPINFOW si = {};
|
||||
si.cb = sizeof(si);
|
||||
PROCESS_INFORMATION pi = {};
|
||||
std::wstring dir = ModuleDirectory();
|
||||
const BOOL ok = CreateProcessW(nullptr, &cmd[0], nullptr, nullptr, FALSE,
|
||||
CREATE_UNICODE_ENVIRONMENT, nullptr, dir.empty() ? nullptr : dir.c_str(), &si, &pi);
|
||||
if (!ok) {
|
||||
LogLine("web window: CreateProcess failed err=%u browser=%ls", GetLastError(), browser.c_str());
|
||||
return false;
|
||||
}
|
||||
CloseHandle(pi.hThread);
|
||||
CloseHandle(pi.hProcess);
|
||||
LogLine("web window: launched pid=%u profile=%ls", pi.dwProcessId, profile.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 输入法
|
||||
//
|
||||
// 症状:页面里的输入框只能打字母,输入法候选框不出现。
|
||||
// 原因:IME 上下文是窗口级的,DNF 主窗口把它握在自己手里,内嵌 IE 控件
|
||||
// 这条窗口链上没有可用的上下文,输入法转换根本不会发生。
|
||||
// 做法分三步,缺一不可:
|
||||
// 1. AttachThreadInput 到控件所属线程 —— 跨线程直接改别人的 IME 关联
|
||||
// 会“返回成功但实际不生效”,这是第一次改完日志打 ok 却仍无候选框的原因;
|
||||
// 2. 取窗口当前上下文,没有就 ImmCreateContext 建一个挂上去;
|
||||
// 3. 显式 ImmSetOpenStatus(TRUE) + ImmSetConversionStatus(NATIVE),
|
||||
// 把上下文推到“中文打开”状态,否则候选框还是不出来。
|
||||
|
||||
struct ImeProbe {
|
||||
DWORD tid;
|
||||
BOOL attached;
|
||||
HIMC existing;
|
||||
BOOL open;
|
||||
DWORD conversion;
|
||||
DWORD sentence;
|
||||
HKL layout;
|
||||
wchar_t layoutName[KL_NAMELENGTH];
|
||||
};
|
||||
|
||||
void ProbeIme(HWND hwnd, const wchar_t* tag, bool verbose = true)
|
||||
{
|
||||
if (!hwnd) return;
|
||||
|
||||
const DWORD self = GetCurrentThreadId();
|
||||
ImeProbe probe = {};
|
||||
probe.tid = GetWindowThreadProcessId(hwnd, nullptr);
|
||||
if (probe.tid && probe.tid != self) {
|
||||
probe.attached = AttachThreadInput(self, probe.tid, TRUE);
|
||||
}
|
||||
|
||||
probe.existing = ImmGetContext(hwnd);
|
||||
if (probe.existing) {
|
||||
probe.open = ImmGetOpenStatus(probe.existing);
|
||||
ImmGetConversionStatus(probe.existing, &probe.conversion, &probe.sentence);
|
||||
ImmReleaseContext(hwnd, probe.existing);
|
||||
}
|
||||
probe.layout = probe.tid ? GetKeyboardLayout(probe.tid) : nullptr;
|
||||
GetKeyboardLayoutNameW(probe.layoutName);
|
||||
|
||||
if (verbose) {
|
||||
LogLine("web ime: probe %ls hwnd=%p tid=%u attach=%d ctx=%p open=%d conv=0x%X sent=0x%X hkl=0x%X kl=%ls",
|
||||
tag, (void*)hwnd, probe.tid, probe.attached ? 1 : 0, (void*)probe.existing,
|
||||
probe.open ? 1 : 0, probe.conversion, probe.sentence,
|
||||
(unsigned)(UINT_PTR)probe.layout, probe.layoutName[0] ? probe.layoutName : L"-");
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
// 只在首次接管时把输入法推到中文打开状态。之后不再干预,
|
||||
// 否则玩家自己切到英文会被我们每 700ms 强行掰回来。
|
||||
if (probe.existing) {
|
||||
HIMC ctx = ImmGetContext(hwnd);
|
||||
if (ctx) {
|
||||
ImmSetOpenStatus(ctx, TRUE);
|
||||
DWORD conversion = 0, sentence = 0;
|
||||
ImmGetConversionStatus(ctx, &conversion, &sentence);
|
||||
ImmSetConversionStatus(ctx, conversion | IME_CMODE_NATIVE, sentence);
|
||||
ImmReleaseContext(hwnd, ctx);
|
||||
LogLine("web ime: opened existing context hwnd=%p", (void*)hwnd);
|
||||
}
|
||||
} else {
|
||||
HIMC created = ImmCreateContext();
|
||||
if (!created) {
|
||||
// ImmCreateContext 失败基本等于本线程被 ImmDisableIME 掉了。
|
||||
LogLine("web ime: ImmCreateContext failed hwnd=%p err=%u (thread IME disabled?)",
|
||||
(void*)hwnd, GetLastError());
|
||||
} else if (ImmAssociateContext(hwnd, created)) {
|
||||
ImmSetOpenStatus(created, TRUE);
|
||||
ImmSetConversionStatus(created, IME_CMODE_NATIVE, IME_SMODE_NONE);
|
||||
LogLine("web ime: created+associated context hwnd=%p ctx=%p", (void*)hwnd, (void*)created);
|
||||
} else {
|
||||
ImmDestroyContext(created);
|
||||
LogLine("web ime: ImmAssociateContext failed hwnd=%p err=%u", (void*)hwnd, GetLastError());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (probe.attached) {
|
||||
AttachThreadInput(self, probe.tid, FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
bool EnsureImeChain(HWND hwnd, bool verbose)
|
||||
{
|
||||
if (!hwnd) return false;
|
||||
// 只给 IE_Server 挂还不够:焦点落在父窗口(Browser Example)时,
|
||||
// 输入法又会被游戏主窗口抢回去,所以父链一起处理。
|
||||
HWND chain[3] = { hwnd, GetParent(hwnd), nullptr };
|
||||
if (chain[1]) chain[2] = GetParent(chain[1]);
|
||||
bool any = false;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (!chain[i]) continue;
|
||||
ProbeIme(chain[i], i == 0 ? L"ie" : (i == 1 ? L"doc" : L"host"), verbose);
|
||||
any = true;
|
||||
}
|
||||
return any;
|
||||
}
|
||||
|
||||
// 取文档对象。WM_HTML_GETOBJECT 的返回值经 ObjectFromLresult 还原成接口指针。
|
||||
IHTMLDocument2* GetDocument(HWND hwnd)
|
||||
{
|
||||
static const UINT msg = RegisterWindowMessageW(L"WM_HTML_GETOBJECT");
|
||||
if (!msg) return nullptr;
|
||||
|
||||
DWORD_PTR result = 0;
|
||||
const LRESULT ok = SendMessageTimeoutW(hwnd, msg, 0, 0,
|
||||
SMTO_ABORTIFHUNG | SMTO_BLOCK, 4000, &result);
|
||||
if (!ok || !result) return nullptr;
|
||||
|
||||
IHTMLDocument2* doc = nullptr;
|
||||
if (FAILED(ObjectFromLresult(static_cast<LRESULT>(result), IID_IHTMLDocument2, 0,
|
||||
reinterpret_cast<void**>(&doc)))) {
|
||||
return nullptr;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
std::wstring DocumentUrl(IHTMLDocument2* doc)
|
||||
{
|
||||
BSTR url = nullptr;
|
||||
std::wstring out;
|
||||
if (doc && SUCCEEDED(doc->get_URL(&url)) && url) {
|
||||
out.assign(url, SysStringLen(url));
|
||||
}
|
||||
if (url) SysFreeString(url);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool NavigateDocument(IHTMLDocument2* doc)
|
||||
{
|
||||
IHTMLWindow2* win = nullptr;
|
||||
if (FAILED(doc->get_parentWindow(&win)) || !win) return false;
|
||||
|
||||
// 用 location.replace 而不是 href:不往历史里塞记录,避免用户按返回键回到原页。
|
||||
std::wstring script = L"location.replace('";
|
||||
script += g_cfg.url;
|
||||
script += L"')";
|
||||
|
||||
BSTR code = SysAllocString(script.c_str());
|
||||
BSTR lang = SysAllocString(L"JavaScript");
|
||||
VARIANT ret;
|
||||
VariantInit(&ret);
|
||||
HRESULT hr = win->execScript(code, lang, &ret);
|
||||
VariantClear(&ret);
|
||||
SysFreeString(code);
|
||||
SysFreeString(lang);
|
||||
win->Release();
|
||||
return SUCCEEDED(hr);
|
||||
}
|
||||
|
||||
// 老行为:把内嵌 IE 的文档导航到本地页面。
|
||||
void InlineRedirectPass(HWND ieHwnd, HWND* lastHandled)
|
||||
{
|
||||
if (!ieHwnd) {
|
||||
*lastHandled = nullptr;
|
||||
return;
|
||||
}
|
||||
if (ieHwnd == *lastHandled) {
|
||||
if (!g_cfg.force) return;
|
||||
// force 模式下防止用户点原生标签页跳走。
|
||||
IHTMLDocument2* doc = GetDocument(ieHwnd);
|
||||
if (doc) {
|
||||
if (!IsLocalUrl(DocumentUrl(doc))) {
|
||||
if (NavigateDocument(doc)) LogLine("web redirect: pulled back to %ls", g_cfg.url.c_str());
|
||||
}
|
||||
doc->Release();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
LogLine("web redirect: IE_Server window found hwnd=%p, waiting %dms", (void*)ieHwnd, g_cfg.delayMs);
|
||||
// 等原生导航把文档对象建好,太早拿会拿到 about:blank。
|
||||
for (int waited = 0; waited < g_cfg.delayMs && !g_stop; waited += 100) {
|
||||
Sleep(100);
|
||||
}
|
||||
|
||||
IHTMLDocument2* doc = GetDocument(ieHwnd);
|
||||
if (!doc) {
|
||||
LogLine("web redirect: document object unavailable hwnd=%p", (void*)ieHwnd);
|
||||
*lastHandled = ieHwnd;
|
||||
return;
|
||||
}
|
||||
const std::wstring current = DocumentUrl(doc);
|
||||
LogLine("web redirect: current url=%ls", current.c_str());
|
||||
if (IsLocalUrl(current)) {
|
||||
LogLine("web redirect: already on local page, skip");
|
||||
} else if (NavigateDocument(doc)) {
|
||||
LogLine("web redirect: navigated to %ls", g_cfg.url.c_str());
|
||||
} else {
|
||||
LogLine("web redirect: execScript failed hwnd=%p", (void*)ieHwnd);
|
||||
}
|
||||
doc->Release();
|
||||
*lastHandled = ieHwnd; // 失败也不无限重试同一窗口
|
||||
}
|
||||
|
||||
// 窗口方案。自绘窗口依赖 WebView2,缺运行时/初始化失败时依次退到
|
||||
// Edge 应用窗口、内嵌 IE 跳转,任何环境都不至于没得用。
|
||||
enum WindowMode {
|
||||
MODE_GM_WINDOW = 0, // 自绘外壳 + WebView2(默认)
|
||||
MODE_APP_WINDOW, // Edge/Chrome 应用窗口
|
||||
MODE_INLINE, // 老的内嵌 IE 跳转
|
||||
};
|
||||
|
||||
bool OwnWindowVisible(int mode)
|
||||
{
|
||||
if (mode == MODE_GM_WINDOW) return GmWindowIsVisible();
|
||||
if (mode == MODE_APP_WINDOW) return FindOwnWindow() != nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
DWORD WINAPI WorkerThread(void*)
|
||||
{
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
|
||||
HWND lastHandled = nullptr;
|
||||
HWND imeHwnd = nullptr;
|
||||
unsigned imeTick = 0;
|
||||
|
||||
bool ownShown = false;
|
||||
int mode = g_cfg.external ? MODE_GM_WINDOW : MODE_INLINE;
|
||||
DWORD lastPoll = 0;
|
||||
DWORD lastLaunch = 0;
|
||||
// 玩家关掉控制台之后的抑制期:这段时间里面板只要露头就继续藏、继续请它关闭,
|
||||
// 免得游戏的关闭动画把旧页面又画出来(残留)。
|
||||
DWORD suppressUntil = 0;
|
||||
int closeAttempts = 0;
|
||||
|
||||
for (;;) {
|
||||
if (InterlockedCompareExchange(&g_stop, 0, 0) != 0) break;
|
||||
|
||||
const DWORD now = GetTickCount();
|
||||
const bool suppressing = suppressUntil && now < suppressUntil;
|
||||
|
||||
// WebView2 要靠消息泵跑起来,窗口开着时必须泵得勤,否则页面会顿。
|
||||
GmPumpMessages((ownShown || suppressing) ? 40 : 120);
|
||||
|
||||
// 原面板绝不能出现在画面上,所以轮询要快;空闲时放宽一点省 CPU。
|
||||
const DWORD interval = (ownShown || suppressing) ? 40 : 60;
|
||||
if (now - lastPoll < interval) continue;
|
||||
lastPoll = now;
|
||||
|
||||
const HWND panel = FindWebPanelWindow();
|
||||
const bool panelVisible = panel != nullptr && IsWindowVisible(panel) != FALSE;
|
||||
|
||||
if (mode == MODE_INLINE) {
|
||||
HWND ieHwnd = FindIeServerWindow();
|
||||
if (ieHwnd) {
|
||||
const bool first = (ieHwnd != imeHwnd);
|
||||
if (first || (++imeTick % 8) == 0) {
|
||||
EnsureImeChain(ieHwnd, first);
|
||||
if (first) imeHwnd = ieHwnd;
|
||||
}
|
||||
} else {
|
||||
imeHwnd = nullptr;
|
||||
}
|
||||
InlineRedirectPass(ieHwnd, &lastHandled);
|
||||
if (!panel) lastHandled = nullptr;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 控制台开着:原面板全程压制,一露头就藏,绝不让旧 IE 页面出现在画面上。
|
||||
if (ownShown) {
|
||||
if (!OwnWindowVisible(mode)) {
|
||||
// 玩家把控制台关了:直接把原面板关掉并进入抑制期,不再放回游戏,
|
||||
// 这样游戏自己的关闭动画不会把旧页面又画出来(残留)。
|
||||
ownShown = false;
|
||||
if (panel) {
|
||||
if (panelVisible) ShowWindow(panel, SW_HIDE);
|
||||
if (g_cfg.closePanel) {
|
||||
PostMessageW(panel, WM_CLOSE, 0, 0);
|
||||
suppressUntil = now + 2500;
|
||||
closeAttempts = 1;
|
||||
LogLine("web window: console closed, panel closed hwnd=%p", (void*)panel);
|
||||
} else {
|
||||
ShowWindow(panel, SW_SHOW);
|
||||
LogLine("web window: console closed, panel restored hwnd=%p", (void*)panel);
|
||||
}
|
||||
}
|
||||
RestoreGameFromConsole();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (panelVisible) {
|
||||
ShowWindow(panel, SW_HIDE);
|
||||
LogLine("web window: panel suppressed hwnd=%p", (void*)panel);
|
||||
}
|
||||
// 全屏游戏会抢窗口栈,每轮确认一次置顶;同时跟着游戏窗口尺寸走
|
||||
GmWindowFollowGame();
|
||||
GmWindowAssertTop();
|
||||
continue;
|
||||
}
|
||||
|
||||
// 抑制期:面板只要还露着就继续藏、继续请它关闭,期间不重新弹控制台。
|
||||
if (suppressUntil) {
|
||||
if (!panel || now > suppressUntil) {
|
||||
suppressUntil = 0;
|
||||
closeAttempts = 0;
|
||||
} else {
|
||||
if (panelVisible) {
|
||||
ShowWindow(panel, SW_HIDE);
|
||||
if (g_cfg.closePanel && closeAttempts < 4) {
|
||||
PostMessageW(panel, WM_CLOSE, 0, 0);
|
||||
++closeAttempts;
|
||||
}
|
||||
LogLine("web window: panel re-hidden after close hwnd=%p attempts=%d",
|
||||
(void*)panel, closeAttempts);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 面板不存在,或者存在但被游戏自己藏着 —— 都不该弹控制台。
|
||||
// 只有「玩家点了小井盖、面板真的显示出来」才算一次打开请求。
|
||||
if (!panelVisible) continue;
|
||||
|
||||
if (mode == MODE_GM_WINDOW) {
|
||||
if (GmWindowFailed()) {
|
||||
LogLine("web window: webview unavailable, switching to app window");
|
||||
mode = MODE_APP_WINDOW;
|
||||
continue;
|
||||
}
|
||||
// 先藏面板再建窗口,避免旧页面在中间闪一下
|
||||
ShowWindow(panel, SW_HIDE);
|
||||
if (GmWindowShow(g_cfg.url.c_str(), g_cfg.width, g_cfg.height)) {
|
||||
ownShown = true;
|
||||
// 独占全屏时先让游戏让位,否则控制台会被游戏画面压住
|
||||
MakeRoomForConsole();
|
||||
LogLine("web window: gm window shown, panel suppressed hwnd=%p", (void*)panel);
|
||||
} else {
|
||||
LogLine("web window: gm window show failed hwnd=%p", (void*)panel);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// MODE_APP_WINDOW
|
||||
if (now - lastLaunch > 3000) {
|
||||
lastLaunch = now;
|
||||
ShowWindow(panel, SW_HIDE);
|
||||
if (LaunchOwnWindow()) {
|
||||
ownShown = true;
|
||||
ShowWindow(panel, SW_HIDE);
|
||||
LogLine("web window: app window launched");
|
||||
} else {
|
||||
mode = MODE_INLINE;
|
||||
LogLine("web window: app window failed, fallback to in-page redirect");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CoUninitialize();
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" void InstallWebRedirect()
|
||||
{
|
||||
LoadConfig();
|
||||
if (!g_cfg.enabled) {
|
||||
LogLine("web redirect: disabled by config");
|
||||
return;
|
||||
}
|
||||
if (g_cfg.url.find(L'\'') != std::wstring::npos) {
|
||||
LogLine("web redirect: url contains a quote, refused");
|
||||
return;
|
||||
}
|
||||
|
||||
InterlockedExchange(&g_stop, 0);
|
||||
HANDLE thread = CreateThread(nullptr, 0, WorkerThread, nullptr, 0, &g_threadId);
|
||||
if (!thread) {
|
||||
LogLine("web redirect: CreateThread failed err=%u", GetLastError());
|
||||
return;
|
||||
}
|
||||
CloseHandle(thread);
|
||||
LogLine("web redirect: worker started tid=%u target=%ls", g_threadId, g_cfg.url.c_str());
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
'���
|
||||
�
|
||||
�
|
||||
�
|
||||
�
|
||||
�
|
||||
�
|
||||
�
|
||||
�
���������������������������������������������������������������������������������������������#%���
�
�
�
�
�
�
�
�
�
��뇦�MNN�MNN�MNN�utr�utr�utr�MNN�MNN�MNN�MNN�뇦�
|
||||
�������������������������������������������������������������������������������������������� � �뇦�MNN�MNN�MNN�MNN�utr�utr�utr�MNN�MNN�MNN�뇦� � � � � � � � � � � � �뇦�
|
||||
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
� �뇦� � � � � � � � � � � � ���撞�����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������utr�utr�MNN�MNN�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�MNN�MNN�MNN�MNN�MNN�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�MNN�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�MNN�MNN�MNN�MNN�MNN�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�MNN�MNN�utr�utr� �MNN�MNN� �utr� � �utr� � � � � � �MNN� � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � �MNN� � � � � � �utr� � �utr� �MNN�MNN� � �뇦� �MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN� � �蔽$�遡q�蔽$�蔽$�蔽$�駭)�駭)�駭)�蔽$�駭)�駭)�駭)�駭)�駭)�蔽$�駭)�駭)�蔽$�蔽$�蔽$�蔽$�駭)�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�駭)�駭)�蔽$�蔽$�蔽$�蔽$�蔽$�駭)�駭)�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�駭)�蔽$�蔽$�蔽$�蔽$�蔽$�駭)�蔽$�駭)�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�駭)�蔽$�蔽$�蔽$�蔽$�駭)�駭)�蔽$�駭)�駭)�駭)�駭)�駭)�蔽$�駭)�駭)�駭)�蔽$�蔽$�蔽$�遡q�蔽$� � �MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN� �뇦� �MNN�utr�MNN�MNN� � � � � � � � � � � �蔽$�駭)�駭)��+��+�棋_�棋_�棋_�棋_��+��s*��s*��s*��s*��x,��+��s*��s*��+��+��+��x,��+��+��s*��x,��x,��+��s*��+��s*��+��x,��x,��+��+��+��+��+��+��s*��s*��+��+��+��+��+��x,��s*��s*��+��x,��+��+��s*��s*��+��+��+��s*��+��x,��x,��s*��+��+��x,��+��+��+��s*��s*��+��x,��s*��s*��s*��s*��+�棋_�棋_�棋_�棋_��+��+�駭)�駭)�蔽$� � � � � � � � � � � �MNN�MNN�utr�MNN� �MNN� �MNN� �蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�駭)�駭)��+��+��s*�쮟3�똣4�냍0�냍0�냍0�냍0�똣4�켷G�켷G���������oP1�����냍0�냍0�냍0�냍0�냍0�냍0�냍0�����냍0�냍0�냍0�냍0�����냍0�oP1�냍0�냍0�냍0�냍0�����냍0�냍0�냍0�냍0�����냍0�����냍0�냍0�냍0�냍0�냍0�냍0�냍0�oP1�oP1�����냍0�냍0�냍0�냍0�냍0�냍0�oP1�냍0�����냍0�냍0�냍0�냍0�냍0�냍0�냍0�냍0�냍0�����냍0�냍0�냍0�����냍0�똣4�켷G�켷G�똣4�냍0�냍0�냍0�냍0�똣4�쮟3��s*��+��+�駭)�駭)�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$� �MNN� �MNN� � �뇦� �MNN� �蔽$�蔽$�駭)��+��+��+��+��s*�쮟3�쮟3�냍0�oP1�M,�M5(�M5(�M5(�M5(�M5(�M5(�������������M5(�M5(�����M5(�0$�����M5(�M5(�M5(�����M5(�M5(�M5(�M5(�M5(�����M5(�M5(�M5(�M5(�M5(�M5(�����M5(�M5(�M5(�M5(�����0$�M5(�����M5(�M5(�0$�M5(�����M5(�M5(�M5(�����0$�M,�0$�M5(�M5(�M5(�M5(�M5(�����M5(�M5(�M5(�M5(�M5(�M5(�M5(�M5(�M5(�����0$�����������������������������M5(�M5(�M5(�M5(�M5(�M5(�M,�oP1�냍0�쮟3�쮟3��s*��+��+��+��+�駭)�蔽$�蔽$� �MNN� �뇦� �MNN�뇦� �MNN� �蔽$��+��s*�쮟3�냍0�냍0�냍0�냍0�sK�M,�M5(�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�����,**�,**����� � � �����,**���������������������,**�,**�����������������,**�,**�,**�������������,**�,**����� �,**�,**�,**�,**�,**� �,**����� �,**�����,**� �,**�,**�,**�,**�,**�,**�����,**�,**� �,**�,**�,**�,**�,**����� � � �,**�����,**� �,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�M5(�M,�sK�냍0�냍0�냍0�냍0�쮟3��s*��+�蔽$� �MNN� �뇦�MNN� �MNN�MNN� � �駭)��s*�냍0�YF/�M5(�M5(�YF/�0$�M5(�,**�,**�)�,**�,**�,**� �,**�,**�,**�,**�,**�����,**� �����,**� �,**� � �����,**�,**�,**�����,**�����,**�,**�,**�����,**� � �����,**�,**�,**�,**�����������������,**�,**�,**�,**�,**�,**�����,**�����,**�,**� �,**�����������������������������������������,**�,**� �����,**� �������������������������,**�,**�,**�,**� �,**�,**�,**�)�h딧�h딧�M5(�0$�YF/�M5(�h딧�h딧�냍0��s*�駭)� � �MNN�MNN� � �MNN� � �蔽$��+�냍0�M5(�,**� �,**�,**� �,**� �,**�,**�,**�,**� �,**� �,**��������������������������������������������� �����,**�,**�,**���������,**�,**�,**�,**�����,**�,**�,**�����,**�,**������������� �,**�,**� �,**�����,**�,**�,**�,**�,**����� �,**�,**�,**�,**�,**�,**�,**�����,**�,**�,**�,**�����,**�,**Line truncated
|
||||
�
|
||||
�
|
||||
�
|
||||
�%FO�U「�Q넄�+"�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �0�0�2!�2!�2!�2!�2!�0�/�0�0�*�*�*�*�*�*�*�(�*�(�+�+�(�(�(�*�*�*�(�(�(�*�*�,�(�(�(�*�*�(�*�(�*�*�*�*�*�*�*�(�*�(�+�+�(�(�(�*�*�*�(�(�(�*�*�,�(�(�(�*�*�(�*�(�*�*�*�*�*�*�*�(�*�(�+�+�(�(�(�+�[윸�2`m�
|
||||
�
|
||||
)Z�Q�b�b�Q�
|
||||
)Z�
|
||||
�2`m�[〕�+�)�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �/�0�2!�2!�2!�0�0�0�0�0�0�/�,�,�*�*�*�*�(�*�(�*�+�(�*�*�*�*�(�(�(�(�(�*�*�+�+�(�*�*�(�*�(�*�*�*�*�*�*�*�(�*�(�*�+�(�*�*�*�*�(�(�(�(�(�*�*�+�+�(�*�*�(�*�(�*�*�*�*�*�*�*�(�*�(�*�+�(�*�)�P꼺�5dr��O�V�.`� | ||||