Compare commits

37 Commits
Author SHA1 Message Date
90CN b64de0f2a4 修复启动器退出时 _MEI 临时目录清理弹窗 2026-11-05 23:33:52 +08:00
90CN 1e936bfb84 一键换装内嵌 gmweb:取消独立 exe 启动并支持页面热键 2026-11-05 21:08:20 +08:00
90CN ab778e9e4c 修复团本频道不显示次数:活动目录可用时补发 op1303 2026-11-05 20:46:50 +08:00
hanfuit b88e1a223b docs:新增远程发版脚本,Release 可用一条命令发布
Gitea 的 Release 全流程走 REST API(实测可行),不用点网页:

- docs/release/publish_release.py(纯标准库,无依赖):校验 tag → 建/更新
  Release → 上传附件 → 回读自检;支持 --dry-run / --draft / --prerelease /
  --expect-commit / --no-asset / --insecure
- 默认先比对本地 tag 与远端 tag 指向的提交,不一致直接拒绝 —— 2026.11.05
  就是 tag 落后 12 个提交,源码包是旧代码且外表看不出来
- .url 里仍是 REPLACE_WITH_YOUR_LINK 时拒绝发版(--force 可跳过)
- 附件同名先删再传,脚本可重复执行(幂等)
- docs/release/README.md 改为「一条命令发版 + 手工兜底」两套流程,附实测记录
- 根 README 发布流程、AGENTS.md 发版清单挂上脚本用法

实测:临时 tag 走完整流程 创建 201 → 上传 201 → 下载 200(内容正确)→
重复执行后只有一个同名附件 → 删除干净(release 204 / tag 已清)。
注意 Gitea 附件字段名必须是 attachment,用 file 会 500 "http: no such file"。
2026-11-05 19:44:14 +08:00
hanfuit 0897761c47 docs:补充 18112 进程判读与 serve.py 需重启的规则
起因:同步 gmweb 时看到 18112 被占,先判断成「启动器在跑」、又改口成「残留 pythonw
serve.py」,两次都错 —— 实际是多调用模式下的 gmweb 子进程,进程名就叫 DNF90启动器.exe。

- 排查文档新增小节「18112 上跑的可能是启动器本身」:给出命令行三态判别表
  (带 serve.py 参数 = gmweb 服务 / 只有 exe 路径且占 19527 = 主进程 /
  父 pid 不存在 = 遗留服务),并附 Get-CimInstance 取证命令
- 明确推论:该进程跑的是启动那一刻的 serve.py;静态文件读盘即生效,
  但路由不会,症状是「页面新版、接口旧版,按钮点了没反应」
- AGENTS.md 8.3 同步补上「改 serve.py 后必须让 18112 进程重启」的部署动作
2026-11-05 19:38:32 +08:00
hanfuit 90877233fd docs:AGENTS.md 补协作约定(走分支、推前先 rebase、推完要同步)
起因:别人直接推 main(cc54603),本地未同步就分叉;同时 tag 与发布包错位。
把这些变成明文规则,避免下一个人或下一个 AI 重犯。

- 8.1 推送前先 rebase(强制):git status -sb → git pull --rebase → push 固定顺序,
  禁止 push 被拒后用 reset --hard,附冲突处理与真实案例
- 8.2 多人/多 AI 协作走分支:跨文件、双人同改 gmweb/ 必须走分支 + 合并请求;
  单人确定改动与纯文档可直推,但仍受 8.1 约束
- 8.3 推完不等于生效:同步 Z:/90server 与桌面补丁包 → diff -rq → 重打包 → 通知重启
- 发版清单新增:打 tag 前用 git log -1 与 ls-remote 的 ^{} 双向确认指向实际发布的提交
- 当前状态记录 tag 基线:v2026.11.05 → 3b240ec(annotated),后续提交不在该 tag 内
- 不要做的事补三条:未 pull 就 push、reset --hard 覆盖、tag 错位发 Release
2026-11-05 19:34:32 +08:00
hanfuit cc546031b0 gmweb:游戏内一键换装、方案配置与全局热键说明 2026-11-05 18:19:24 +08:00
hanfuit 3b240ec176 docs:新增 AGENTS.md 作为 AI / 新人接续开发的入口文件
一页纸解决「接手时不知道从哪读、哪些不能碰、改完怎么验」:
- 0 组件与端口速览;1 必读顺序(踩坑记录 P-001/P-002/P-011 列为首要)+ 按改动选文档
- 2 硬规则 R1–R9:DLL 与 exe 成对、改 DLL 要补 CRC 并重编服务端、Z 盘只是部署副本、
  gmweb 只能 IE11、静态 no-store、sys.executable 陷阱、不提交产物、版本号三处一致、声明与 GPL
- 3 构建命令(服务端三个 exe / 启动器单文件 / 客户端补丁 / PVF 导出)
- 4 代码地图:server.py 按功能给出行号区间,其余给模块职责
- 5 验证方法:进程端口、启动器日志、CRC verify、diff -rq,
  以及本项目一直在用的「静态服务 + 无头浏览器截图/量 rect」手法,附分场景清单
- 6 发版七项清单;7 当前状态;8 提交约定;9 不要做的事
- CLAUDE.md 作为指向 AGENTS.md 的兼容入口;根 README 顶部与文档索引、docs/README 同步挂上
2026-11-05 17:40:13 +08:00
hanfuit adcc5b0f3a docs:新增踩坑记录,13 条坑的成因复盘与判断规则
和「排查记录」不同,这份文档不写怎么修,只回答「为什么会踩、为什么没早发现」,
给的是可迁移的判断,而不是单次操作步骤。

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

- 新增多调用模式 run_script_instead():exe <xxx.py> [args] 时用 runpy 直接
  执行该脚本,不开窗口/不提权/不占互斥量,打包后拉起 serve.py 才合法
- 新增单实例互斥(Local\DNF90Launcher-5E9C1B7A):重复启动把已有窗口
  ShowWindow+SetForegroundWindow 后退出,不再开第二个窗口
- start_gmweb() 增加就绪校验:spawn 后最多等 6 秒确认 18112 在监听,未监听
  则改用 importlib 在本进程线程里跑 serve.py 的 main(),绝不再拉起新窗口
- self_elevate() 提权重启带上原始参数(原来 ShellExecuteW 传 None,参数全丢);
  DNF90_GMWEB_CHILD=1 的子进程不再触发 UAC
- WebView2 数据目录固定到 %LOCALAPPDATA%\DNF90Launcher\WebView2 且
  private_mode=False,避免 exe 在只读目录/网络盘时窗口空白且无报错;
  启动前查注册表确认运行时是否存在
- 新增 启动器日志.txt(exe 同目录,512KB 转 .old.txt):console=False 下
  print 与未捕获异常全部落盘,顶部记录 pid/argv/SERVER_ROOT
- 文档:新增 docs/启动器窗口风暴排查.md(现象、根因、复现手法、修复点、
  日志判读表),README 常见坑与文档索引、docs/README、launcher/README 同步
2026-11-05 17:22:51 +08:00
hanfuit da502b3524 docs:补上 90CN.dll 自校验 CRC 工具的文档说明
- 新增 docs/客户端补丁与CRC校验.md:SelfCrcSlot 结构、CRC32 算法与种子坑、
  槽位为什么只在 .data/.rdata 里找、info/verify/patch 用法与退出码、
  build_90cn.ps1 集成、与「服务端 DLL 指纹」的区别、六类故障排查
- 新增 client-patch/tools/README.md:脚本清单与常用命令速查
- docs/README.md:文档索引与上手路径补一条
- 根 README:关键设计说明补「客户端自校验(CRC32)」小节,文档索引补一行
- 90CN.cpp:清掉过期的重复注释(旧的 patch_selfcrc.py / .text 段说法),与实现对齐
2026-11-05 16:40:33 +08:00
hanfuit 9bdaf28683 docs:网盘地址改为真实百度网盘分享(提取码 s33r)
- docs/release/网盘下载.url:URL 换成 pan.baidu.com/s/1j31dVPF_nPKSN2QYHU-Ygg?pwd=s33r,双击直达目录
- 发布说明模板:下载表格填百度网盘真实链接与提取码,阿里云盘一行保留待填
- docs/release/README.md:占位说明改为「已指向真实分享,重新分享后需同步」
- 根 README:标题下与快速开始处补上成品下载入口
2026-11-05 16:19:37 +08:00
hanfuit 904f8a45d0 README:架构章节改用表格,避免中文宽度导致的 ASCII 图错位 2026-11-05 16:17:10 +08:00
hanfuit 607d705908 README:按 v2026.11.05 现状重写
- 补全整体架构图(启动器 19527 / 服务端 18111 / GM 页 18112 / 客户端 DLL)
- 目录结构补 gmweb、launcher/qr、docs/release
- 子项目表补 gmweb,标注 Go 1.26.5、Python 3.15 32 位
- 新增:启动器六页与更新日志双来源、GM 控制台 IE11 布局约束、二维码替换规则
- 新增:版本号与发布流程(CURRENT_VERSION、Release、网盘 .url 附件、DLL 指纹绑定)
- 新增:常见坑速查表、文档索引、提交约定
2026-11-05 16:16:45 +08:00
hanfuit 65169f5e81 docs:发布物料入库(Release 说明模板 + 网盘 .url 快捷方式)
- docs/release/发布说明模板.md:本次更新 / 下载渠道 / 安装 / 说明四段
- docs/release/网盘下载.url:InternetShortcut 模板,URL 为占位,发版前替换
- docs/release/README.md:发版七步与常见坑(占位地址别直传、DLL 与 EXE 需配套)
2026-11-05 16:16:41 +08:00
hanfuit fb3ec9f777 GM 页:关于页补上官方QQ群二维码,面板改为三列
- 关于页组合面板 534→712,新增中间列「官方QQ群」(178px,含 QQ 群号 1128511524)
- 新增 gmweb/qr/qq.png,沿用 bindQr 的缺图占位逻辑(qr-qq)
- 介绍卡片同步加宽到 712,与面板左右对齐;资源版本号 ?v=20261105C
2026-11-05 16:13:53 +08:00
hanfuit cc6beb0a71 启动器:关于页新增官方QQ群二维码,内容整体居中
- 关于页二维码面板改为三列:赞助我们(支付宝/微信)| 官方QQ群 | 团队公众号
- 新增 qr/qq.png 并加入 /qr/ 白名单与打包资源
- 各列二维码改为列内居中,QQ群列宽 184px,避免群号文字折行
- 关于页内容块 margin:0 auto 整体居中,并压缩上下间距使 1204x717 窗口内不出现滚动条
2026-11-05 16:11:26 +08:00
hanfuit 6a4b7e58d3 启动器:游戏更新拆分为当前/历史双子页,新增关于我们页
- 游戏更新页改为「当前更新 / 历史更新」两个标签页,历史页显示条数
- 更新日志合并仓库 Release 与本地 更新日志.txt,按日期去重、仓库优先;断网时仅用本地记录
- 主导航新增「关于我们」,内容与游戏内控制台关于页一致(四行文案 + 赞助/公众号二维码)
- 新增 /qr/<name> 白名单路由读取启动器目录 qr\ 下图片,防止目录穿越
- 二维码支持点击放大(灯箱),图片缺失时显示占位而不是破图
- 打包 spec 补入 qr 三张图,静态响应统一 Cache-Control: no-store
2026-11-05 16:05:00 +08:00
hanfuit 1dd3775510 配置:GM 控制台窗口宽 920→800,窄屏下四页布局实测无横向溢出 2026-11-05 15:17:10 +08:00
hanfuit 040f6fbd93 启动器:品牌改为 DNF90 灵宝小竹单机版,本地版本号 v2026.11.05,静态响应禁缓存
- index.html:title / 品牌条 / 首页主标题 / 赞助弹窗文案 统一改品牌名
- server.py:CURRENT_VERSION 与 statVersion 占位同步到 v2026.11.05
- server.py:Handler._send 增加 Cache-Control: no-store,避免 WebView2 缓存旧页面
2026-11-05 15:16:44 +08:00
hanfuit ee3e8d0ced GM 页:顶部合并为一行,赞助页改关于页(三张二维码),标签支持 hash 直达
- #hd 与 #tabs 合并成 #topbar,标签在左、连接状态在右,内容区多出 22px
- 赞助页改为关于页:介绍卡片 + 赞助我们/团队公众号组合面板,三张二维码同一行
- 二维码占位修复(img 在脚本前已 404,补 complete/naturalWidth 兜底)
- 标签支持 #home/#char/#item/#about 直达
- 内容区底部 62→71,修正被日志栏遮住 9px 的问题
2026-11-05 15:16:39 +08:00
hanfuit 06558415da GM 页改版:加回满疲劳/赞助页,去掉背包与邮件页和重复标题
- 角色页新增「回满疲劳」,走 POST /dnf/gm/characters/{id}/fatigue/refill(需角色下线)
- 新增「赞助」页:支付宝 / 微信收款码占位,图放 gmweb/qr/ 下即显示
- 删除背包页与邮件页(发到邮箱按钮保留在发物品页)
- 去掉页头「GM 控制台」文字(窗口标题栏已画标题,重复),页头只留连接状态
- serve.py 补 jpg/jpeg/gif/webp/bmp/svg 的 MIME
- 控制台宽度 1040 -> 920
2026-11-05 14:15:41 +08:00
hanfuit 7f41f9797a GM 控制台跟随游戏窗口:窗口模式下按游戏客户区收小并居中
- 新增 FindGameWindow/FitInsideGame:取本进程最大可见顶层窗口的客户区作为游戏画面
- 开窗口时按 min(配置尺寸, 客户区-2倍边距) 定尺寸并居中;游戏画面小于配置时自动缩
- 新增 GmWindowFollowGame:切全屏/改分辨率/挪游戏窗口时重新摆放,玩家手动拖过的窗口不动
- 服务端指纹回绑 186A2C95… (1260032)
2026-11-05 13:29:05 +08:00
hanfuit 0c7ef6d645 小井盖面板全程压制:不再放回、关控制台后关闭并抑制 2.5 秒
- 触发条件改为「面板真的可见」而不是「窗口存在」,避免隐藏缓存的旧面板被当成点击
- 控制台打开期间面板一露头就 SW_HIDE,关控制台时关面板并在 2.5 秒内持续压制、最多再请 4 次关闭
- 轮询从 250ms 提到 40/60ms,消除旧页面闪现
- 服务端指纹回绑 CE86D4DF… (1258496)
2026-11-05 13:26:06 +08:00
hanfuit 32469e4341 配置注释:本服全屏为无边框最大化,置顶控制台已验证可覆盖 2026-11-05 13:22:34 +08:00
hanfuit 0a6baf20e9 配置:关掉 GM 控制台时直接关闭游戏内嵌面板(close_action=1) 2026-11-05 13:18:17 +08:00
hanfuit b22579658e 小井盖改为自绘 GM 窗口(WebView2)+ 背包可见 + 启动器互斥
- 90CN.dll:新增 90CN_gm_window.cpp,自绘标题栏/边框的置顶窗口,内嵌 WebView2 承载 gmweb 页面
- 点小井盖时隐藏游戏内嵌 IE 面板、弹出控制台;关掉后还原,独占全屏压不住时可选 minimize_game
- 窗口每轮确认置顶,避免游戏抢窗口栈后消失
- gmweb:新增背包页,发放后自动跳转并高亮新入包的行(GET /local/dnf-gm/inventory/list)
- 启动器:新增 /console-status,控制台运行时置灰 GM 工具入口并阻止进入
- 服务端指纹回绑新 DLL(1258496 / 7A179B1B…)
2026-11-05 13:16:52 +08:00
hanfuit e9a3952a9d GM 页:修复内嵌 IE 输入法 + 物品分类浏览
- 90CN.dll:给 IE 控件窗口链重新关联 IME 上下文,中文输入不再只能打字母
- gmweb:页面加 ime-mode:active,focus 时再主动要一次
- 物品页加分组/类型/等级筛选,不依赖中文输入也能定位物品
- 服务端指纹回绑新 DLL(1223680 / 26633404…)
2026-11-05 12:37:03 +08:00
hanfuit c4ffa57bf2 GM 页:增幅属性按 PVF 实际映射标注(物防/魔防/物攻/魔攻) 2026-11-05 12:22:08 +08:00
hanfuit 18e98ca7c9 小助手内嵌 GM 页:网页重定向 + 物品分页 + 装备强化增幅发放
- 90CN.dll 新增 IE 控件接管,把腾讯小助手页面重定向到本地 GM 页
- gmweb:物品列表改分页(每页 10/20/50,列表固定 6 行高)
- 发放走 grant-options,支持强化 0-31、增幅属性+等级、锻造 0-8、品质
- 服务端补齐 amplify_level:等级统一写入 ext_data0/packed_flag_byte/reinforce/upgrade_level
- 增幅与强化互斥校验,避免写出客户端无法创建的装备
2026-11-05 12:20:46 +08:00
hanfuit 6e570fe820 修复 DLL 自校验 CRC 算法并加固槽位定位(频道无法连接问题)
根因:重写的 patch_dll_crc.py 用 binascii.crc32(data, 0xFFFFFFFF) 计算 CRC,
该参数是"末值异或之后的运行值",再喂回去等于重复施加了条件化,得到的是
CRC32 的另一个变体,与 90CN.cpp 里 Crc32Update 的实现不一致。结果是补丁写
入的 expected 永远校验不过,客户端在 VerifySelfIntegrity 失败后安装流程中断,
transport 钩子(codec/decode/route/dproto)全部没装上,表现为"无法连接频道"。

改动:
- tools/patch_dll_crc.py
  * CRC 改为标准 zlib.crc32(init 默认 0),与 C 端 Crc32Update 完全一致;
    已用旧版本 DLL 反向验证(10-20、11-03、10-28 三份的存储值均能复算一致)
  * 槽位改为按 PE 段表定位,只接受 .data/.rdata 内的候选,拒绝把补丁写进
    .text 机器码(此前曾命中 cmp ..., 0C0DEC0DEh 的立即数导致访问违例)
  * 新增 info/verify 子命令,patch 后自动回读校验
- 90CN.cpp
  * SelfCrcSlot 三个字段加 volatile,避免 /O2 把 offset/expected 折叠成常量
  * VerifySelfIntegrity 增加分步诊断日志,便于定位安装线程中断点
- client_build_id.go
  * 指纹重绑为本次构建产物:1215488 / C7BE377C…8247071A

验证:客户端日志 self crc check computed==expected==0x80AFC116 ok=1,
transport DLL installed codec=1 decode=1 route=1 dproto=1,
channel connect result=1,first-login watchdog server_seen=1 client_seen=1。
2026-11-05 11:37:10 +08:00
hanfuit 2ca06e113a 修正客户端完整性校验:补齐 CRC 后处理脚本并回绑 90CN.dll 指纹
问题:上一提交把 boundDll 指纹改成 1230336/F6DDA534…,但配套的
90CN.dll 未交付到客户端目录,服务端启动即被 verifyClientArtifacts 拦下
(client dll: size mismatch: 1230848 != 1230336),日志表现为空壳。

本次改动:
- 新增 client-patch/tools/patch_dll_crc.py,实现仓库中缺失的 DLL 自校验
  槽位回填(magic 0xC0DEC0DE + offset + 全文件 CRC32,跳过自身 4 字节),
  附带 verify 子命令可回读校验
- 新增 client-patch/tools/build_90cn.bat / .ps1,一键编译 + 打 CRC + 输出指纹
- client_build_id.go 指纹回绑到本次实际构建产物:
  1215488 / 30E41FF583893E611E6975DF96246E1320B042F7B33A32503C987F211D1776F5

验证:重编 DNF90Server.exe 后裸跑,完整性校验通过,PVF 加载完成,
dnfbridge 正常监听 :7001,game_ports=31,admin http 127.0.0.1:18111。
2026-11-05 10:41:47 +08:00
hanfuit c58168406b 启动器:游戏更新入口移到开始旁,移除设置页面
- 侧边栏顺序调整为 开始 → 游戏更新 → GM工具 → 一键换装 → 游戏设置
- 删除「设置」页面(侧边栏入口 + page-settings section)
- shell.js 移除 loadSettings 函数及 setRefresh 事件监听
- shell.css 清理 .settings-* 相关样式(明暗主题两套)
2026-11-04 20:38:22 +08:00
hanfuit 0ced464168 启动器新增游戏设置页(频道开关+活动开关),窗口标题改名
- 服务端新增活动开关运行时配置与 GM 接口(GET/POST /dnf/gm/activity-switches),
  持久化到 Settings,接入月签到/周签到/PC房↔累计在线的实时下发
- 服务端新增频道开关 GM 接口(channel-switches),目录生成时过滤禁用频道
- channelcatalog/channelinfo 支持按禁用频道 ID 过滤目录与客户端脚本
- 启动器新增「游戏设置」页面:频道开关 Tab + 活动开关 Tab
- native.py/server.py 新增对应转发 API
- 窗口标题/页面 title/侧边栏「90轮回」统一改为「DNF90 灵狐小竹整合单机版」
2026-11-04 20:08:50 +08:00
hanfuit 977a4a1196 启动器:移除服务端工具/支援兵/插件页面,支援兵模板独立保存
- 删除服务端工具、支援兵、插件三个页面(侧边栏入口 + HTML section)
- server.py: 删除 servertool(9个)、companion/basic-attack 相关接口及常量
- native.py: 删除无人引用的 get_server_features/save_server_features/
  companion_state/companion_save(保留 GM 工具依赖的 gameplay 方法)
- shell.js: 删除 loadServerTool/loadCompanion 全部逻辑及状态变量
- shell.css: 清理 st-/cp- 相关样式
- templates/支援兵页面模板.md: 支援兵页面完整导出为独立模板
- 侧边栏「更新日志」更名为「游戏更新」
2026-11-04 19:32:01 +08:00
hanfuit 56236496a0 启动器 UI 改用仓库新源码,不上传本机旧 UI 2026-11-04 19:24:41 +08:00
hanfuit 70b3c5a6dc 合并本地领先源码:旧UI、GM疲劳燃烧、白金徽章孔、宠物修复、支援兵普攻开关 2026-11-04 19:20:49 +08:00
109 changed files with 11625 additions and 1048 deletions

No files matched your search

+5
View File
@@ -53,3 +53,8 @@ launcher/dnf_items.json
*.swo *.swo
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# 启动器运行日志(exe 同目录)
launcher/启动器日志.txt
启动器日志.txt
启动器日志.old.txt
+291
View File
@@ -0,0 +1,291 @@
# 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 的包)
- [ ] **打 tag 前先确认它指向「实际发布的那个提交」**:`git log --oneline -1 v2026.11.xx`,
再用 `git ls-remote --tags origin` 看远端解析到的 `^{}` 是不是同一个 sha。
tag 与代码错位时,Release 的「源码包」是一份旧代码,排查起来极难想到
- [ ] 上面两项可以交给脚本,它先比对 tag 再动手:
`python docs/release/publish_release.py --version v2026.11.xx --dry-run`(看过计划后去掉 `--dry-run`)
---
## 7. 当前状态(2026-11-05)
- 版本 `v2026.11.05`;`main` 与 `origin/main` 同步
- **tag 基线**:`v2026.11.05` 指向 `3b240ec`(annotated),就是本次补丁包对应的代码;
之后的提交(如 `cc54603` 一键换装)**不在**这个 tag 里,发下一版要另打 `v2026.11.06`
- Release `2026.11.05更新`(id=3)绑此 tag,附件 `90CN灵宝小竹整合20261105更新补丁.rar`
- 启动器:六页(开始/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,格式照抄现有条目)
### 8.1 推送前先 rebase(强制,不是建议)
`main` 是**多人共享**的分支,别人(包括另一个 AI)随时可能已经推过。本地 commit 后直接 `push`
会被拒(non-fast-forward);这时最容易发生的坏操作是「先 reset 再重来」,改动就丢了。
固定顺序,别跳步:
```bash
git status -sb # ① 工作区必须干净;看 ahead/behind
git pull --rebase # ② 把本地提交挪到远端最新之上,有冲突当场解决
git push origin main # ③ 推
```
- 同步别人的改动时用 `git merge --ff-only origin/main` 或 `git pull --rebase`,**不要** `git reset --hard`
- rebase 遇到冲突:改完 `git add <文件>` → `git rebase --continue`;搞不定就 `git rebase --abort` 回到原样再来
- 推送后用 `git log --oneline -3` 确认自己的提交在最新,而不是被顶回旧位置
> 真实案例:`cc54603`(别人的一键换装)就是直接推的 `main`,本地因未同步而分叉;
> 好在工作区干净才能 `--ff-only` 快进过去,否则就得手工合。
### 8.2 多人 / 多 AI 协作走分支,别直推 `main`
| 场景 | 做法 |
|---|---|
| 改动跨多个文件、要评审、或不确定是否上线 | 建分支 `feat/xxx` / `fix/xxx` → 推送 → 发合并请求(Pull Request),由维护者合并 |
| 同时有两人在改同一个目录(尤其 `gmweb/`) | **必须走分支**,否则后推的人一定覆盖前者 |
| 单人、改动确定、改完立刻部署验证 | 可以直推 `main`,但仍要遵守 8.1 |
| 只改文档 | 可以直推 `main`,仍要遵守 8.1 |
分支命名:`feat/换装方案`、`fix/启动器端口` 这类「类型/主题」,合并后删掉分支。
### 8.3 推完不等于生效
仓库改了,`Z:\90server` 和桌面补丁包**还是旧的**(见 R3)。凡是改了 `gmweb/`、启动器或客户端的提交,
推完要:同步部署副本 → `diff -rq` 核对 → 该重打包的重打包 → 通知实际在玩的人重启启动器。
**改 `serve.py` 尤其要注意**:静态文件(`index.html` / `loadout.js` / `.css`)每次请求读盘、替换即生效,
但 `serve.py` 的路由是**进程启动时注册**的。抓 18112 的那个进程(进程名可能就叫 `DNF90启动器.exe`,
看命令行有没有 `serve.py --port 18112`)不重启,就会出现「页面是新版、接口是旧版」——
按钮点着没反应,最难查。所以:同步完先看 18112 是谁在听,该关就关,再重新打开启动器。
> 真实案例:`cc54603` 推了 gmweb 一键换装,但部署目录没同步,游戏里看不到新功能;
> 同时 18112 被一个残留 `serve.py` 占着,启动器看到端口已占用就跳过启动,
> 于是「改了、推了、也重启了,还是旧页面」(见 P-007、P-008)。
---
## 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 约束)
- ❌ 不要在没 `git pull --rebase` 的情况下直接 `push` —— 见 8.1
- ❌ 不要在 push 被拒后用 `git reset --hard` 或强推 `--force` 覆盖别人的提交 —— 先 `pull --rebase`
- ❌ 不要在 tag 指向错误提交的情况下发 Release —— 见第 6 章最后一项
+10
View File
@@ -0,0 +1,10 @@
# CLAUDE.md
本仓库的统一入口是 **[AGENTS.md](AGENTS.md)**(硬规则 R1–R9、构建命令、验证方法、代码地图、发版清单)。
开工前请先完整读一遍,再按其中的「开工前必读」选读 `docs/` 下的对应文档。
三条最容易被忽略、且会直接导致故障的规则:
1. `90CN.dll` 与 `DNF90Server.exe` 指纹绑定,**必须成对构建、同批发布**(R1/R2)
2. `gmweb/` 是 IE11 环境,**不能用 flex 等现代 CSS**,改完资源要加 `?v=`(R4)
3. 单文件打包后 `sys.executable` 指向启动器自身,**任何拉起子进程的代码都要校验副作用**(R6)
+164 -57
View File
@@ -1,119 +1,226 @@
# 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) ├── 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) ├── loadout-tool/ 一键换装工具(C# / .NET)
├── launcher/ 整合启动器 + GM 管理工具(Python + pywebview) ├── gmtool/ GM 工具旧版(FastAPI,已被 launcher 取代,仅留参考)
├── gmtool/ GM 工具旧版(Python + FastAPI,已被 launcher 取代,保留参考)
├── pvf/ PVF 解析套件(Go,模块 pvfine,GPL-3.0) ├── pvf/ PVF 解析套件(Go,模块 pvfine,GPL-3.0)
│ └── cmd/pvfexport/ 物品名称表导出工具 │ └── 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 ```bash
go env -w GOPROXY=https://goproxy.cn,direct go env -w GOPROXY=https://goproxy.cn,direct
go env -w GOSUMDB=sum.golang.google.cn go env -w GOSUMDB=sum.golang.google.cn
cd server 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 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`。 **启动器**(Python 3.15 32 位):
### 2. GM 启动器(launcher/)
要求 Python 3.15(32 位)+ Windows。打包:
```bash ```bash
cd launcher cd launcher
python -m pip install pywebview pythonnet clr-loader pyinstaller python -m pip install pywebview pythonnet clr-loader pyinstaller
python -m PyInstaller --clean --noconfirm "DNF90启动器.spec" python -m PyInstaller --clean --noconfirm "DNF90启动器.spec"
# 产物:launcher/dist/DNF90启动器.exe
``` ```
产物在 `launcher/dist/DNF90启动器.exe`。详见 `docs/开发文档.md`。 > 单文件打包有代价:onefile 会先解包再启动,改完源码必须重新打包;旧进程未退出时会继续占用 19527 端口,看到旧界面先确认进程已杀干净。
### 3. PVF 解析(pvf/) **PVF 物品名导出**:
```bash ```bash
cd pvf cd pvf
go build ./cmd/pvfexport/ # 物品名称表导出工具 go build ./cmd/pvfexport/
pvfexport -pvf Script.pvf -out dnf_items.json -compact 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 页三列是否都正常
> **第 5、6 步可以一条命令搞定**(Gitea REST API,脚本只用标准库):
>
> ```bash
> set GITEA_TOKEN=xxxx
> python docs/release/publish_release.py --version v2026.11.06 --notes-file docs/release/发布说明模板.md
> ```
>
> 它会先核对本地/远端 tag 是否指向同一提交(防「Release 挂在旧代码上」),再建或更新 Release、上传 `.url`,可重复执行。加 `--dry-run` 只打印计划。详见 [docs/release/README.md](docs/release/README.md)。
> ⚠️ **DLL 指纹绑定**:`90CN.dll` 的 SHA-256 在编译期编进 `DNF90Server.exe`(`boundDllSHA256`)。换 DLL 必须重编服务端,否则客户端连接会被拒绝。发布包里 DLL 与 EXE 一定要成对。
## 九、关键设计说明
### GM 工具双数据源 ### 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`,实时操作 - 启动器通过探测 18111 是否监听来切换,界面上以状态灯区分
- **SQLite 直读**:直接读写 `Server/runtime/data/dnf90.db`
### 物品名称离线显示 ### 物品名称离线显示
`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 拍卖行
服务端已实现 GM 拍卖行接口(`/local/dnf-gm/auction/*`),支持: 服务端实现 `/local/dnf-gm/auction/*`:免押金上架(`Deposit=0`)、自动补足上架(背包不足时先发差额)、列背包 / 删除背包行(含穿戴装备)。
- **免押金上架**(`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/`:**私有/闭源**,请勿对外公开。 - 编译脚本 `build_90cn.ps1` 第三步会自动补 CRC,`-SkipCrc` 出来的 DLL 不要发布
- `pvf/`:来自开源项目 [pvfine](https://github.com/) ,遵循 **GPL-3.0**(见 `pvf/LICENSE`)。若需合并/分发,注意 GPL 传染性。 - 定位槽位只认 `.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
View File
@@ -38,6 +38,7 @@
#include <algorithm> #include <algorithm>
extern "C" void InstallRaidProbe(); extern "C" void InstallRaidProbe();
extern "C" void InstallWebRedirect();
namespace namespace
{ {
@@ -13158,20 +13159,17 @@ static bool VerifyBoundMoviePresent()
// ── 自校验(self-integrity-crc)──────────────────────────────────────── // ── 自校验(self-integrity-crc)────────────────────────────────────────
// //
// 构建后由 patch_selfcrc.py 把自身 .text 段的 CRC32 写进 g_selfCrcExpected。 // 构建后由 tools/patch_dll_crc.py 写入两个字段:
// 运行时重新读取磁盘上的本 DLL,算 .text 段 CRC 并比对:
// 任何字节被改动(比如 patch 掉校验分支)→ CRC 不符 → 客户端无法启动。
// 占位初值必须是 0xC0DEC0DE,脚本靠它定位写入位置。
// ── 自校验(self-integrity-crc)────────────────────────────────────────
//
// 构建后由 patch_dll_crc.py 写入两个字段:
// offset = 本结构 expected 字段在文件中的绝对偏移 // offset = 本结构 expected 字段在文件中的绝对偏移
// expected = 整个文件的 CRC32(计算时跳过 [offset, offset+4),避免自指) // expected = 整个文件的 CRC32(计算时跳过 [offset, offset+4),避免自指)
// 运行时重新读盘、用同样规则重算并比对;任何字节被改动 → 不符 → 客户端无法启动。 // 运行时重新读盘、用同样规则重算并比对;任何字节被改动 → 不符 → 客户端无法启动。
// 占位初值必须是 0xC0DEC0DE,脚本靠它定位写入位置(详见 docs/客户端补丁与CRC校验.md)。
static struct SelfCrcSlot { static struct SelfCrcSlot {
unsigned int magic; // 固定 0xC0DEC0DE // volatile:这三个字段只由构建后脚本改文件字节,代码里从不写。
unsigned int offset; // expected 字段的文件偏移(脚本写入) // 不加 volatile 时 /O2 会把 offset/expected 当常量折叠成 0,补丁写对了也读不到。
unsigned int expected; // 文件 CRC32(脚本写入) volatile unsigned int magic; // 固定 0xC0DEC0DE
volatile unsigned int offset; // expected 字段的文件偏移(脚本写入)
volatile unsigned int expected; // 文件 CRC32(脚本写入)
} g_selfCrcSlot = { 0xC0DEC0DEu, 0u, 0u }; } g_selfCrcSlot = { 0xC0DEC0DEu, 0u, 0u };
static unsigned int Crc32Update(unsigned int crc, const unsigned char* data, size_t length) 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() static bool VerifySelfIntegrity()
{ {
wchar_t path[MAX_PATH] = {}; wchar_t path[MAX_PATH] = {};
// 崩溃定位断点:worker 在 TCLS 之后踩 AV 时靠这些行判断停在哪一步。
LogLine("self crc step=enter slot=%p", (void*)&g_selfCrcSlot);
// 用数据变量地址解析自身模块;函数地址在某些构建/加载方式下解析不到(实测 err=126)。 // 用数据变量地址解析自身模块;函数地址在某些构建/加载方式下解析不到(实测 err=126)。
HMODULE selfModule = nullptr; HMODULE selfModule = nullptr;
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | 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()); LogLine("self crc diag: GetModuleHandleEx failed err=%u", GetLastError());
return false; return false;
} }
LogLine("self crc step=module module=0x%p", (void*)selfModule);
if (!GetModuleFileNameW(selfModule, path, MAX_PATH)) { if (!GetModuleFileNameW(selfModule, path, MAX_PATH)) {
LogLine("self crc diag: GetModuleFileName failed err=%u", GetLastError()); LogLine("self crc diag: GetModuleFileName failed err=%u", GetLastError());
return false; return false;
} }
LogLine("self crc step=path");
FILE* file = nullptr; FILE* file = nullptr;
if (_wfopen_s(&file, path, L"rb") != 0 || !file) { if (_wfopen_s(&file, path, L"rb") != 0 || !file) {
LogLine("self crc diag: open failed"); LogLine("self crc diag: open failed");
@@ -13209,6 +13211,7 @@ static bool VerifySelfIntegrity()
fseek(file, 0, SEEK_END); fseek(file, 0, SEEK_END);
const long size = ftell(file); const long size = ftell(file);
fseek(file, 0, SEEK_SET); fseek(file, 0, SEEK_SET);
LogLine("self crc step=size size=%ld", size);
if (size < 4096) { if (size < 4096) {
LogLine("self crc diag: file too small size=%ld", size); LogLine("self crc diag: file too small size=%ld", size);
fclose(file); fclose(file);
@@ -13221,6 +13224,7 @@ static bool VerifySelfIntegrity()
} }
const size_t read = fread(image, 1, static_cast<size_t>(size), file); const size_t read = fread(image, 1, static_cast<size_t>(size), file);
fclose(file); fclose(file);
LogLine("self crc step=read read=%u image=%p", (unsigned int)read, (void*)image);
if (read != static_cast<size_t>(size)) { if (read != static_cast<size_t>(size)) {
free(image); free(image);
return false; return false;
@@ -13228,6 +13232,7 @@ static bool VerifySelfIntegrity()
const unsigned int slotOffset = g_selfCrcSlot.offset; const unsigned int slotOffset = g_selfCrcSlot.offset;
const unsigned int expected = g_selfCrcSlot.expected; const unsigned int expected = g_selfCrcSlot.expected;
LogLine("self crc step=slot offset=0x%X expected=0x%08X", slotOffset, expected);
bool ok = false; bool ok = false;
if (g_selfCrcSlot.magic != 0xC0DEC0DEu || slotOffset < 16 || if (g_selfCrcSlot.magic != 0xC0DEC0DEu || slotOffset < 16 ||
static_cast<size_t>(slotOffset) + 4 > static_cast<size_t>(size)) { static_cast<size_t>(slotOffset) + 4 > static_cast<size_t>(size)) {
@@ -14986,7 +14991,7 @@ DWORD WINAPI InstallTransportWorkerInner(void*)
bool tclsInstalled = InstallTclsCompatibility(); bool tclsInstalled = InstallTclsCompatibility();
LogLine("TCLS early bootstrap result=%d", tclsInstalled); LogLine("TCLS early bootstrap result=%d", tclsInstalled);
// 自校验:本 DLL 的 .text 段 CRC 必须与构建时写入的值一致。 // 自校验:本 DLL 整个文件的 CRC32 必须与构建时写入的值一致。
if (!VerifySelfIntegrity()) { if (!VerifySelfIntegrity()) {
LogLine("self crc check failed: terminating client"); LogLine("self crc check failed: terminating client");
MessageBoxW(nullptr, MessageBoxW(nullptr,
@@ -14995,7 +15000,9 @@ DWORD WINAPI InstallTransportWorkerInner(void*)
TerminateProcess(GetCurrentProcess(), 1); TerminateProcess(GetCurrentProcess(), 1);
} }
// 影片绑定校验:文件缺失/损坏直接终止,客户端无法启动。 // 影片绑定校验:文件缺失/损坏直接终止,客户端无法启动。
LogLine("self crc verified, entering movie binding check");
EnforceBoundMovieOrExit(); EnforceBoundMovieOrExit();
LogLine("movie binding check passed");
// 攻坚队创建入口默认写死 owner 0x76(RaidCreateWindow),把该立即数改成 // 攻坚队创建入口默认写死 owner 0x76(RaidCreateWindow),把该立即数改成
// 0x7A 后打开原生 RaidSelectSingleModeWindow(团队/单人选择),单人按钮 // 0x7A 后打开原生 RaidSelectSingleModeWindow(团队/单人选择),单人按钮
// 再把 mode 0 传回 0x76 的创建窗口。窗口与按钮回调全部是客户端原生实现。 // 再把 mode 0 传回 0x76 的创建窗口。窗口与按钮回调全部是客户端原生实现。
@@ -15475,6 +15482,9 @@ DWORD WINAPI InstallTransportWorkerInner(void*)
InstallRaidProbe(); InstallRaidProbe();
LogLine("raid probe integrated normal/single mode compatibility"); 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", 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, enableSceneRouteCompat ? 1 : 0,
enableDprotoCompat ? 1 : 0, enableDprotoCompat ? 1 : 0,
@@ -15501,6 +15511,18 @@ DWORD WINAPI InstallWorker(void*)
} }
} // namespace } // 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:Queue90CNClientNotice=_Queue90CNClientNotice@8")
#pragma comment(linker, "/EXPORT:Dequeue90CNClientEvent=_Dequeue90CNClientEvent@8") #pragma comment(linker, "/EXPORT:Dequeue90CNClientEvent=_Dequeue90CNClientEvent@8")
#pragma comment(linker, "/EXPORT:Query90CNCharacterStatSnapshot=_Query90CNCharacterStatSnapshot@8") #pragma comment(linker, "/EXPORT:Query90CNCharacterStatSnapshot=_Query90CNCharacterStatSnapshot@8")
+44
View File
@@ -261,6 +261,14 @@ bool CompanionEquipmentProbeEnabled() {
return enabled; return enabled;
} }
// 支援兵普攻开关:启动器在客户端目录放置 90CN_basic_attack.disabled。
// 只影响 CompanionBasicAttack 回退程序,12 格技能程序不变。
bool CompanionBasicAttackEnabled() {
static const bool disabled =
CompanionFlagFilePresent(L"90CN_basic_attack.disabled");
return !disabled;
}
bool CompanionEquipmentApplyEnabled() { bool CompanionEquipmentApplyEnabled() {
// Production default: the mode1 equipment rows are replayed for every // Production default: the mode1 equipment rows are replayed for every
// companion APC. A disable flag exists for a one-restart rollback. // companion APC. A disable flag exists for a one-restart rollback.
@@ -1924,6 +1932,42 @@ bool ApplyDynamicRuntimeKeyPrograms(
return false; 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[ std::uint8_t* directBytecode[
Contract::kQuickSlotCount] = {}; Contract::kQuickSlotCount] = {};
std::size_t directBytecodeSize[ std::size_t directBytecodeSize[
+671
View File
@@ -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);
}
}
+95
View File
@@ -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;
}
+27
View File
@@ -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
+792
View File
@@ -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�.`����,]�O� �5dr�R꼹�*�*�*�*�*�+� �뇦� �MNN�MNN� �뇦� �/�0�0�0�0�0�0�0�2!�2!�0�0�0�-�-�,�(�(�(�(�(�(�(�(�(�(�(�(�*�*�*�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�*�*�*�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�365�U「� �L� k�-\�뀶�븝�븝�븝��:y�L� �U「�587�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �/�/�/�0�2!�2!�2!�0�2!�2!�0�0�/�/�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�O걥�%FO�
)Y�a�?��e깒�븝�Ldk��샗�⒩� �a�
)Y�#BL�O걤�+�+�*�+�+� �뇦� �MNN�MNN� �뇦� �/�0�/�0�2!�2!�2!�0�0�/�/�0�0�-�,�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�b꽈�
�J�
a�6�샗�샗�6�4i�e깒�븝��
a�J� �b꽃�+�*�*�*�+� �뇦� �MNN�MNN� �뇦� �-�0�0�0�0�0�0�0�/�/�0�/�/�-�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�b밉�
�R�Z�:w�1�1�#J��⒩�샗�1�Z�R�
�b밉�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �0�0�2!�2!�0�0�0�/�0�0�/�,�,�,�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�E��
�N� R�K�K�3j�Ldk�븝�샗�,�3k� R�M��C}�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �0�0�2!�2!�0�0�0�/�0�0�/�-�,�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�6Zi��B� K�C�A�(�샗�샗�(�,_�C� K�B��6Yg�+�*�*�+�+� �뇦� �MNN�MNN� �뇦� �,�/�/�/�/�0�/�/�/�/�-�-�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�2EN�#)�
$Q�E�>�8}�%T�#��D�8}�>�E�
$Q�!'�2EM�*�*�+�+�+� �뇦� �MNN�MNN� �뇦� �-�/�/�0�0�0�0�/�-�-�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�,�,�,�,�+�+�+�+�+�+�+�+�*�*�*�*�+�+�*�+�+�+�+�,�+�,�,�,�,�,�,�,�+�+�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�-*'�,Q`� �<� @�7|� !�샗�⒩��7|� @�<� �,Q`�-)%�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �/�-�/�/�0�0�/�/�-�/�-�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�+�+�+�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�*�*�*�)�3KS�5?�
�9�=�%�샗�⒩��=�9�
�5?�4LT�+�+�+�+�*�+� �뇦� �MNN�MNN� �뇦� �/�-�,�-�-�/�/�/�/�/�-�,�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�+�+�+�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�+�*�*�*�*�+�+�+�*�*�*�*�*�*�*�)�)�9\h�6?� �
!L� &X�)�� H�
!L� �6?�8]i�+�+�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �-�-�,�,�-�/�/�/�-�,�,�,�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�+�+�*�*�+�+�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�*�*�*�*�*�*�*�*�*�*�*�*�*�)�)�*�7Q[�3_p�)0��� ��)0�3_p�8T^�+�+�+�+�+�+�*�+� �뇦� �MNN�MNN� �뇦� �-�-�-�,�,�,�,�,�,�,�,�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�%!#�(9�,L� 0Z�2g�6p�8v�:|�:|�8v�6p�3g� 1[�,K�'9�# "�*�*�*�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�*�+�)�*�*�)�,+(�8S]�>o�;p�;n�>m~�8S]�.-*�+�+�+�+�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �,�-�-�/�0�0�/�(�(�-�/�/�/�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�'�"/�,R�5p�M�.g�A{�F궹�K눻�O뚒�J뗓�O뤫�W쇹�U쀠�Y쇸�V뺎�P띝�D��0j�M�5p�,R�"/�'�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)�(�(�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �,�,�,�-�/�0�/�-�(�,�,�,�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�#�*I�;~�$_�D�M뚒�J뤫�I롕�J먝�J믛�F띧�I뤬�E}�2K�2K�P눬�Y쒋�Z싞�X싟�X쎺�]잋�^졿�\쒊�K눵�$_�;~�*I�#�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)�(�(�)�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �-�-�,�,�-�-�/�/�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�%#)�3W�N�B}�Q먚�C눾�A댲�J볖�J뫽�N볖�R쀟�M먝�F듫�G됓�+Ow�3O�3O�(Mw�3|�.x�,v�-w�.x�2w�:��G듫�S붭�X뽡�C~�J�-R�$"(�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�,�+�,�+�+�*�*�*�*�*�*�+�+�+�+�+�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �,�-�-�.�,�,�,�,�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�)!"�0S�X�?j�Fu�F눾�@됖�L붯�S쀠�R볓�G댮�<��4x�.u�.x�5~�5f�!>_�!>_�8i�G롗�F롖�O뽤�L붱�G먟�A뗙�9녧�2~�5��3z�B끦�[샽�N댪�R�.P�("#�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�-�,�-�,�,�*�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �,�-�-�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�*F�A�E�<d�)@�)@�6e�L믙�N뫺�C꺧�1u�)n�+n�!]�M�?u�
5h�<m�Bn�Bn�<m� 6h�@u�P�*b�9z�J먝�J믛�M붱�N뺖�N뺖�6c�(>� ;\�L눵�C{�A�*F�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�-�,�-�,�,�*�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �,�-�-�+�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(� )�,S�(b�U믗�P뤫�+Nw�9W�8V�2W�B꺨�/s� \�G��(W�-]�2d� 7i�:n�<q�?s�Cx�Cx�?s�<q�:n� 7i�2d�-]�(W�H��.i�H뚓�T섁�1K�6R�1L�4e�5{�E꾅�(b�,S� )�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)�(�(�)�)�*�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �+�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�*?�?�B~�O뤫�@꺫�;꺪�B}�)Jp�)Kr�;p�Q�)X�0`� 8j�>q�@t�Au�Au�Av�Bv�Bv�Bw�Bw�Bv�Bv�Av�Au�Au�@t�>q� 8j�0`�)X�.c�-Ou�"Ad� =\�O됅�L먟�>눿�>끨�=y�?�*?�,�,�,�,�,�,�,�,�,�,�,�,�,�+�*�+�*�+�)�*�*�*�*�*�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �,�+�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+H�J�Q롍�P먛�>곉�9곍�G띢�J롒�I끟�!V�1`� 5f�=q�?t�@v�Aw�Bw�By�Cy�Cz�Dz�D{�D{�D{�D{�Dz�Cz�Cy�By�Bw�Aw�@v�?t�=q�9j�=m�/Z�H~�L먝�H뚓�P볔�M뫾�G띥�D꺥�J�+H�*�*�*�*�*�*�*�*�*�*�*�*�)�)�*�)�)�(�)�)�)�)�)�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)H�T�S믖�Q뫸�>곉�4|�L먜�M롐�"V�*X�
4e�>r�@t�Av�Bw�Cx�Dz�Cp�!AR�)>6�/<#�3:�69 �88�88�69 �3:�/<#�)>6�!AR�Cp�Dz�Cx�Bw�Av�Au�@t�9j�.]�&[�K뚒�G뚒�O믙�T쀝�D뚕�C꾅�S�)H�(�(�(�(�(�(�(�(�(�(�(�(�(�)�(�(�'�'�(�(�(�(�'�'�'�'�'�'�'�'�'�'� �뇦� �MNN�MNN� �뇦� �)�)�+�-�/�0�/�/�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)H�V�\쇵�Q믗�H듨�9곋�B댲�=|�N�/_�;o�?s�@u�Bw�Cx�Cq�$?D�1;�66�01�+-�'*�$'�"%�!%�!%�"%�$'�'*�+-�01�66�1;�$?D�Cq�Cx�Bw�@u�?s�<o�/_�I�:v�K롒�T쀞�S쀞�N믙�E녟�U�)H�)�)�)�)�)�)�)�)�)�)�)�)�*�(�)�' �' �' �' �' �' �' �' �' �' �' �' �' �' �' �' � �뇦� �MNN�MNN� �뇦� �,�,�,�+�0�0�/�-�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�+G�V�\싛�V쀝�M롏�4~�J뫻�.f�+X� 5e�>q�?s�Au�Bw�Ah�'=7�66�./�&(�$&�13�@@�JI�SP�XU�[X �[X �XU�SP�JI�@@�23�$&�&(�./�66�(=4�Ae�Bw�Au�?s�>q� 5e�+X�.e�O믗�Y싞�\쎷�P붫�>��S�)E�.�,�.�.�,�,�,�,�,�,�*�+�*�+�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �-�+�-�-�/�/�/�-�)�)�)�)�(�(�(�)�)�)�)�)�)�)�)�)�)�)�)�%;�Q�O궩�@j�T먓�9�G뤪�.c�,Y�9j�>q�?s�Au�Aq�&<8�54�+-�"&�24�HG�_\ �rn�뀅%�뵍1�쥮>�エH�괸O�괵M�ぇE�젦;�뵍0�뀅%�ro�_\ �HG�24�"&�*,�54�&<8�Aq�Au�?s�>q�9j�,Y�.b�S볊�^왺�_졻�H뚑�J댫�L�&;�)�)�)�)�)�)�)�)�)�)�*�' �'�' �' �' �' �' �' �' �' �' �( �(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �-�/�+�/�/�0�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�%� C�Q꽴�+B�+B�!<[�@됕�-a�,Z�:k�=q�?s�Av�!=H�66�-.�#&�9:�\Y
�uq�뼇8��J�병[�좀j�횬v�股�路�畓�畓�騏�箝�퓻t�윽e�릴U��G�븧5�so�\Y
�9:�#&�-.�66�!=H�Av�?s�=q�:k�,Z�/a�^앦�.Ln�%9�%Cf�M녚�A�$�(�(�(�(�(�(�(�'�(�)�' �'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'� �뇦� �MNN�MNN� �뇦� �0�0�-�/�0�0�0�.�.�/�/�/�.�.�.�.�.�.�.�.�.�.�(�)�)�(�+Q�L끚�Hy�6S�;[�6Q�7n�,Z�:l�=p�?s�@u�%;8�23�&)�46�PO�}y'�│I�뭅^�컁q�膏�驥�畓�沓�畓�頓�錟�錟�沓�露�錟�騏�邱�愷|�쩌m�럴X�짛D�zw$�NM �46�&)�23�%;7�@u�?s�=p�:l� .]�7f�0J�7U�0J�:s�D~�,Q�(�)�+�(�(�(�)�)�*�,�*�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,� �뇦� �MNN�MNN� �뇦� �2!�0�2!�2!�0�0�0�0�/�/�0�/�/�+�+�+�+�+�+�.�+�.�(�)�,�'C�*d�R믖�Y쀚�5Z�'In�5a�7d�8i�<o�>q�@t�&:2�/0�$(�BC�li�줉G�섯e�珏�駑�路�路�根��匡y�訣q�迦e�퓟]�탁X�타Y�횐_�介g�皐p�光y�根��饑�騏�芹�프x�뭅\�쐶>�hd�BC�$(�/0�&:2�@t�>q�<o�9k�>k�&Eg�#Be�(Hk�D됑�<~�*c�(D�(�(�+�(�)�.�)�)�+�(�,�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �2!�0�2!�2!�2!�0�0�0�0�0�/�-�(�(�(�(�(�(�)�+�.�.�(�)�(!#�@�S뫵�M뫹�P믖�@끥�?겾�5b�8i�;n�=q�?s�%80�-.�*,�GF ��.�뎡]�첸r�謙�慤x�퓸n�탈i�캡b�첨[�쨉V�천S�척L�캑H�척G�처G�척I�캑K�캔Q�천U�첨Z�캣`�탈g�퓸o�慤x�愷}�은j��P�{x$�ED�*,�-.�%80�?s�=q�<o�<m�>k�Gz�`쒃�R샽�-v�I끠�@�"�(�)�(�)�.�)�)�*�+�*�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �2!�0�0�0�2!�/�/�0�0�/�-�)�)�)�)�(�(�(�(�)�+�.�(�)�-P�Az�R볎�Y싙�@끥�:꺩�'X� 3b�:l�<o�>q�$81�,.�.1�c`�몠<�릿^�커s��w�캥j�윳\�쓩T�쉼P�쉴L�쒼J�육F�윤C�챵@�캐=�캐;�캑<�캑<�처=�짼@�윤D�엽H�쒼K�쒼N�엾U�융^�캬j�횰v�쩌l�눕T�뙃3�_\�.1�,.�$81�>q�<o�;m�5d�)Z�Y싛�U샽�:꺪�2t�Bz�,O�)�(�(�)�.�)�)�+�*�(�(�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �0�0�2!�0�0�/�0�0�/�-�+�/�)�)�)�)�)�)�(�(�+�.�(�!&�L�R뫷�N먙�J뚎�7�,a�
/\�9k�;m�=p�#75�-/�-1�da�뿎C�릿_�첵n�청i�쉿X�변O�벽M�벼K�벵K�벵G�쉰C�쒜@�육<�琡�鋏�琡�틂1�博�町�穆�유<�쒜A�서E�벵H�벼K�벽M�변N�쓴[�청k�좀k�눗V�뮀9�`^�-1�-/�#75�=p�;m�9k�
/\�2e�X싙�D뗕�)q�I꾁�L�%�)�(�)�,�)�)�+�(�(�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �0�/�0�0�0�0�0�/�/�/�/�/�-�+�(�(�)�)�)�(�+�.�(�,Q�E�S붪�S볊�5z�A궶� -X�6f�9k�<n�8C�./�(+�QP�뵎B�뎠Z�슐a�섧\�릭P�랸K�략H�랴G�랴F�랴D�릉B�벴>�쉭9�졈:�莢�和�和�홴)�杭�和�標�졀9�쉭:�벴?�릊B�랴E�랴G�략H�릎K�묻Q�섧]�섬`�낟U�룎:�LK�&)�./�8C�<n�9k�6f� -X�O띘�P뺎�6곋�+n�D|�,Q�(�(�)�,�)�)�+�(�)�)�)�(�(�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �0�0�0�0�/�0�/�/�0�/�-�-�+�*�*�(�(�)�)�(�+�.�#�I�R뫵�T뺊�J뚎�>꺧�+]� 1_�7i�9l�:j�//�+-�MM�냳7�괸S�럭W�묻S�땝I�뎀F�델D�뇽C�뇻B�뇻A�뇹@�덴=�릇:�섕6�횐P�迪�薑8�外�홴%�迹�聃Y�桎�획M�뺄6�릇:�덴>�뇻@�뇻B�뇽C�델D�뎀F�땝K�별V�럭W��O��~/�II�(+�//�:i�9l�7i� 1_�/_�Y쎵�I뤪�(o�D�I�#�(�)�,�)�)�,�(�)�)�(�(�(�)�)�)�)�(�(�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �2!�0�0�/�/�/�/�/�-�+�+�(�(�(�(�*�(�)�)�(�+�.�-M�G�V뺎�P뫷�2u�A궴� .X�5e�8i�9l�"32�-.�AB�uq)�ЖN��K�눋K�꺽F�괜C�괘@�괘?�광>�괏>�괏=�괏<�낄:�때7�벳2��R�蕁e�蒼�澧\��"�襪b�音�栗p��R�벱2�덱7�낄:�괏<�괏>�광>�괘?�괘@�괜C�꺽F�뎅M��K�ぇL�pl"�<=
�,-�"32�9l�8i�5e� .X�V볂�Q붫�+t�3r�By�,K�(�)�,�)�)�,�(�)�)�(�(�)�(�(�(�(�(�(�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �/�0�0�/�-�/�/�-�-�+�(�(�(�(�(�*�(�)�)�(�+�- �K�3T{�6P�:e�9~�*]�1^�6g�8j�7[�,,�/3
�][�쐶C�ªA�괬G��C��@��=��<�췅:�췃:�췃9�췃9�츪8�가6�끽3�름/�潔Z�暾e�和�背o�� �朗Z�和�槁O�鱇Z�름/�끽3�가6�츪8�췃9�췃:�췅:��<��>��@��C�괬I�ĦC�슄?�XV�+.�,,�7Z�8j�6g�1^�4e�Y쒆�?w�'Lu�Dq� E�'�)�.�)�)�+�(�)�)�(�(�)�(�(�(�(�(�(�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �/�/�/�/�-�-�+�+�-�-�+�(�)�(�(�*�(�)�)�(�+�#)7�/]�.G�3O�.F�I뗉�/X�4c�6h�8k�"1.�-.�II�{.�ℓC�ぃA��A�А=�쳽:�첑8�찟6�찞6�찞5�찞5�찞5�찞3�쵞2�가/�땋+�痼a�和�出�和�풍�和�和�和�缺_�땅+�가/�쵞2�첏4�찞5�찞5�찞6�찟6�첑8�쳽:�А=��B�イC�ℓC�|x)�CD �,.�"1-�8k�6h�4c�/X�]샭�-D�.G�,B�0b�#0�)�,�)�)�+�(�)�)�(�(�)�(�)�)�(�)�)�)�)�)�(�(�)� �뇦� �MNN�MNN� �뇦� �/�/�/�-�/�-�-�-�+�+�(�)�)�(�(�*�(�)�)�(�+�1W�K}� <[�"@b�$Ac�:t�1[�4d�6g�7b�++�03
�gc�쐷?�짗;�ィ@�쯃<�쯀8�쬄6�쬂4�쪠3�쪟2�쪟2�쪟2�쪟2�쬁0�쮾/�췂,�끽(�錤n�箱�홴-�緣�틀�蔭�풩*�軾�懃l�끽(�췂,�쮾/�쬂0�쪟2�쪟2�쪟2�쪠3�쬂4�쬄6�쯀8�쯃<�ィA�쩅<�썥>�b_�-/�**�7b�6g�4d�1[�L�6T�!>_�6T�Fw�-S�)�,�)�)�+�(�)�)�(�(�)�(�)�)�*�(�)�)�)�)�(�(�)� �뇦� �MNN�MNN� �뇦� �-�-�-�-�+�+�,�(�(�*�(�(�(�(�(�*�(�(�)�(�+�7p�[볆�G��=l�>y�*V�2^�5e�7g�1;�--�AB�wu'�쩆?�짔9�쭫<�쪢7�짌3�쥥1�죾0�죻/�죻/�죻/�죻/�죻/�짉-�쪟,�쳺*�깻&�勒��決O�替�蠟W�틂�澧]�足�痼T�隷}�깼&�쳺*�쬂+�쥤-�죻/�죻/�죻/�죻/�죾0�쥥1�짌3�쪢7�쭫;�쩄:�짘>�vr#�=> �+,�1;�7g�5e�3_�1]�Gx�-V�4b�[붥�6o�)�,�)�)�+�(�)�)�(�(�)�(�)�)�(�(�(�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �+�-�,�,�+�,�*�*�*�*�*�-�-�+�*�*�(�(�)�(�%"�K�R먖�B눻�N띜�.o�0X�3a�5e�6g�$*�.0�TR�럩3�윓7�쩃9�짎5�죾2�윍/�옊-�쓳,�쓳,�쓳+�옉,�옉,�쓳+�젗*�짉)�첎'�깻#�璹�槁M�杭�例[�틀�隙D�杭�枯L�屑�깻#�첎&�짉)�윋)�쓳*�옉,�쓳,�쓳,�쓳,�옊-�윍/�죾2�짎5�쩃:�윓7�뜈2�QO�-/�$*�6g�5e�3a�1Z�M뤧�8~�*j�T뫳�K�#�,�)�)�+�(�)�)�(�(�)�(�)�)�(�(�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�+�-�/�/�-�(�+�+�(�(�*�(�(�)�(�&8�-e�N뚎�K뤧�I듡�+i�0Y�2b�4e�5^�((�36�ea�윖<�쓼5�줂6�윎1�쐭-�쉹*�쉸)�쉷(�쉷(�쉷(�솉(�쉷(�썚'�쐫&�죺%�찚"�뇩�珽�枯?�抗�抗�튿�抗�抗�暠@�迹�뇩�첍#�쥟%�쓲&�썚'�솉(�솉(�쉷(�솉(�쉸)�쉹*�쐮-�윎1�줃6�쓼5�윖<�c_�14�''�4\�4e�2b�0Y�C�3}�a�N뚌�-d�$5�,�)�)�+�(�)�)�(�(�)�(�)�)�(�(�)�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�+�-�+�+�+�(�(�(�(�*�(�)�)�(�+K�Ay�G됍�P믔�A꾁�([�0[�2b�4e�/C�*+�@A �so �씂7�쐳2�옏2�쉻,�삇)�뼂'�븡&�븡%�븡%�븡%�븡%�븡$�뿃#�쐪#�죺!�찚�쒜3�標�講:�標�標�짯�標�標�鵑=�標�쉭0�쳹�죺!�썜#�뿃$�븡$�븡%�뻿%�뻿%�븡&�뼂'�삆)�쉻,�옏2�쐳3�씂7�rn�=>
�)*�/B�4e�2b�0[�9j�;곉�"d�H꽾�@w�)H�,�)�)�+�(�)�)�(�(�)�(�)�)�(�(�)�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�,�,�+�,�,�)�)�(�(�*�(�)�)�(� 0[�M녍�D눸�L뤧�9}�N�1\�3c�4e�+-�--�KJ��~(�솗4�슂2�솑.�븤*�뱣&�뭸$�몘$�몘$�몘$�몘$�몘#�뭸#�븞 �뿂�쓯�첌�狼r�跛�抉B�跛�跛�젱�跛�跛�횅5�跛�瑙j�찙�윇�솉�븠#�뭷#�몘#�몘$�몘$�몘$�뭸$�뱣&�븣*�솑.�슂3�슄5��~(�IH �+,�+-�4e�3c�1\�.Z�?녟�_�@|�N녎� /Z�,�)�)�+�(�)�)�(�(�)�(�)�)�(�(�)�(�)�)�(�(�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�,�+�-�-�-�+�)�(�(�*�(�)�)�(�2f�T뚇�H듡�K뤥�.r�Es�1]�3c�5e�)�.0�RP�땲.�뿋2�삊/�븤+�몚&�럢#�뛽"�뛼 �뛼 �럟 �룂 �룂���럞�뿁�쨺�뇨�音�坼�坼�坼�坼�윕�坼�坼�坼�坼�羽�깹�짃�뿁�몗�몖�릢�럠 �럟 �뛼 �뛽!�럢#�몚&�븤+�삊/�뿋2�땲.�QO �-/�(�5e�3c�1]�$Kv�F뗎�c�9x�V뤝�2f�,�)�)�+�(�)�)�(�(�)�(�)�)�(�(�)�(�)�(�(�(�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�,�,�,�+�,�(�(�(�(�*�(�)�)�(�6o�U먒�K띛�R붨�0v�<e�1_�3c�4e� $�13�XU�뱪4�뵍1�뵊-�릧'�똽#�뒃�����똼�뛼�똻��~�뛻�븞�젒�캕�講E�瑃�瑃�瑃�瑃�瑃�엮�瑃�瑃�瑃�瑃�瑃�회:�츦�윇�뵁�럞�뛽�룄�똼�땫�땬�땫�뛽#�릧'�뵊.�뵍1�뱪4�VS�02�#�4e�3c�1_�>f�C댭�b�A~�\뽗�6p�,�)�)�+�(�)�)�(�(�)�(�(�(�(�(�)�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �,�*�*�,�,�,�*�*�,�,�*�*�*�*�*�*�(�)�)�(�=y�\믅�8a�G{�5z�8^�1`�3d�4e�!�36�[V�뼋6�뭾/�뭽+�뛿$�뒃 �����럟�릢�뢾�뢿�뱟�뿁�죸�츧�챰,�音�涕�涕�涕�涕�涕�쑵�涕�涕�涕�涕�涕�嵬�윗#�췀�젒�뿁�몖�몖�뭷�룄�뛼�뛼�똽 �럣$�뱦+�뭾.�뼋6�ZU�25� �4e�3d�1`�8_�B눺�+e�?n�`쇭�;y�,�)�)�+�(�)�)�(�(�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�(�)�,�,�,�(�(�(�(�(�(�(�(�(�*�(�)�)�(�F�%>Z�,D�*@�7o�7\�1a�3d�4f��47�\W�솘8�릩.�뜃(�땬#��~��~�럞�봿�뻽�솆�쓮�젏�찙�천K�裴�適�蒼�蒼�蒼�蒼�蒼�蒼�쉠�蒼�蒼�蒼�蒼�蒼�蒼�靷�隷s�쉰<�쪜�윇�썙�삃�뿁�뱡�룂�똼�뛾#�럥(�릩-�삎7�\W�36��4f�3d�1a�7\�@y�)@�(>�0Mn� F�,�)�)�+�(�)�)�(�(�)�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �(�(�(�*�,�,�(�(�(�(�(�(�(�(�(�*�(�)�)�(�H�8U� <\�6T�7h�8\�1a�3d�4f��47�\W�뼌6�럧.�뒆'�� ��~�땫�뱡�쐩�짅�랬9�睾o�횔W�퓔H�桎�桎�桎�桎�桎�桎�桎�桎�桎�섄�桎�桎�桎�桎�桎�桎�桎�桎�桎�획M�캘N�횝a�괌+�짃�쓮�븞�뛽�땬 �뜂&�럧-�뼌6�\W�36��4f�3d�1a�;`�2b�5R�:Y�5P�I�,�)�)�+�(�)�)�(�(�)�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �(�(�(�*�(�)�)�)�)�)�)�(�(�(�(�*�(�)�)�(� @|�Jy�*Mt�7^�<}�:`�1`�3d�4f��47�[V�뱪4�뜆.�늹&��~ ��}�릢�변[�凜�飮�卽�卽�芋�섄�詣�卽�卽�卽�卽�卽�卽�卽�兪�빻�音�卽�卽�卽�卽�卽�卽�卽�芋�섄�詣�卽�卽�譽�錟�땝Q�뭷�똽�땯%�럦-�뱪4�[V�36��4f�3d�1`�=c�5v�%Gl�#Be�Bl� A}�,�)�)�+�(�)�)�(�(�)�(�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�(�(�*�(�)�)�)�)�)�)�)�)�(�(�*�(�)�)�(�8q�Y볂�I뗉�Y싖�:|�<d�1`�3d�4f��35�XS�럨2�뙂.�뀅&��}��}�몖�搏�鏃�鏃�鏃�鏃�鏃�鋼P�僅`�鏃�鏃�鏃�鏃�鏃�鏃�鏃�翔�벨�裳�鏃�鏃�鏃�鏃�鏃�鏃�鏃�瑙f�跏J�鏃�鏃�鏃�鏃�鏃�輩�뵂�땭�뎱%�뜄.�럨2�XS�24��4f�3d�1`�@g�.t�/m�V먒�]붢�7q�)�)�)�+�(�)�)�(�(�)�(�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�(�(�*�(�)�)�(�(�(�(�(�(�(�(�*�(�)�)�(�2f�R듔�@궹�J뚏�7y�Eq�2_�3d�4f� �.0�RN �뀇,�뙃/��&��|��|�릢�쉼Q�足�足�足�足�足�續�섐�掌�足�足�足�足�足�足�潔V�벨�适^�足�足�足�足�足�足�掌�섐�鮮�足�足�足�足�足�엿W�뵃�땭�늺%�뜆.�뀇,�RN �.0� �4f�3d�2_�"Is�$j�'d�T뫱�R듔�2e�.�)�)�+�(�)�)�(�(�)�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �*�*�*�*�(�)�)�(�(�*�*�*�*�*�*�*�(�)�)�(� 0[�H�A꺢�F됎�4v�"N��3]�4d�5f�%-�*+�KJ �{x&�뙄0��&�z�굖�뛻�쨼�芋�珽�珽�珽�珽�珽�졈3�率z�珽�珽�珽�珽�珽�庵�츩�쮻�캗�縯�珽�珽�珽�珽�珽�妄��졀.�珽�珽�珽�珽�珽�兪�쬀�몘�늸�뇗%�뜇/�{x&�KJ �*+�%-�5f�4d�3]�)V�d�,j�W붣�J�� /Z�,�)�)�+�(�)�)�(�(�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �(�(�(�(�(�)�)�(�(�(�(�(�(�(�(�*�(�)�)�(�+L�<r�C곀�H됌�8x�&V�4[�4d�Line truncated
�mi�럪2��(�|x!�~v��~�쐩�狼y�迹�迹�迹�迹�迹�妄�열*�杖�迹�迹�暲�琅�쭥�븞�몗�뿁�첍$�撈�杖�迹�迹�杖�유.�勞~�迹�迹�迹�迹�迹�率|�윉�똽�� �뀆(�럪1�mi�CD
�"#�*B�5e�4d�4[�*^�a�2o�Y붣�=r�*I�,�)�)�+�(�)�)�(�(�)�(�(�(�(�(�)�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �(�(�)�)�)�)�)�(�(�(�(�(�(�(�(�*�(�)�)�(�&8�(^�<y�3w�1t�X�5Y�3d�5f�3_��<>�_Z�룑4�꼥*�{w!�zt�굕�뱠�랫/�迪�迪�迪�迪�迪�音�빻�僕�迪�拍�츫,�삂���걐�}r�걐���쉳�뇹7�不�迪�僕�빻�音�迪�迪�迪�迪�迪�릇2�뿁��~��} �냳)�룑3�_Z�<>��3]�5f�3d�5Y�,i�X�=z�[뽘�*`�$5�,�)�)�+�(�)�)�(�(�)�(�(�(�(�(�)�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�(�(�*�(�)�)�(�%"�G�=x�/r�6{�"b�7W�4c�6f�7g��35�QN ��*�뇚.�}y$�wr�{t��~�옄�翔�暲�暲�暲�暲�暲�勤f�랬7�꺄@�뱠��}�t�um�ng�le�of�wn�걐��~�뵂�랴I�묵;�苟e�暲�暲�暲�暲�暲�鐥�젔�똼�굗��|#�늼-��+�RN �35��7g�6f�4c�7W�+p�`�M뗆�^샰�H�#�,�)�)�+�(�' �)�(�(�)�(�)�)�(�(�)�(�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�-�/�,�)�)�)�-�-�)�)�(�(�*�(�)�)�(�+�6o�C|�0q�7{�&f� Ix�5`�5f�6h�&;�$%�EE
�mg�뙅2�}'�wt�vq��w�릢�윱U�鱗�鱗�鱗�鱗�語�릎L�몗��{�|r�sk�le�f` �b\ �`[ �b\ �fa �mf �tk�|s��{�뭶�벽M�語�鱗�鱗�鱗�鱗�졔V�뱡��}�z��&�뙅1�mg�EF
�$&�&:�6h�5f�5`�)Q~�c�(e�P뚈�V롃�5n�)�,�)�)�+�(�/�' �'�(�)�(�)�)�(�(�)�(�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �,�)�,�/�/�,�(�(�(�/�/�-�(�(�(�*�(�)�)�(�+�.S�:p�7w�E끤�>z�(^�:_�6f�7h�5a��;>�]V�늽/�긾-�xv#�to�yq�긻�뿂�裔�靷�裔�탁b�썚��|�zq�qi�ha�b\ �_Y �[W �YT �XT �YU �\W �_Y �c] �ic�qi�{q��|�쐩�틥d�裔�靷�裔�솈��~�x�~z"�꼦,�늽/�]V�;>��5a�7h�6f�:_�;q�(g�>|�Y붢�Cx�+P�)�,�)�)�+�(�-�/�' �'�)�(�)�)�(�(�)�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�-�/�/�,�*�*�-�-�/�-�,�*�*�*�*�(�)�)�(�+�!%3�!W�E�"<Z�%:�#Fl�'Dg�6f�7h�8i� ,�*,�KJ �ni�뎶1�}{'�ur�sn�wp�굓�옅q�융n�뻿��~�u�rj�h` �`[ �[V �WR �VQ �UQ �TP �TP �TP �UQ �VQ �XS �[V �`[ �ha �rj��u��~�뿃�윷n�윷r��{�}v�{x��}&�뎶0�nj�KJ �*,� ,�8i�7h�6f�(Ei�'Jo�'=�+Jl�`쇪�#X� .�)�,�)�)�+�(�-�-�.�' �(�(�)�)�(�(�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �,�/�/�/�/�(�/�0�/�-�,�(�(�(�(�*�(�)�/�/�+�-�>��Cq�1L�7U�/H�+[�8a�7h�8j�1Y��=?�UP�긽-�긾,�xu#�qo�pk�rl�vo�yo�vn�qi �ic �a[ �ZV �VR
�SO
�QN
�QN �QN �QN �QN �QN �QN �QN �QN
�SO
�VR
�ZV �a[ �jd �rj�xp�{r�zs�xq�xs�|y"�뀇+�긽,�UP�=?��1Y�8j�7h�9b�6a�/H�6S�/I�P겢�?��'�)�,�)�)�+�(�,�,�,�.�' �'�)�)�(�(�)�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�,�/�-�-�,�2!�2!�/�-�(�(�(�(�(�*�(�/�2!�0�+�.�,L�@r�$Bd�#Bd�+Mr�-k�;]�7g�8j�9l�!2�$%�IH �d^�뒌2�})�us �mj�if�ib�fa�c^ �^Z �[V
�UQ
�QM �PL �NK �NK
�NK
�NK
�NK
�NK
�NK
�NK
�NK
�NK
�NK �PL �QM �UR
�[V �`[ �f`�jd�nh�qn�wu ��(�땳1�d^�IH �$%�!2�9l�8j�7g�;]�8t�#Ac�#Bd�%Bd�Cu�/N�(�)�,�)�)�+�(�+�+�+�,�.�' �(�)�(�(�)�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�-�/�/�/�0�2!�2!�/�-�)�)�)�(�(�*�,�2!�/�,�+�.�"�J�L꽶�K겤�X붠�=|�M�9b�9j�:k�8g� �78�PM�oj�뇞0�|z(�po �if�b_�]Z�WU�TQ �PM
�NK
�KI �KI
�KI
�KI
�KI
�KI
�KI
�KI
�KI
�KI
�KI
�KI
�KI
�KI
�LJ
�OL
�RO �VT �\Y�c_�ig�rp�|z'�늾0�oj�PM�78� �8f�:k�9j�9b�$S�V�@x�J{�]믃�L�&#%�(�)�,�)�)�+�(�*�*�*�+�+�.�' �(�(�(�)�(�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �/�/�0�2!�2!�0�2!�2!�/�,�)�)�)�,�/�2!�/�/�,�(�+�.�(�-Q�<p�J녓�T뫯�N뗅�*h�<_�8i�9l�:m�'B��>A �QO�wu&�뀉/�xx'�nm�db�\Z�WU�QO �NL �LJ �KI
�JH
�IG �IG �IG �IG �IG �IG �IG �IG �IG �IG �JH
�KI
�LJ �NL �QO �VT�\[�db�nl�yx&�뀉/�wu%�QO�>A ��'B�:m�9l�8i�<_�V�(e�O뗅�]뽔�Dx�/T�+�(�)�,�)�)�+�(�*�0�*�*�+�+�.�' �'�(�)�(�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �/�/�2!�2!�2!�0�/�/�,�(�(�(�-�0�2!�2!�2!�/�)�(�+�.�-�$",�G�G궮�M뚈�M띖�J댥�%U�<d�9l� :m� ;n� 3��DF
�]V�zw'�꼧0�ww(�ll!�cc�[[�VU�QP�NM �KJ �JI
�IH
�HG �GF �GF�FE�FE�GF�GF �HG �IH
�JI
�KJ �NM �QP�VU�[[�cc�ll!�ww(�꼧0�{x'�]V�DF
�� 3� ;n� :m�9l�<d�K� \�H녏�U뤝�\빺�H�"#+�+�(�)�,�)�)�+�(�' �*�*�*�*�*�*�-�' �'�)�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �0�2!�0�2!�2!�0�0�/�,�/�/�/�0�0�0�0�/�,�)�(�-�-�/�-�,P�5j�>|�N롇�R먎�G눯�$R�"?l� <n�!<n�!<o�/� !�GH �]V�vt%�뀉1�yy)�nn#�ee�^^�XX�TS�PO�NM�KK �JI �IH �IH �HG �HG �IH �IH �JI �KK �NM�PO�TS�XX�^^�ee�nn#�yy)�뀉1�vt%�]V�HH � !�/�!<o�!<n�!<n�#@l�%R�V�>{�O뗃�Y믇�;o�-Q�*�+�(�)�,�)�)�+�(�)�/�)�/�)�)�*�*�-�' �(�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �2!�2!�0�0�0�0�/�/�/�/�0�2!�2!�0�0�/�(�)�-�0�0�/�-�,�&$�>�D{�@}�T볁�U롁�5_�.Ms�'Ct�!<o�"<o�=c�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=c�"<o�"<o�)Eu�-Lq�&O~�>{�F꺠�M댥�U뚂�>�$"$�*�+�(�)�,�)�)�+�(�' �)�' �)�)�)�)�)�*�-�' �'�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �0�0�0�0�0�0�/�0�2!�0�2!�2!�2!�/�0�0�-�-�0�0�/�/�,�/�,�(D�$Y�B~�D궴�":V�*A�+A�-Ou�$?o�<f�Ei�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Ei�<f�&Ar�-Lp�)?�*@�+Jl�P뗃�U뤝�#X�+H�*�*�+�(�)�,�)�)�+�(�)�/�)�/�)�/�)�)�)�)�-�' �'�(�(�(�)� �뇦� �MNN�MNN� �뇦� �0�0�0�/�/�/�2!�2!�2!�0�0�2!�0�/�0�0�0�0�0�/�/�/�/�/�,�)�-U�Au�C|�4O�;[�5R�3\�#Em�>\�U�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�U�>\�%Hq�&Iq�7T�:Z� ;X�M됇�<p�/U�*�*�*�+�(�)�,�)�)�+�(�& �(�& �(�& �(�(�(�(�)�)�-�' �(�(�(�)� �뇦� �MNN�MNN� �뇦� �0�0�2!�2!�2!�0�0�2!�2!�2!�2!�2!�2!�2!�2!�2!�0�0�0�!�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�.E�Kr�Y�Y�Mz� 0N� 0O� 0O� 0O� 0O� 0O� 0O� 0N� /N� /N� /N� 0O� 0O� 0O� 0O� 0O� 1P� 0O� 0O� 0O� 0O� 0O� 1O� 0O� 0O� 0N� 0N� 0O� 0O� 0O� 0O� 0O�N{�Y�Y�Kr�.E�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)��)�+�(�(�(�(�/�(�/�(�/�(�(�(�(�,�' �'�(�(� �뇦� �MNN�MNN� �뇦� �0�0�2!�2!�2!�0�0�0�2!�0�2!�2!�2!�2!�2!�0�0�/� �7P�#V}�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�Mp�Aa�X�Y�T� /L�5T�8Y�9Z�9[�:[�:\�;]�:\�:]�:]�9[�9[�;]�;^�<^�;^�;^�<^�<`�=a�=a�=`�<`�<`�=`�<`�<`�<_�;^�<_�=a�:\�3T�T�Y�X�Aa�Mp�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�#V}�7P��*�(�(�(�& �(�& �(�& �(�(�(�(�(�(�,�' �(�(� �뇦� �MNN�MNN� �뇦� �2!�2!�2!�2!�0�/�0�0�/�/�2!�2!�0�0�0�/�-� �/F�!Pv�Hk�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�@_�Q|�Y�X�7Y� 5V�;]�;]�:]�;^�Fi�j뜲�;]�<^�<_�<`�<`�<_�<^�<_�<`�<_�;^�<^�<_�<_�<_�;_��돪�;^��<^�;^�;^�;^�;]�;]�<^�;]� 5V�9[�X�Y�Q|�@_�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Hk�!Pv�/F��(�(�(�(�(�)�(�(�(�(�(�(�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �0�0�0�0�0�/�/�-�-�-�0�0�0�0�0�0��)=�!Pu�Gk�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Ab�Ei�Y�Y�Ck�6V�=a�<`�;^�;^�;]�i떘�▤�9[�i떘�▤�▤�▤�▤�▤�▤�▤�Eh�:\�눏�눏�눏�눏�렡�▤�렡�눏�눏�눏�눏�;]�<^�;^�:[�9[� 4S�Ai�Y�Y�Ei�Ab�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Gk�!Pu�)=��)�)�)�)�*�)�)�)�)�)�)�)�)�)�)�)�*� �뇦� �MNN�MNN� �뇦� �0�0�0�,�/�/�-�-�/�0�2!�2!�0�2!�0��$6�!Nr�Gk�Dg�Dg�8T�/F�/F�/F�/F�/F�/F�/F�/F�0H�>\�U�Y�My� 3R�9[�9[�:\�;]�;^�9\}�v샍�읚�Ce�g닼�떙�Cb�Cc�Cc�Cc�Cc�읚�Ce� 7V�g닼�g닶�g닼�g닶�v샋�읚�v샋�g닶�g닶�g닶�g닼�7X�9Z�9[�9[�9[�9[� 2Q�Lx�Y�U�>\�0H�/F�/F�/F�/F�/F�/F�/F�/F�/F�8T�Dg�Dg�Gk�!Nr�$6�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �/�/�,�/�-�/�0�0�0�2!�2!�2!�0�0�0� 1�!Ko�Fj�Be�Be�7S�Hh�/r�.o�.o�.o�-m�.p�.p�-l� Q}�Kr�Y�U�2R�9[�<^�;^�;]�;]�;^�겈�쎙�쎙�쎙�Ac�Tu�s빓�s빖�+Nm�렢�]}�d뀰�,Po�Bd�d넦�d넢�d넦�d넦�s빓�쎙�s빓�d넦�d넦�d넢�d넧�,Rt�=a�=a�=a�=a�>a�;\�4T�T�Y�Ks�"Z~�4겑�5~�5~�5z�2w�1y�3~�3}�2z�Mh�7S�Be�Be�Fj�!Ko�)�)�(�(�(�(�)�)�)�)�)�)�)�)�(�(�*� �뇦� �MNN�MNN� �뇦� �-�-�,�-�/�0�0�0�0�0�2!�2!�0�/�-� Hj�Fi�Ad�Ac�9V�8S�-m�.r�-o�-l�-l�-m�.p�.o�&_�Aa�X�X�9\� 6W�;^�<_�;^�;]�:\�9Z�6Xw�p뫑�뿻�?_}�*Kj�~졗�뫒�Rs�@a��Z{�뿻�w쇊�Bc�b깶�뿻�~졗�~졗�꽘�뿻�뿻�~졗�~졗�~졗�꽘�뿻�@a�9Y�9Z�9[�:\�9[�9[� 5U�8Y�X�X�Aa�"T�(_�)a�)a�)c�*g�+g�+h�+i�)b�7T�9V�Ac�Ad�Fi�)�)�(�(�*�(�(�(�(�(�(�(�(�(�(�(�*� �뇦� �MNN�MNN� �뇦� �0�,�-�-�0�0�2!�2!�2!�0�0�0�/�0�/� Fi�@b�?a�;[�.G�#S}�&Z�%U�$U�%V�%V�%X�%X�$U��De�Q|�Y�@f� 1O�8Y�8Y�8X�8Y�8Y�7X�6V� 3Q�_~�벜� 1P�?^�{쑠�4So�_�벜� 1O�>]z�f눉� 4R�_�벜�)Gd�_~�뜳�m렜�)Gc�4So�{쑠�Xw�>]z�벜�>^}�6W�7W�7W�7Y�7X�8Y�7X� 1O�?f�Y�Q|�Cf�#S��#S�$U�&Y�&Z�%V�&X�&X�%V�!Nz�.G�;[�?a�@b�)�)�(�(�*�*�*�*�*�*�*�*�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �0�,�-�/�/�0�0�2!�2!�2!�0�0�0�0�0�?a�>`�>`�3N�'`�1w�1w�0r�0r�1t�/r�.p�0u�1w�Nr�Q|�Y�Cl�7W�=a�=a�=a�<`�<_�<_�<`�4Wy�x쎗�맭�꽙�d넧�x쎗�x쎗�꽙�맭�x쎗�x쎗�x쎗�Np�7X� 5U�x쎗�맭�맭�맭�맭�맭�r뵸�3Vv� 7W� 7V�9[�=`�>b�>b�=a�=a�>a�=a�7W�Dm�Y�Q|�Pr�3z�3y�3z�4}�4}�3y�3y�3z�5겑�2y�'`�3N�>`�>`�)�)�(�(�*�(�(�(�(�(�(�(�(�(�(�(�*� �뇦� �MNN�MNN� �뇦� �,�/�,�,�-�/�0�2!�0�2!�2!�/�/�/�/�?a�=_�=_�4P�#Su�3z�1w�1u�1u�/t�0u�0u�1w�0t�'`�Aa�X�X�9[�7Y�<`�<_�<_�<_�<^�:]�v샍�뜳�뜳�b깵�Mn�[|�b깶�뜳�뜳�|왒�[|�[|�<_~�9Z�8Y�A`�Ml�|왏�궏�[|�?\�[|�눏�3Ww�:[�;]�<_�<_�<^�<^�<_�<_�7Y�8]�X�X�Aa�)e�1w�1w�/r�/t�1u�/r�.o�/r�/t�/r�"Ru�4P�=_�=_�)�)�(�(�*�(�(�(�(�(�(�(�(�(�(�(�*� �뇦� �MNN�MNN� �뇦� �,�0�/�0�/�/�-�/�0�0�0�0�-�,�,�!Fk�=`�;^�9[�1N�Gu�*b�+d�,h�-k�+g�)_�)]�)c�)b�Ms�Ks�Y�S� 1O�7W�8Z�8Z�8Y�8Y�7X�2Rp�a��떘� 1M� 3R�=[�z쎖�떘�떘�떘�1Qn� 2O� 4R�7V�St�떘�떘�떘�t빒�t빒�t빓�z쎗�떘�떘�Ll�8Y�8Y�8Y�8Y�8X�7W�6V� 0N�S�Y�Ks�Ls�)]�)_�)_�)`�)`�)`�(_�(]�)_�)a�Hu�1N�9[�;^�=`�)�)�(�(�*�(�)�)�)�)�)�)�)�(�(�(�*� �뇦� �MNN�MNN� �뇦� �/�0�0�0�0�/�-�-�/�/�/�/�/�/�-�:U�"Ej�<_�:]�9Z�1M�"Pt�/p�.m�-l�.m�.o�/p�/p�,i�(`�>\�U�Y�N{�3S�;^�;^�;]�;^Line truncated
*E�
)D�
)D� +H� ,I� ,J� ,I� +G� *E� +G� ,H� ,H� +H� +G�
*F� )E� )E�
(C�
(B� )D� *F� ,H� +I� *F�
*E� +G� ,J� ,I� ,I�Ju�Y�Y�Ks�Ac�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�'Jq�9T� �'�(�)�)�(�(�*�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�,�,�,�,�,�,�-�-�-�,�,�,�(��
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)� -�>\�U�Y�Y�Y�Y�Y�X�X�X�Y�Y�Y�Y�X�X�Y�Y�Y�Y�Y�Y�Y�Y�X�W�X�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�U�>\� .�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)��%�+�(�)�)�(�(�*�(�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�+�.�(�)�,�(�)�+�)�(�*�*�(�(� ,5�Ei�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Ei�+5�(�(�+�*�(�*�*�*�(�(�+�+�(�(�,�)�)�.�(�)�)�(�(�*�(�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�,�,�,�,�,�,�,�,�(�*�(�)�)�(�+�.�(�)�,�(�)�)�,�)�(�*�(�(�*�/>�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�.=�(�(�*�+�(�(�*�*�*�(�)�)�)�(�)�,�)�)�.�(�)�)�(�(�*�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�,�*�*�*�,�,�,�,�,�,�,�,�,�,�,�(�)�)�(�+�.�(�)�,�(�)�)�,�)�(�(�*�(�(�*�+�+�*�*�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�*�+�+�+�*�*�(�*�*�*�+�(�)�)�)�(�)�,�)�)�.�(�)�)�(�(�(�*�*�*�*�*�*�*�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �-�*�,�,�,�,�,�,�,�(�(�(�(�,�,�,�,�)�)�(�+�.�(�)�,�(�(�)�+�)�(�+�(�(�(�(�*�*�*�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�*�*�*�*�(�(�*�*�*�+�(�)�)�(�(�)�,�)�)�.�(�)�)�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �(�*�*�,�,�,�,�,�,�,�(�,�,�,�(�(�)�)�)�(�+�.�(�)�,�(�(�)�)�)�(�*�+�*�(�(�(�(�(�(�(�(�(�(�(�*�*�*�*�*�*�(�(�(�(�(�(�(�(�*�*�*�*�*�*�(�(�(�(�(�(�(�(�(�(�(�(�*�*�*�*�*�(�)�)�(�(�)�,�)�)�+�(�)�)�)�(�(�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �(�(�*�*�,�,�,�,�,�,�,�,�-�-�-�)�)�)�(�(�+�.�(�)�,�(�)�(�)�+�)�(�(�(�*�(�(�(�(�(�(�(�(�*�*�*�*�*�*�*�*�*�(�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�(�(�(�(�(�(�*�*�*�(�(�(�(�)�(�(�)�(�)�,�)�)�+�(�(�)�)�)�)�)�(�(�)�)�(�(�(�(�(�)�)� �뇦� �MNN�MNN� �뇦� �(�(�(�(�(�*�,�,�,�,�,�,�,�-�-�,�)�(�(�+�.�.�(�)�,�(�(�)�+�)�+�)�)�)�)�(�*�*�*�*�*�(�*�*�*�*�*�(�*�(�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�(�(�)�)�(�)�(�)�+�(�(�)�,�)�)�+�.�(�(�)�)�)�)�)�+�)�)�)�(�(�)�)�(�(� �뇦� �MNN�MNN� �뇦� �(�(�(�(�(�(�(�*�,�,�,�,�,�,�,�(�(�(�+�.�)�+�(�)�,�(�)�(�(�+�(�)�+�+�,�)�(�*�*�*�*�(�*�*�*�*�+�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�(�*�*�*�*�*�*�*�*�(�(�+�+�+�)�(�)�(�(�+�(�)�,�)�(�)�+�.�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�*�*�*�*�*�*�*�*�*�*�*�,�+�+�+�*�+�+�)�+�.�(�(�,�(�)�(�(�)�(�)�)�)�)�,�)�(�*�*�(�(�(�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�.!�+�*�*�*�*�(�(�*�(�(�)�)�)�)�)�(�)�(�(�)�(�)�,�)�)�+�)�+�+�+�+�+�.�.�.�+�+�+�+�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)�)�(�)�+�(�(�,�(�(�)�)�(�)�(�(�)�)�+�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)�)�)�(�(�)�(�)�)�(�(�)�)�)�(�)�(�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)�)�)�)�)�(�(�)�(�(�(�(�(�(�(�(�(�(�(�(�)�)�)Line truncated
�x릝�v�J�b�9�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�b�9�v�J�q늮�
�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��놃X�`�4�\�1�\�1�\�1�\�1�\�1�\�1�A{"�.W�A{"�\�1�\�1�A{"�.W�A{"�.W�A{"�\�1�\�1�\�1�\�1�A{"�.W�A{"�\�1�\�1�\�1�\�1�\�1�\�1�\�1�A{"�.W�A{"�A{"�.W�.W�.W�.W�.W�A{"�\�1�\�1�\�1�\�1�A{"�.W�.W�A{"�A{"�.W�A{"�\�1�\�1�\�1�\�1�\�1�`�4�놃X��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)����F�[�+�[�+�[�+�[�+�[�+�[�+�[�+�-S�汎��-S�[�+�[�+�-S�汎��-S�汎��-S�@v�[�+�[�+�[�+�-S�汎��-S�[�+�[�+�[�+�[�+�[�+�[�+�[�+�-S�汎��-S�-S�汎��汎��汎��汎��汎��-S�[�+�@v�-S�-S�-S�汎��汎��-S�-S�汎��-S�[�+�[�+�[�+�[�+�[�+�[�+���F��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��{�=�Y�&�Y�&�Y�&�Y�&�Y�&�Y�&�Y�&�,O�藜�,O�,O�?p�,O�藜�,O�,O�藜�,O�Y�&�Y�&�Y�&�,O�藜�,O�Y�&�Y�&�Y�&�Y�&�Y�&�Y�&�Y�&�,O�藜�,O�,O�藜�,O�,O�,O�藜�,O�Y�&�,O�藜�藜�藜�,O�,O�?p�,O�藜�,O�Y�&�Y�&�Y�&�Y�&�Y�&�Y�&�{�=��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��y�3�W� �W� �W� �W� �W� �W� �W� �+K�橋�橋�橋�+K�+K�橋�+K�+K�+K�=j�+K�+K�+K�+K�橋�+K�+K�+K�+K�+K�=j�=j�+K�+K�橋�+K�+K�橋�橋�橋�橋�橋�+K�W� �=j�+K�+K�橋�+K�W� �W� �+K�橋�+K�W� �W� �W� �W� �W� �W� �y�3��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��v�+�U��U��U��U��U��U��U��*G �톈�*G �*G �*G �*G �톈�톈�톈�톈�*G �톈�톈�톈�톈�톈�톈�톈�톈�톈�톈�*G �*G �톈�톈�톈�톈�톈�톈�*G �*G �*G �톈�*G �U��U��U��*G �톈�*G �U��U��*G �톈�*G �U��U��U��U��U��U��v�+��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��s�#�S��S��S��S��S��:_�)C �)C �쁨�)C �)C �쁨�쁨�쁨�)C �)C �)C �:_�)C �)C �)C �)C �쁨�)C �)C �)C �)C �쁨�)C �:_�)C �)C �쁨�)C �)C �쁨�쁨�쁨�쁨�쁨�)C �:_�)C �)C �)C �쁨�)C �)C �)C �)C �쁨�)C �)C �:_�S��S��S��S��s�#��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��g��Jr �Jr �Jr �Jr �Jr �%9�겔�겔�겔�겔�겔�%9�%9�겔�%9�겔�%9�Jr �Jr �Jr �Jr �%9�겔�%9�Jr �Jr �%9�겔�%9�Jr �Jr �%9�겔�%9�4P �%9�%9�%9�%9�%9�%9�%9�겔�겔�겔�겔�겔�겔�겔�겔�겔�겔�겔�%9�Jr �Jr �Jr �Jr �g���)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��=n�,E�,E�,E�,E�,E�"�Ξ�"�"�"�Ξ�"�"�Ξ�"�Ξ�"�,E�,E�,E�0�"�Ξ�"�,E�,E�"�Ξ�"�0�"�"�Ξ�Ξ�"�Ξ�Ξ�Ξ�Ξ�Ξ�Ξ�"�"�"�"�Ξ�"�"�"�"�Ξ�"�"�0�,E�,E�,E�,E�=n��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��>s�-H�-H�-H�-H�-H�$�싄�$�-H�$�싄�$�$�싄�$�싄�$�-H�-H�-H�$�싄�$�3�-H�-H�$�싄�$�$�싄�싄�싄�$�3�$�$�싄�$�$�$�3�-H�-H�$�싄�$�-H�-H�$�싄�$�-H�-H�-H�-H�-H�-H�>s��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��A{
�/M�/M�/M�/M�/M�&�뤐�&�&�&�뤐�&�!6�&�뤐�&�&�!6�/M�!6�&�뤐�&�/M�/M�/M�&�뤐�&�!6�&�&�뤐�&�&�뤐�&�뤐�뤐�뤐�&�/M�/M�!6�&�뤐�&�/M�/M�&�뤐�&�/M�/M�/M�/M�/M�/M�A{
��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��G��3S �3S �3S �3S �3S �)�끆�끆�끆�끆�끆�)�)�끆�끆�)�끆�)�$:�)�끆�)�$:�3S �3S �3S �)�끆�)�3S �3S �)�끆�)�)�끆�)�끆�)�)�$:�3S �3S �)�끆�)�$:�3S �3S �)�끆�)�3S �3S �3S �3S �3S �3S �G���)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��L��7Z �7Z �7Z �7Z �7Z �-�|닯�-�-�-�-�-�|닯�-�-�|닯�|닯�-�-�|닯�-�'@ �7Z �'@ �-�-�-�|닯�-�7Z �'@ �-�|닯�-�-�|닯�-�|닯�-�-�-�'@ �'@ �-�|닯�-�7Z �7Z �7Z �-�|닯�-�7Z �7Z �7Z �7Z �7Z �7Z �L���)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��S��;b�;b�;b�;b�;b�)E �1�)E �;b�;b�1�t��1�)E �)E �1�t��1�t��1�)E �;b�;b�1�t��t��t��1�)E �;b�1�t��t��1�t��1�t��t��t��t��t��1�1�t��1�)E �;b�;b�;b�1�t��1�;b�;b�;b�;b�;b�;b�S���)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��Z�&�Ao�>i�>i�>i�>i�>i�>i�>i�>i�>i�,J�4
�,J�>i�>i�,J�4
�,J�4
�,J�>i�>i�>i�,J�4
�4
�4
�,J�>i�>i�,J�4
�4
�,J�4
�,J�4
�4
�4
�4
�4
�,J�,J�4
�,J�>i�>i�>i�>i�,J�4
�,J�>i�>i�>i�>i�>i�Ao�Z�&��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �S�6�Q�!�Dt�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�Dt�Q�!�O�4�
�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�" ��V�;�a�/�^�*�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�^�*�a�/�V�;��$ �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�" � ��������������������������������������������������������� �" �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�( �( �( �)�)�( �( �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�( �)�)�( �( �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�( �)�)�( �( �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�( �)�)�( �( �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�뇦� �MNN�MNN� �뇦�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�( �)�)�( �( �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�뇦� �MNN�MNN� �뇦�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�( �)�)�( �( �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�뇦� �MNN�MNN� �뇦� ��������������������������������������������������������������������������������������������������������������� �뇦� �MNN�MNN� �뇦���{;�즬B�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�즬B��{;��뇦� �MNN�MNN� �뇦��웭@�뛧@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@Line truncated
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�kI*�( �뇦� �MNN�MNN� �뇦�)�jH)�@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �jH)�)�뇦� �MNN�MNN� �뇦�)�iG(�?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �iG(�)�뇦� �MNN�MNN� �뇦�)�hF(�=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �hF(�)�뇦� �MNN�MNN� �뇦�)�gE'�<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �gE'�)�뇦� �MNN�MNN� �뇦�)�fD'�;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �fD'�)�뇦� �MNN�MNN� �뇦�)�eC&�9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �eC&�)�뇦� �MNN�MNN� �뇦�)�dB&�8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �dB&�)�뇦� �MNN�MNN� �뇦�)�cA%�7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �cA%�)�뇦� �MNN�MNN� �뇦�)�b@$�6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �b@$�)�뇦� �MNN�MNN� �뇦�)�a?$�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�a?$�)�뇦� �MNN�MNN� �뇦�)�`>#�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3Line truncated
���������������������������������������������������������������������������������������������������������������
�뇦� �MNN�MNN� �뇦��쯻9�웭@�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁰@�뾪8�엙5�쯹6��뇦� �MNN�MNN� �뇦��쉜=�냙>�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�{];��]1�쁫2��뇦� �MNN�MNN� �뇦��똦;�sW8�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�븝�fO7�fO7�븝�fO7�fO7�fO7�븝�fO7�fO7�fO7�fO7�fO7�fO7�fO7�븝�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�븝�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�븝�fO7�븝�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�lM+��`.��뇦� �MNN�MNN� �뇦��꼊7�iQ5�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�븝�[H3�븝�[H3�[H3�[H3�븝�[H3�[H3�[H3�[H3�[H3�[H3�븝�븝�븝�븝�븝�븝�븝�[H3�[H3�[H3�[H3�[H3�븝�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�븝�[H3�[H3�븝�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�aF'�Z*��뇦� �MNN�MNN� �뇦��}]4�`J/�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�븝�븝�븝�븝�븝�븝�븝�븝�R@.�R@.�븝�R@.�븝�R@.�R@.�R@.�븝�R@.�R@.�R@.�R@.�R@.�R@.�븝�R@.�R@.�R@.�R@.�븝�븝�븝�븝�R@.�R@.�븝�븝�븝�븝�븝�븝�븝�븝�븝�븝�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�W?!�wT&��뇦� �MNN�MNN� �뇦��uY1�XD-�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�븝�븝�I:*�I:*�I:*�븝�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�븝�븝�븝�I:*�I:*�I:*�I:*�I:*�븝�븝�븝�븝�븝�븝�I:*�븝�I:*�I:*�븝�I:*�I:*�븝�I:*�I:*�I:*�I:*�븝�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�N8�oO#��뇦� �MNN�MNN� �뇦��pU.�Q@)�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�븝�@5&�븝�@5&�@5&�@5&�븝�@5&�@5&�@5&�@5&�@5&�@5&�븝�븝�@5&�@5&�@5&�븝�븝�@5&�@5&�@5&�@5&�@5&�븝�@5&�@5&�븝�@5&�븝�@5&�@5&�븝�@5&�@5&�븝�@5&�@5&�@5&�@5&�븝�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�F3�iK ��뇦� �MNN�MNN� �뇦��mR.�M='�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�븝�=2%�=2%�븝�=2%�븝�븝�븝�븝�븝�=2%�=2%�븝�븝�=2%�=2%�=2%�=2%�=2%�=2%�=2%�븝�븝�=2%�=2%�=2%�븝�=2%�=2%�븝�=2%�븝�=2%�=2%�븝�=2%�=2%�븝�븝�븝�븝�=2%�븝�=2%�=2%�븝�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�B0�fH��뇦� �MNN�MNN� �뇦��mR.�M='�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�븝�=2%�>2%�>2%�>2%�>2%�>2%�=2%�=2%�븝�븝�븝�븝�븝�븝�븝�>2%�>2%�>2%�>2%�>2%�븝�>2%�>2%�븝�>2%�븝�>2%�>2%�븝�>2%�=2%�븝�=2%�=2%�븝�=2%�븝�=2%�=2%�븝�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�B0�fH��뇦� �MNN�MNN� �뇦��fH�B0�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�1$�1$�1$�1$�1$�1$�1$�1$�1$�븝�븝�븝�븝�븝�븝�븝�븝�븝�븝�븝�1%�1%�2%�븝�2%�2%�븝�2%�2%�븝�2%�2%�2%�2%�2%�븝�2%�2%�븝�2%�븝�1%�1%�븝�1%�1%�븝�1%�1%�븝�1%�븝�1$�븝�1%�1$�1$�1$�1$�1$�1$�1$�1$�1$�1$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�B0�fH��뇦� �MNN�MNN� �뇦��fH�B0�0$�0$�1$�1$�1$�1$�1$�1$�1$�1$�1$�1$�1$�1$�1$�1%�1%�1%�1%�2%�2%�2%�2%�2%�2%�3&�3&�3&�4&�4&�4&�4&�4'�븝�5'�븝�5'�6'�5'�6'�6'�6'�6'�븝�븝�븝�븝�븝�븝�븝�7(�6(�6(�7(�7(�븝�6(�6(�븝�6(Line truncated
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�B'
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�wV0�0�뇦� �MNN�MNN� �뇦�.�wV0�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�A&
�A&
�@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �B'
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�wV0�1�뇦� �MNN�MNN� �뇦�0�vU0�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �A' �D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�vU0�1�뇦� �MNN�MNN� �뇦�1�vU0�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �A' �C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�vU0�1�뇦� �MNN�MNN� �뇦�0�uU0�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �A' �C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�uU0�.�뇦� �MNN�MNN� �뇦�1�uT/�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �@& �C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�uT/�.�뇦� �MNN�MNN� �뇦�1�uT/�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �@& �B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�uT/�0�뇦� �MNN�MNN� �뇦�1�tS/�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�?% �?% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �@& �B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�tS/�-�뇦� �MNN�MNN� �뇦�0�tS/�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�>% �>% �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �?& �A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�tS/�.�뇦� �MNN�MNN� �뇦�-�sS/�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �>% �A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�sS/�.�뇦� �MNN�MNN� �뇦�-�sR.�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �>% �A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�sR.�.�뇦� �MNN�MNN� �뇦�.�sR.�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �>% �@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�sR.�1�뇦� �MNN�MNN� �뇦�0�rQ.�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �=$ �@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�rQ.�1�뇦� �MNN�MNN� �뇦�.�rQ.�@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �=$ �@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �rQ.�0�뇦� �MNN�MNN� �뇦�.�qP.�?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �=$ �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �qP.�.�뇦� �MNN�MNN� �뇦�-�qP-�?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �=$ �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �qP-�.�뇦� �MNN�MNN� �뇦�+�qP-�>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �<# �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �qP-�+�뇦� �MNN�MNN� �뇦�.�pO-�>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �<# �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �pO-�.�뇦� �MNN�MNN� �뇦�,�pO-�=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �<# �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �pO-�&
�뇦� �MNN�MNN� �뇦�,�oN,�=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �:! �:! �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �;" �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �oN,�+�뇦� �MNN�MNN� �뇦�,�oN,�=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �9! �9! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:" �=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �oN,�+�뇦� �MNN�MNN� �뇦�,�nM,�<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �:" �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �nM,�+�뇦� �MNN�MNN� �뇦�,�nM,�<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �:" �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �nM,�+�뇦� �MNN�MNN� �뇦�)�nL,�;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �9! �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �nL,�+�뇦� �MNN�MNN� �뇦�(�mL+�;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �9! �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �mL+�+�뇦� �MNN�MNN� �뇦�*�mK+�;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �9! �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �mK+�+�뇦� �MNN�MNN� �뇦�.�lK+�:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �7 �7 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �lK+�&
�뇦� �MNN�MNN� �뇦�0�lJ+�:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �8 �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �lJ+�&
�뇦� �MNN�MNN� �뇦�0�kJ*�9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �8 �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �kJ*�&
�뇦� �MNN�MNN� �뇦�0�kI*�9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �8 �9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �kI*�+�뇦� �MNN�MNN� �뇦�0�jI*�8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �6 �6 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �jI*�&
�뇦� �MNN�MNN� �뇦�0�jI*�8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �7 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �jI*�*�뇦� �MNN�MNN� �뇦�0�iH)�7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �iH)�*�뇦� �MNN�MNN� �뇦�-�iH)�7 �7 �7 �7 �7 �7 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �7 �7 �7 �7 �7 �7 �iH)�+�뇦� �MNN�MNN� �뇦�0�iG)�7 �7 �7 �7 �7 �7 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �7 �7 �7 �7 �7 �7 �iG)�+�뇦� �MNN�MNN� �뇦�0�hG)�6 �6 �6 �6 �6 �6 �5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�6 �6 �6 �6 �6 �6 �hG)�+�뇦� �MNN�MNN� �뇦�1�hF)�6 �6 �6 �6 �6 �6 �4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�6 �6 �6 �6 �6 �6 �hF)�+�뇦� �MNN�MNN� �뇦�1�gF(�5 �5 �5 �5 �5 �5 �4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�5 �5 �5 �5 �5 �5 �gF(�+�뇦� �MNN�MNN� �뇦�1�gE(�5 �5 �5 �5 �5 �5 �3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�4�4�4�5 �5 �5 �gE(�+�뇦� �MNN�MNN� �뇦�1�fE(�4�4�4�)�)�)�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�����4�4�fE(�+�뇦� �MNN�MNN� �뇦�0�fD(�4�4�,�41(�41(�41(�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�궪�궪�궪�2EF�%�4�fD(�+�뇦� �MNN�MNN� �뇦�.�eD'�3�3�+�31(�31(�31(�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3Line truncated
��4�*�*�*�*�*�*�[9"�,!�뇦� �MNN�MNN� �뇦�,�[9"�)�)�)�)�)�)�)��8:2�Xnl�Sor�Edj�" �3�3�3�3�3�3�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�3�3�3�3�3�3�+� !,�@샓�Y��g��5QX��)�)�)�)�)�)�[9"�(�뇦� �MNN�MNN� �뇦�+�Z8"�)�)�)�)�)�)�41(�bqj�j�}�_xw�Sor�,1.�%�2�2�3�3�3�3�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�3�3�3�3�3�3�3� �>O�Y��r쬠�됓�궪�)�)�)�)�)�)�Z8"�,!�뇦� �MNN�MNN� �뇦�,�Z8"�)�)�)�)�)�)�31(�brk�i�}�^xw�Ror�+1-�$�2�2�3�3�3�3�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�3�3�3�3�3�3�2� �>O�Y��r쬠�됓�꽲�)�)�)�)�)�)�Z8"�(�뇦� �MNN�MNN� �뇦�+�Z8!�(�(�(�(�(�(�(��7:2�Wnl�Ror�Ddj�! �2�2�3�3�3�3�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�3�3�3�3�3�3�+� !,�@샓�Y��g��5QX��(�(�(�(�(�(�Z8!�(�뇦� �MNN�MNN� �뇦�+�Y7!�(�(�(�(�(�(�3�2�&� �4=9�Cbg�7V_�1�1�2�2�2�2�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�2�2�2�2�2�2�# �)��?빞�,Vf�
��2�(�(�(�(�(�(�Y7!�(�뇦� �MNN�MNN� �뇦�+�Y7!�(�(�(�(�(�(�2�2�2�0�$��-=@�0�0�2�2�2�2�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�2�2�2�2�2�2�(//�Vr�� �.�2�2�(�(�(�(�(�(�Y7!�(�뇦� �MNN�MNN� �뇦�+�Y7!�(�(�(�(�(�(�2�2�2�2�2�-�"�0�0�1�1�1�1�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�1�1�1�1�1�1�#�
�(�2�2�2�2�(�(�(�(�(�(�Y7!�(�뇦� �MNN�MNN� �뇦�+�Y6!�'�'�'�'�'�'�1�1�1�1�1�1�1�0�0�1�1�1�1�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�1�1�1�1�1�1�.�1�1�1�1�1�1�'�'�'�'�'�'�Y6!�(�뇦� �MNN�MNN� �뇦�+�X6!�'�'�'�'�'�'�1�1�1�1�1�1�1�/�/�0�0�0�0�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�0�0�0�0�0�0�0�1�1�1�1�1�1�'�'�'�'�'�'�X6!�(�뇦� �MNN�MNN� �뇦�+�X6 �'�'�'�'�'�'�0�0�0�0�0�0�0�/�/�0�0�0�0�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�0�0�0�0�0�0�/�0�0�0�0�0�0�'�'�'�'�'�'�X6 �(�뇦� �MNN�MNN� �뇦�+�X5 �&�&�&�&�&�&�&�&�&�&�&�&�&�'�'�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�'�&�&�&�&�&�&�&�&�&�&�&�&�X5 �(�뇦� �MNN�MNN� �뇦�+�X5 �&�&�&�&�&�&�&�&�&�&�&�&�&�'�'�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�'�&�&�&�&�&�&�&�&�&�&�&�&�X5 �(�뇦� �MNN�MNN� �뇦�+�W5 �&�&�&�&�&�&�&�&�&�&�&�&�&�&�&�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�&�&�&�&�&�&�&�&�&�&�&�&�&�W5 �(�뇦� �MNN�MNN� �뇦�+�W5 �&�&�&�&�&�&�&�&�&�&�&�&�&�&�&�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�&�&�&�&�&�&�&�&�&�&�&�&�&�W5 �(�뇦� �MNN�MNN� �뇦�*�W4 �&�&�&�&�&�&�&�&�&�&�&�&�&�&�&�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�&�&�&�&�&�&�&�&�&�&�&�&�&�W4 �(�뇦� �MNN�MNN� �뇦�+�W4 �%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�%�%�%�%�%�%�%�%�%�%�%�%�%�W4 �(�뇦� �MNN�MNN� �뇦�,�V4 �%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�V4 �(�뇦� �MNN�MNN� �뇦�.�`>$�.�.�.�.�.�.�.�.�.�.�.�.�.�-�-�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.Line truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.
@@ -0,0 +1,69 @@
┤D ╟⌡Nи⌡Nи Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи Nи⌡Nи⌡Nи Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи Nи⌡Nи⌡Nи⌡Nи Nи Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи┤D ╟╗H июSФюSФюSФюRФюRФюS ФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюR ФюS ФюSФюSФюRФюSФюSФюRФюSФюRФюSФюSФюSФюSФюSФюSФюSФюSФюSФюRФюS ФюS ФюSФюRФюSФюSФюRФюS ФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюR ФюS ФюSФюSФюRФюSФюSФюSФюSФюSФюRФюRФюS ФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюRФюSФюSФюSФюSФюSФюRФюSФюS ФюSФюSФюSФ╗H и╧K исV
ФсV
ФсU ФсU ФсU ФсV ФсV
ФсV
ФсV
ФсV
ФсV ФсV ФсV
ФсV
ФсV
ФсV ФсV ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсV ФсV
ФсU
ФсV
ФсV
ФсV ФсV ФсV
ФсV
ФсV
ФсV ФсV ФсV ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсU ФсV ФсV
ФсV
ФсV
ФсV
ФсV ФсV ФсV
ФсV
ФсV
ФсV ФсV ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсU ФсU ФсU ФсV ФсV
ФсV
ФсV
ФсV
ФсV ФсV
ФсV
ФсV
ФсV
ФсV
ФсU
ФсV
ФсV ФсV
ФсV ФсV ФсV
ФсV
ФсV ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсV ФсV
ФсV
ФсV
Ф╧K ицTиъaФъaФъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`ФъaФъaФъaФъaФъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`ФъaФъaФъaФъaФъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`ФъaФъaФъaФъaФъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`ФъaФъaФъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`ФъaФъ`Фъ`Фъ`Фъ`ФъaФъaФъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`ФцTих`иЕoФЕoФЕoФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕoФЕoФЕoФЕoФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕoФЕoФЕoФЕoФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕoФЕoФЕoФЕoФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕoФЕoФЕoФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕoФЕoФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФх`ииoиФФФ~ФФФФФФ~ФФ~ФФ~ФФ~ФФФФ~ФФ~ФФ~ФФ~ФФ~ФФ~ФФФФФФФФФФФФ~ФФФФФФ~ФФ~ФФ~ФФ~ФФФФФФ~ФФ~ФФ~ФФ~ФФ~ФФ~ФФФФ~ФФФФФФФФФФФФФФ~ФФ~ФФ~ФФ~ФФФФ~ФФ~ФФ~ФФ~ФФ~ФФ~ФФФФФФФФФФФФ~ФФФФФФФФФФ~ФФФФФФ~ФФ~ФФ~ФФ~ФФФФФФФФФФФФ~ФФ~ФФ~ФФ~ФФФФ~ФФФФФФФФФФФФ~ФФФФФФ~ФФФФФФФФ~ФФ~ФФФФФиnии{иФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ▄ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ▄ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ▄ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█Фи{ии┘иФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤Фи┘ии█иФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║Фи█ии⌠иФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╖ФФ╖ФФ╗ФФ╗ФФ╖ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╖ФФ╖ФФ╗ФФ╖ФФ╖ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╖ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╖ФФ╖ФФ╗ФФ╗ФФ╖ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╖ФФ╖ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╗Фи⌠и╟┌╟и■ии⌠ии⌠ии■ии■ии⌠ии■ии■ии⌠ии⌠ии■ии■ии⌠ии⌠ии■ии■ии■ии■ии⌠ии■ии■ии■ии■ии■ии⌠ии■ии■ии■ии⌠ии⌠ии■ии■ии⌠ии■ии■ии■ии■ии⌠ии■ии■ии■ии■ии■ии■ии⌠ии■ии■ии⌠ии⌠ии■ии■ии⌠ии⌠ии■ии■ии■ии■ии⌠ии■ии■ии■ии■ии■ии■ии⌠ии⌠ии■ии■ии⌠ии■ии■ии⌠ии■ии⌠ии■ии⌠ии⌠ии⌠ии⌠ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■и╟┌╟
Binary file not shown.
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
#include "client_assets.h"
IDR_COMBAT_POWER_PANEL_SKIN_V2 RCDATA "assets\\combat_power_panel_skin_v2.bgra"
IDR_COMBAT_RANK_EXPLORE_V3 RCDATA "assets\\combat_rank_explore_v3.pbgra"
IDR_COMBAT_RANK_PIONEER_V3 RCDATA "assets\\combat_rank_pioneer_v3.pbgra"
IDR_COMBAT_RANK_FEARLESS_V3 RCDATA "assets\\combat_rank_fearless_v3.pbgra"
IDR_COMBAT_RANK_CONQUER_V3 RCDATA "assets\\combat_rank_conquer_v3.pbgra"
IDR_COMBAT_RANK_BATTLE_PINNACLE_V3 RCDATA "assets\\combat_rank_battle_pinnacle_v3.pbgra"
IDR_COMBAT_RANK_HEROIC_V3 RCDATA "assets\\combat_rank_heroic_v3.pbgra"
IDR_COMBAT_RANK_MARTIAL_MASTERY_V3 RCDATA "assets\\combat_rank_martial_mastery_v3.pbgra"
IDR_COMBAT_POWER_RANK_TOOLTIP_V2 RCDATA "assets\\combat_power_rank_tooltip_v2.bgra"
+25
View File
@@ -0,0 +1,25 @@
# client-patch/tools
编译与构建后处理脚本。完整说明见 [docs/客户端补丁与CRC校验.md](../../docs/客户端补丁与CRC校验.md)。
| 文件 | 用途 |
|------|------|
| `build_90cn.bat` | 双击入口,转调下面的 ps1 |
| `build_90cn.ps1` | MSBuild 编 `Release\|Win32` → 收集产物 → 调 `patch_dll_crc.py` 写自校验 CRC;`-SkipCrc` 可跳过(仅调编译用,别拿去发布) |
| `patch_dll_crc.py` | 补齐 / 校验 `90CN.dll` 自校验 CRC32 |
## 常用命令
```bash
# 编 DLL 并自动补 CRC
powershell -NoProfile -ExecutionPolicy Bypass -File build_90cn.ps1
# 只处理已有的 DLL
python patch_dll_crc.py info <dll> # 看节表与槽位
python patch_dll_crc.py verify <dll> # 校验当前 CRC
python patch_dll_crc.py patch <dll> # 重写 CRC(-o 可输出到副本)
```
退出码:`0` 通过 / `1` 校验失败或找不到槽位 / `2` 用法错误。
> ⚠️ 改完 DLL 除了补 CRC,还要**重编服务端**——`DNF90Server.exe` 在编译期绑定了 DLL 的 SHA-256,指纹不配套会被拒绝连接。
+7
View File
@@ -0,0 +1,7 @@
@echo off
rem Double-click wrapper for build_90cn.ps1 (Release|Win32 + CRC patch).
setlocal
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build_90cn.ps1" %*
echo.
pause
+96
View File
@@ -0,0 +1,96 @@
# 90CN.dll one-click build script.
#
# 1. locate MSBuild (Build Tools / VS 2019 / VS 2022)
# 2. build Release|Win32 from client-patch/90CN.vcxproj
# 3. write the self-integrity CRC32 into the fresh DLL (patch_dll_crc.py)
# 4. print the size + SHA-256 that client_build_id.go must carry
#
# Usage (from anywhere):
# powershell -ExecutionPolicy Bypass -File build_90cn.ps1
# powershell -ExecutionPolicy Bypass -File build_90cn.ps1 -SkipCrc
#
# The output DLL lands in client-patch\Release\90CN.dll and is copied to
# ..\out\90CN.dll for convenience.
param(
[switch]$SkipCrc,
[string]$Configuration = "Release",
[string]$Platform = "Win32"
)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$projectDir = Split-Path -Parent $here
$vcxproj = Join-Path $projectDir "90CN.vcxproj"
function Find-MSBuild {
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path $vswhere) {
$path = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild `
-find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1
if ($path) { return $path }
}
$candidates = @(
"C:\VS\BuildTools\MSBuild\Current\Bin\MSBuild.exe",
"${env:ProgramFiles}\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe",
"${env:ProgramFiles}\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe",
"${env:ProgramFiles(x86)}\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe",
"${env:ProgramFiles(x86)}\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe"
)
foreach ($c in $candidates) { if (Test-Path $c) { return $c } }
return $null
}
if (-not (Test-Path $vcxproj)) { throw "vcxproj not found: $vcxproj" }
$msbuild = Find-MSBuild
if (-not $msbuild) {
throw @"
MSBuild not found.
Install the free "Build Tools for Visual Studio" and tick:
- 使用 C++ 的桌面开发 (Desktop development with C++)
- MSVC v142 - VS 2019 C++ x64/x86 生成工具
- Windows 10 SDK
Download: https://aka.ms/vs/17/release/vs_BuildTools.exe
"@
}
Write-Host "[1/4] MSBuild : $msbuild"
Write-Host "[2/4] building $Configuration|$Platform ..."
& $msbuild $vcxproj /nologo /v:minimal /m `
/p:Configuration=$Configuration /p:Platform=$Platform
if ($LASTEXITCODE -ne 0) { throw "build failed (exit $LASTEXITCODE)" }
$dll = Join-Path $projectDir "$Configuration\90CN.dll"
if (-not (Test-Path $dll)) { throw "build succeeded but $dll is missing" }
if (-not $SkipCrc) {
Write-Host "[3/4] patching self-integrity CRC ..."
$python = (Get-Command python -ErrorAction SilentlyContinue).Source
if (-not $python) { $python = (Get-Command py -ErrorAction SilentlyContinue).Source }
if (-not $python) { throw "python not found; run with -SkipCrc or install Python" }
& $python (Join-Path $here "patch_dll_crc.py") patch $dll
if ($LASTEXITCODE -ne 0) { throw "CRC patch/verify failed" }
} else {
Write-Host "[3/4] CRC patch skipped (-SkipCrc)"
}
$outDir = Join-Path (Split-Path -Parent $projectDir) "out"
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
Copy-Item $dll (Join-Path $outDir "90CN.dll") -Force
$size = (Get-Item $dll).Length
$sha = (Get-FileHash $dll -Algorithm SHA256).Hash
Write-Host "[4/4] done"
Write-Host ""
Write-Host " dll : $dll"
Write-Host " size : $size"
Write-Host " sha256: $sha"
Write-Host ""
Write-Host "Update server/internal/services/dnfbridge/client_build_id.go with:"
Write-Host " boundDllBytes = int64($size)"
Write-Host " boundDllSHA256 = `"$sha`""
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""90CN.dll self-integrity CRC32 patcher.
The client patch embeds this struct (see client-patch/90CN.cpp, VerifySelfIntegrity):
struct SelfCrcSlot {
unsigned int magic; // 0xC0DEC0DE, used to locate the slot in the file
unsigned int offset; // absolute file offset of `expected`
unsigned int expected; // CRC32 of the whole file, skipping [offset, offset+4)
};
At runtime the DLL re-reads itself, recomputes that CRC and compares. Any byte
change (e.g. patching out the check) makes the client refuse to start.
IMPORTANT — the magic is NOT unique in the image. MSVC encodes the comparison
`magic != 0xC0DEC0DEu` as an immediate inside .text, so a naive "find the first
0xC0DEC0DE" scan lands on machine code and overwrites instructions, which shows
up at runtime as an access violation right after the slot read. This tool
therefore resolves the slot through the PE section table and only accepts a
candidate that lives in a data section (.data/.rdata) and whose offset/expected
fields are either zero (fresh build) or already self-consistent.
The CRC is the standard reflected CRC-32 (poly 0xEDB88320, init 0xFFFFFFFF,
final XOR 0xFFFFFFFF) == zlib.crc32 / binascii.crc32.
Usage:
python patch_dll_crc.py info <dll> # show slot + section layout
python patch_dll_crc.py verify <dll> # check the current stored CRC
python patch_dll_crc.py patch <dll> # rewrite offset+expected in place
python patch_dll_crc.py patch <dll> -o <out.dll> # write to a copy instead
Exit codes: 0 = ok, 1 = verification failure / slot not found, 2 = usage error.
"""
from __future__ import annotations
import argparse
import binascii
import struct
import sys
import zlib
from pathlib import Path
MAGIC = 0xC0DEC0DE
MAGIC_BYTES = struct.pack("<I", MAGIC)
SLOT_SIZE = 12
OFFSET_FIELD = 4
EXPECTED_FIELD = 8
# Section names whose raw data may hold the slot. MSVC puts a zero-initialised
# (but non-const) static object in .data; if it is truly constant it can end up
# in .rdata. Anything executable is rejected outright.
SLOT_SECTIONS = (".data", ".rdata")
class SlotError(RuntimeError):
pass
def parse_sections(blob: bytes) -> list[dict]:
if len(blob) < 0x40 or blob[:2] != b"MZ":
raise SlotError("not a PE image (missing MZ header)")
pe = struct.unpack_from("<I", blob, 0x3C)[0]
if blob[pe : pe + 4] != b"PE\0\0":
raise SlotError("not a PE image (missing PE signature)")
nsec = struct.unpack_from("<H", blob, pe + 6)[0]
opt_size = struct.unpack_from("<H", blob, pe + 20)[0]
sh = pe + 24 + opt_size
sections = []
for i in range(nsec):
base = sh + i * 40
if base + 40 > len(blob):
break
name = blob[base : base + 8].rstrip(b"\0").decode("latin1")
vsize, va, rsize, roff = struct.unpack_from("<IIII", blob, base + 8)
chars = struct.unpack_from("<I", blob, base + 36)[0]
sections.append(
{
"name": name,
"vsize": vsize,
"rva": va,
"rawsize": rsize,
"rawoff": roff,
"executable": bool(chars & 0x20000000),
"writable": bool(chars & 0x80000000),
}
)
if not sections:
raise SlotError("PE has no section table")
return sections
def section_of(sections: list[dict], file_offset: int) -> dict | None:
for sec in sections:
if sec["rawoff"] <= file_offset < sec["rawoff"] + sec["rawsize"]:
return sec
return None
def find_slot(blob: bytes) -> tuple[int, dict]:
"""Return (file offset, section) of the SelfCrcSlot struct."""
sections = parse_sections(blob)
candidates: list[tuple[int, dict]] = []
pos = blob.find(MAGIC_BYTES)
while pos >= 0:
sec = section_of(sections, pos)
if sec is not None and sec["name"] in SLOT_SECTIONS:
candidates.append((pos, sec))
pos = blob.find(MAGIC_BYTES, pos + 1)
if not candidates:
# Distinguish "magic only in code" from "magic nowhere": give a useful hint.
if blob.find(MAGIC_BYTES) >= 0:
raise SlotError(
"0xC0DEC0DE only found in an executable section — refusing to "
"patch machine code (the real slot lives in .data/.rdata)"
)
raise SlotError("0xC0DEC0DE slot magic not found")
def score(item: tuple[int, dict]) -> tuple[int, int]:
p, sec = item
offset = struct.unpack_from("<I", blob, p + OFFSET_FIELD)[0]
fresh = offset == 0
patched = offset == p + EXPECTED_FIELD
# Prefer a patched slot, then a fresh one, then .data over .rdata.
return (2 if patched else 1 if fresh else 0, 1 if sec["name"] == ".data" else 0)
candidates.sort(key=score, reverse=True)
best = candidates[0]
if score(best)[0] == 0:
raise SlotError(
"slot magic found in data but offset field is neither 0 nor self-referential"
)
return best
def compute_crc(blob: bytes, expected_offset: int) -> int:
"""CRC32 of the whole file, skipping the 4 `expected` bytes.
Must match 90CN.cpp VerifySelfIntegrity() exactly:
unsigned int crc = 0xFFFFFFFF;
crc = Crc32Update(crc, image, slotOffset);
crc = Crc32Update(crc, image + slotOffset + 4, size - slotOffset - 4);
crc ^= 0xFFFFFFFF;
Crc32Update is the reflected CRC-32 (poly 0xEDB88320) with no internal
conditioning, so "init 0xFFFFFFFF ... final XOR 0xFFFFFFFF" is exactly
zlib.crc32()/binascii.crc32() with the DEFAULT seed 0.
Do NOT seed binascii.crc32 with 0xFFFFFFFF: that argument is the running
CRC *after* the final XOR, so seeding it re-applies the conditioning and
yields a different, non-matching variant (this bug shipped once and made
the client reject its own DLL).
"""
if expected_offset < 16 or expected_offset + 4 > len(blob):
raise SlotError(
f"slot offset 0x{expected_offset:X} out of range (size 0x{len(blob):X})"
)
crc = zlib.crc32(blob[:expected_offset])
crc = zlib.crc32(blob[expected_offset + 4 :], crc)
return crc & 0xFFFFFFFF
def read_slot(blob: bytes) -> tuple[int, dict, int, int]:
pos, sec = find_slot(blob)
magic, offset, expected = struct.unpack_from("<III", blob, pos)
if magic != MAGIC:
raise SlotError("slot magic mismatch after read")
return pos, sec, offset, expected
def cmd_info(path: Path) -> int:
blob = path.read_bytes()
sections = parse_sections(blob)
print(f"file : {path} ({len(blob)} bytes)")
for sec in sections:
print(
f" {sec['name']:9s} rva=0x{sec['rva']:08X} vsize={sec['vsize']:8d} "
f"raw=0x{sec['rawoff']:08X} rawsize={sec['rawsize']:8d}"
f"{' EXEC' if sec['executable'] else ''}"
f"{' WRITABLE' if sec['writable'] else ''}"
)
try:
pos, sec, offset, expected = read_slot(blob)
except SlotError as exc:
print(f"slot : NOT FOUND ({exc})")
return 1
print(
f"slot : file 0x{pos:08X} in {sec['name']} "
f"offset=0x{offset:08X} expected=0x{expected:08X}"
)
return 0
def cmd_verify(path: Path) -> int:
blob = path.read_bytes()
try:
pos, sec, offset, expected = read_slot(blob)
except SlotError as exc:
print(f"FAIL {path.name}: {exc}")
return 1
print(
f"slot : file 0x{pos:08X} in {sec['name']} "
f"offset=0x{offset:08X} expected=0x{expected:08X}"
)
if offset != pos + EXPECTED_FIELD:
print(f"FAIL {path.name}: slot offset does not point at itself")
return 1
actual = compute_crc(blob, offset)
print(f"crc32 : computed=0x{actual:08X} stored=0x{expected:08X}")
if actual != expected:
print(f"FAIL {path.name}: CRC mismatch (build not patched, or file modified)")
return 1
print(f"OK {path.name}: self-integrity CRC verified")
return 0
def cmd_patch(src: Path, dst: Path | None) -> int:
blob = bytearray(src.read_bytes())
try:
pos, sec = find_slot(blob)
except SlotError as exc:
print(f"FAIL {src.name}: {exc}")
return 1
expected_offset = pos + EXPECTED_FIELD
# offset must be stored before computing: it is part of the hashed range.
struct.pack_into("<I", blob, pos + OFFSET_FIELD, expected_offset)
crc = compute_crc(bytes(blob), expected_offset)
struct.pack_into("<I", blob, pos + EXPECTED_FIELD, crc)
out = dst or src
out.write_bytes(bytes(blob))
print(
f"slot : file 0x{pos:08X} in {sec['name']} "
f"offset=0x{expected_offset:08X} expected=0x{crc:08X}"
)
print(f"OK {out.name}: patched ({len(blob)} bytes)")
return cmd_verify(out)
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="90CN.dll self-integrity CRC patcher")
parser.add_argument("action", choices=["info", "verify", "patch"])
parser.add_argument("dll", type=Path)
parser.add_argument("-o", "--out", type=Path, help="output path for patch (default: in place)")
args = parser.parse_args(argv)
if not args.dll.is_file():
print(f"FAIL {args.dll}: not a file")
return 2
if args.action == "info":
return cmd_info(args.dll)
if args.action == "verify":
return cmd_verify(args.dll)
return cmd_patch(args.dll, args.out)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+7
View File
@@ -4,9 +4,13 @@
| 文档 | 内容 | | 文档 | 内容 |
|------|------| |------|------|
| [../AGENTS.md](../AGENTS.md) | **入口文件**:给 AI / 新人的一页纸——硬规则、构建命令、验证方法、代码地图、发版清单(开工前先读这份) |
| [开发文档.md](开发文档.md) | **GM 启动器 / 整合工具** 完整开发文档(环境搭建、架构、API 契约、打包部署、常见问题) | | [开发文档.md](开发文档.md) | **GM 启动器 / 整合工具** 完整开发文档(环境搭建、架构、API 契约、打包部署、常见问题) |
| [服务端开发文档.md](服务端开发文档.md) | **服务端(Go)** 架构、GM 接口契约、拍卖行/背包改动、编译部署 | | [服务端开发文档.md](服务端开发文档.md) | **服务端(Go)** 架构、GM 接口契约、拍卖行/背包改动、编译部署 |
| [PVF解析开发文档.md](PVF解析开发文档.md) | **PVF 解析** 套件(pvfine)、pvfexport 工具、离线名称显示集成 | | [PVF解析开发文档.md](PVF解析开发文档.md) | **PVF 解析** 套件(pvfine)、pvfexport 工具、离线名称显示集成 |
| [客户端补丁与CRC校验.md](客户端补丁与CRC校验.md) | **客户端补丁(90CN.dll)** 自校验机制、`patch_dll_crc.py` 用法、与「服务端 DLL 指纹」的区别、排查手册 |
| [启动器窗口风暴排查.md](启动器窗口风暴排查.md) | **启动器重复开窗口 / 页面空白** 的根因(打包后 `sys.executable` 指向自身)、复现手法、修复点与日志判读 |
| [踩坑记录.md](踩坑记录.md) | **踩坑合集**(13 条):每条只写现象 / 成因 / 为什么没早发现 / 教训,开头是一句话教训总表,结尾是发版检查清单 |
| [launcher-README.md](launcher-README.md) | 整合启动器 + GM 工具的原始 README | | [launcher-README.md](launcher-README.md) | 整合启动器 + GM 工具的原始 README |
其余子项目的 README 分散在各自目录: 其余子项目的 README 分散在各自目录:
@@ -19,7 +23,10 @@
## 快速上手路径 ## 快速上手路径
0. **任何人(含 AI)接手**:先读 [../AGENTS.md](../AGENTS.md),再回来按场景选文档。
1. **只想跑起来**:看根目录 [README](../README.md) 的「快速开始」。 1. **只想跑起来**:看根目录 [README](../README.md) 的「快速开始」。
2. **改 GM 工具(启动器)**:看 [开发文档.md](开发文档.md)。 2. **改 GM 工具(启动器)**:看 [开发文档.md](开发文档.md)。
3. **改服务端 GM 接口**:看 [服务端开发文档.md](服务端开发文档.md) 的「GM 接口契约」。 3. **改服务端 GM 接口**:看 [服务端开发文档.md](服务端开发文档.md) 的「GM 接口契约」。
4. **改 PVF 解析 / 物品名**:看 [PVF解析开发文档.md](PVF解析开发文档.md)。 4. **改 PVF 解析 / 物品名**:看 [PVF解析开发文档.md](PVF解析开发文档.md)。
5. **改客户端补丁 / 重编 DLL**:看 [客户端补丁与CRC校验.md](客户端补丁与CRC校验.md)——改完 DLL 要补 CRC,并且要重编服务端。
6. **遇到怪问题先翻 [踩坑记录.md](踩坑记录.md)**:13 条坑的一句话教训和判断规则,可能已经记过了。
+76
View File
@@ -0,0 +1,76 @@
# 发布物料(docs/release)
每次发版要贴到仓库 Release 里的几样东西。体积十几个 G 的整包不适合直传,所以走「说明文档 + 网盘快捷方式」的组合。
| 文件 | 用途 |
|------|------|
| `publish_release.py` | **远程发版脚本**:校验 tag → 建/更新 Release → 上传附件(见下) |
| `发布说明模板.md` | Release 正文模板:本次更新、下载表格、安装步骤、说明 |
| `网盘下载.url` | Windows 网络快捷方式,双击直接打开网盘页面;作为附件上传 |
## 一条命令发版(推荐)
Gitea 的 Release 全流程都能走 REST API,不用点网页。脚本只用 Python 标准库,不需要装任何依赖。
```bash
# 1) token 放环境变量(只对当前窗口有效,别写进 .bat 提交上去)
set GITEA_TOKEN=xxxxxxxxxxxxxxxx
# 2) 先干跑:只打印计划,不产生任何写操作
python docs/release/publish_release.py --version v2026.11.06 --dry-run
# 3) 正式发(说明正文从模板读)
python docs/release/publish_release.py --version v2026.11.06 --notes-file docs/release/发布说明模板.md
```
它按六步走,任何一步不对就停下,不会留下半个 Release:
| 步骤 | 做什么 | 失败是否中止 |
|---|---|---|
| 1 | 解析本地 tag,与远端 tag 双向比对指向的提交 | **是** |
| 2 | 读说明正文(`--notes-file` 优先,其次 `--notes`) | — |
| 3 | 该 tag 已有 Release 就更新,没有就创建 | **是** |
| 4 | 检查附件;`.url` 里若还是 `REPLACE_WITH_YOUR_LINK` 就拒绝 | **是** |
| 5 | 上传附件;同名先删再传,**可以反复执行** | **是** |
| 6 | 回读自检(附件清单、是否草稿),打印 `html_url` | — |
常用参数:
```bash
--draft # 先存草稿(网页上只有自己可见),确认后去掉 --draft 重跑即转正式
--prerelease # 标记预发布
--expect-commit 3b240ec # 强制要求 tag 指向该提交,比默认更严
--asset 某个文件 # 可重复;默认只传 docs/release/网盘下载.url
--no-asset # 只发说明,不传附件
--insecure # 自签证书的内网 Gitea 才需要
--api / --repo # 换服务器或换仓库时用
```
**为什么第 1 步要卡 tag**:2026.11.05 发版时,Release 挂着的 tag 指向一个**落后 12 个提交**的旧提交,源码包于是是旧代码,从网页上完全看不出来。脚本现在把这件事挡在发版之前,`--expect-commit` 还能多加一道。
> 实测记录(2026-11-05):临时 tag 跑完整流程,创建 `201` → 上传 `201` → 下载 `200`(拿到正确的 `.url` 内容)→ 重复执行只保留一个同名附件 → 删除干净。字段名必须是 `attachment`,用 `file` 会返回 `500 http: no such file`。
## 手工发版(不想用脚本时)
1. 复制 `发布说明模板.md` 的内容到 Release 描述,按本次改动填「本次更新」
2. 核对下载链接:模板里的下载表格已填当前百度网盘地址与提取码,换盘或重新分享时同步替换
3. 若换了链接,同步改 `网盘下载.url`,让 `URL=` 与表格里的链接**完全一致**:
```ini
[InternetShortcut]
URL=https://pan.baidu.com/s/1j31dVPF_nPKSN2QYHU-Ygg?pwd=s33r
IconIndex=0
```
> 提取码已经拼进 `?pwd=`,双击打开后直接进目录,玩家不用再手输。
4. 附件区上传 `网盘下载.url`(注意:不是把整个网盘文件传上来)
5. 检查版本号与更新日志:`launcher/server.py` 的 `CURRENT_VERSION`、发布包根目录的 `更新日志.txt`
6. **确认 tag 指向**:`git log --oneline -1 v2026.11.xx` 与 `git ls-remote --tags origin` 里的 `^{}` 是同一个 sha
## 注意
- 仓库里的 `网盘下载.url` 现在指向**真实分享**(百度网盘,提取码 `s33r`);重新分享会生成新链接,记得同步改 `.url` 与模板表格,避免玩家点开 404
- 传完自己双击验证一次,确认能打开网盘页面而不是 404
- token 只走环境变量 `GITEA_TOKEN` 或 `--token`,**不要**提交到仓库里的任何文件
- 发布包里 `Client\90CN.dll` 与 `Server\runtime\bin\DNF90Server.exe` 必须配套(DLL 指纹绑定),缺一个客户端就连不上
+302
View File
@@ -0,0 +1,302 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""DNF90 远程发版:创建 / 更新 Gitea Release 并上传附件(纯标准库,无需 pip)
它做的事,等价于你在网页上点的那几下,但可以重复执行、不会手抖:
1. 校验本地 tag 存在,并解析它指向的提交(可 --expect-commit 强制核对)
2. 该 tag 已有 Release → 更新说明;没有 → 创建(--draft 可先存草稿)
3. 上传附件(默认 docs/release/网盘下载.url),同名附件自动替换
4. 打印 Release 地址,附一次自检结果
用法
----
# token 放环境变量,别写进命令历史
set GITEA_TOKEN=xxxxxxxx
# 先干跑,看它准备做什么(不产生任何写操作)
python docs/release/publish_release.py --version v2026.11.06 --dry-run
# 正式发:说明从文件读,附件用默认的网盘 .url
python docs/release/publish_release.py --version v2026.11.06 --notes-file docs/release/发布说明模板.md
# 存成草稿先看看,确认后再去掉 --draft 重跑(会自动转为正式)
python docs/release/publish_release.py --version v2026.11.06 --notes-file x.md --draft
为什么要单独校验 tag 指向
----------------------
2026.11.05 发版时踩过:Release 挂在 tag 上,而 tag 指着一个落后 12 个提交的旧提交,
源码包于是是旧代码,而且从外表看不出来。所以本脚本默认会拿本地 `git rev-list -n1 <tag>`
和远端 tag 解析结果双向比对,对不上直接拒绝发版。
安全
----
- token 只从 `--token` 或环境变量 `GITEA_TOKEN` 读,不落盘、不进仓库
- `--insecure` 才跳过 TLS 校验(自签证书的内网 Gitea 用),默认严格校验
"""
from __future__ import annotations
import argparse
import json
import mimetypes
import os
import ssl
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_API = "https://git.hanfuit.com/api/v1"
DEFAULT_REPO = "Lingbao/90CNServer"
DEFAULT_ASSET = os.path.join("docs", "release", "网盘下载.url")
PLACEHOLDER = "REPLACE_WITH_YOUR_LINK"
# --------------------------------------------------------------------- 输出
def say(msg=""):
try:
print(msg)
except UnicodeEncodeError: # 老控制台 cp936
print(msg.encode("gbk", "replace").decode("gbk", "replace"))
def ok(msg):
say(" [OK] " + msg)
def warn(msg):
say(" [警告] " + msg)
def die(msg, code=1):
say(" [失败] " + msg)
sys.exit(code)
# ------------------------------------------------------------------ HTTP 层
class Api:
def __init__(self, base, repo, token, insecure=False):
self.base = base.rstrip("/")
self.repo = repo
self.token = token
self.ctx = ssl._create_unverified_context() if insecure else ssl.create_default_context()
def _url(self, path):
return "%s/repos/%s%s" % (self.base, self.repo, path)
def call(self, method, path, payload=None, raw=None, ctype=None):
"""返回 (status, 解析后的 json 或 None)。4xx/5xx 不抛异常,交由调用方判断。"""
data = None
if raw is not None:
data = raw
elif payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
ctype = "application/json"
req = urllib.request.Request(self._url(path), data=data, method=method)
req.add_header("Authorization", "token " + self.token)
req.add_header("Accept", "application/json")
if ctype:
req.add_header("Content-Type", ctype)
try:
with urllib.request.urlopen(req, timeout=60, context=self.ctx) as resp:
body = resp.read()
return resp.status, (json.loads(body.decode("utf-8")) if body.strip() else None)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", "replace")
try:
return exc.code, json.loads(body)
except Exception:
return exc.code, {"message": body[:300]}
except urllib.error.URLError as exc:
die("连不上 %s:%s" % (self.base, exc.reason))
def upload(self, release_id, filepath, name=None):
"""multipart 上传。注意字段名必须是 attachment —— 用 file 会 500 'no such file'。"""
name = name or os.path.basename(filepath)
with open(filepath, "rb") as fh:
blob = fh.read()
boundary = "----DNF90Release%s" % os.urandom(8).hex()
ctype = mimetypes.guess_type(name)[0] or "application/octet-stream"
parts = []
parts.append(("--%s\r\n" % boundary).encode())
parts.append(('Content-Disposition: form-data; name="attachment"; filename="%s"\r\n' % name).encode("utf-8"))
parts.append(("Content-Type: %s\r\n\r\n" % ctype).encode())
parts.append(blob)
parts.append(("\r\n--%s--\r\n" % boundary).encode())
body = b"".join(parts)
q = urllib.parse.urlencode({"name": name})
return self.call("POST", "/releases/%s/assets?%s" % (release_id, q), raw=body,
ctype="multipart/form-data; boundary=%s" % boundary)
# -------------------------------------------------------------------- git
def git(*args):
try:
out = subprocess.run(["git"] + list(args), capture_output=True, text=True,
encoding="utf-8", errors="replace")
except FileNotFoundError:
return None
if out.returncode != 0:
return None
return out.stdout.strip()
def local_tag_commit(tag):
return git("rev-list", "-n", "1", tag)
def current_branch_commit():
return git("rev-parse", "HEAD")
# -------------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser(description="远程创建/更新 Gitea Release 并上传附件")
ap.add_argument("--version", required=True, help="tag 名,例如 v2026.11.06")
ap.add_argument("--name", help="Release 标题,默认「<version> 更新」")
ap.add_argument("--notes", help="Release 说明正文")
ap.add_argument("--notes-file", help="从文件读说明正文(优先于 --notes)")
ap.add_argument("--asset", action="append", default=None,
help="附件路径,可重复;默认 %s" % DEFAULT_ASSET)
ap.add_argument("--expect-commit", help="要求 tag 必须指向该提交(默认取本地 tag 解析值)")
ap.add_argument("--draft", action="store_true", help="创建为草稿(不公开)")
ap.add_argument("--prerelease", action="store_true", help="标记为预发布")
ap.add_argument("--no-asset", action="store_true", help="只发说明,不传附件")
ap.add_argument("--force", action="store_true", help="允许 .url 里仍是占位地址")
ap.add_argument("--dry-run", action="store_true", help="只打印计划,不做写操作")
ap.add_argument("--token", help="Gitea token(默认读环境变量 GITEA_TOKEN)")
ap.add_argument("--api", default=os.environ.get("GITEA_API", DEFAULT_API))
ap.add_argument("--repo", default=os.environ.get("GITEA_REPO", DEFAULT_REPO))
ap.add_argument("--insecure", action="store_true", help="跳过 TLS 校验(自签证书)")
args = ap.parse_args()
token = args.token or os.environ.get("GITEA_TOKEN")
if not token:
die("缺少 token:设置环境变量 GITEA_TOKEN,或传 --token")
if os.environ.get("GITEA_TOKEN") and not args.token:
warn("正在使用环境变量里的 token(ps 历史里看不到,但别写进 bat 脚本)")
api = Api(args.api, args.repo, token, args.insecure)
tag = args.version
title = args.name or ("%s 更新" % tag)
say("== 1. 校验 tag ==")
local_sha = local_tag_commit(tag)
if not local_sha:
die("本地找不到了 tag %s:先 `git tag -a %s <commit>` 再推送" % (tag, tag))
ok("本地 %s → %s" % (tag, local_sha[:10]))
if args.expect_commit and not local_sha.startswith(args.expect_commit):
die("tag 指向 %s,与要求的 %s 不符 —— 拒绝发版" % (local_sha[:10], args.expect_commit[:10]))
head = current_branch_commit()
if head and head != local_sha:
behind = git("rev-list", "--count", "%s..%s" % (local_sha, head))
warn("HEAD 比 tag 多 %s 个提交(这是正常的:tag 只对应已发布的那个版本)" % (behind or "?"))
status, remote_tag = api.call("GET", "/tags/%s" % urllib.parse.quote(tag))
if status == 200:
rsha = ((remote_tag or {}).get("commit") or {}).get("sha")
if rsha and not rsha.startswith(local_sha[:10]):
die("远端 tag 指向 %s,本地是 %s —— 先把 tag 推上去再说" % (rsha[:10], local_sha[:10]))
ok("远端 tag 一致(%s)" % (rsha or "?")[:10])
elif status == 404:
die("远端没有 tag %s:先 `git push origin refs/tags/%s`" % (tag, tag))
else:
die("查询远端 tag 失败:HTTP %s %s" % (status, (remote_tag or {}).get("message")))
say("== 2. 说明正文 ==")
body = ""
if args.notes_file:
with open(args.notes_file, "r", encoding="utf-8") as fh:
body = fh.read()
ok("读取 %s(%d 字符)" % (args.notes_file, len(body)))
elif args.notes:
body = args.notes
ok("使用命令行说明(%d 字符)" % len(body))
else:
warn("没有说明正文,Release 描述会是空的")
say("== 3. 检查已有 Release ==")
status, exist = api.call("GET", "/releases/tags/%s" % urllib.parse.quote(tag))
if status == 200 and isinstance(exist, dict):
rid = exist.get("id")
suffix = ",并转正式" if (exist.get("draft") and not args.draft) else ""
ok("已存在 Release id=%s;本次将更新说明%s" % (rid, suffix))
action = "update"
have_assets = [a.get("name") for a in (exist.get("assets") or [])]
elif status == 404:
ok("还没有 Release,本次将创建")
action = "create"
rid, have_assets = None, []
else:
die("查询 Release 失败:HTTP %s %s" % (status, (exist or {}).get("message")))
say("== 4. 附件 ==")
assets = [] if args.no_asset else (args.asset or [DEFAULT_ASSET])
for path in assets:
if not os.path.exists(path):
die("附件不存在:%s" % path)
size = os.path.getsize(path)
if path.lower().endswith(".url"):
with open(path, "rb") as fh:
text = fh.read().decode("utf-8", "replace")
if PLACEHOLDER in text and not args.force:
die("%s 里还是占位地址 %s,先填真实网盘链接(或用 --force 硬发)" % (path, PLACEHOLDER))
state = "(将替换同名)" if os.path.basename(path) in have_assets else ""
ok("%s %.1f KB%s" % (path, size / 1024.0, state))
if args.dry_run:
say("")
say("== 干跑结束,未做任何写操作 ==")
say("将执行:%s Release「%s」(tag %s) %s" % (
"创建" if action == "create" else "更新", title, tag,
",附件 " + str(len(assets)) + " 个" if assets else ",无附件"))
return
say("== 5. 写入 ==")
if action == "create":
payload = {"tag_name": tag, "target_commitish": local_sha, "name": title,
"body": body, "draft": bool(args.draft), "prerelease": bool(args.prerelease)}
status, rel = api.call("POST", "/releases", payload=payload)
if status not in (201, 200):
die("创建失败:HTTP %s %s" % (status, (rel or {}).get("message")))
rid = rel.get("id")
ok("已创建 Release id=%s" % rid)
else:
payload = {"name": title, "body": body, "draft": bool(args.draft),
"prerelease": bool(args.prerelease)}
status, rel = api.call("PATCH", "/releases/%s" % rid, payload=payload)
if status not in (200, 201):
die("更新失败:HTTP %s %s" % (status, (rel or {}).get("message")))
ok("已更新 Release id=%s" % rid)
for path in assets:
name = os.path.basename(path)
# 同名先删,保证可重复执行
for a in (rel.get("assets") or []):
if a.get("name") == name:
api.call("DELETE", "/releases/%s/assets/%s" % (rid, a.get("id")))
ok("删除同名旧附件 %s" % name)
status, res = api.upload(rid, path, name)
if status not in (201, 200):
die("上传 %s 失败:HTTP %s %s" % (name, status, (res or {}).get("message")))
ok("已上传 %s(%.1f KB)" % (name, (res or {}).get("size", 0) / 1024.0))
say("== 6. 自检 ==")
status, final = api.call("GET", "/releases/%s" % rid)
if status != 200:
warn("回读失败 HTTP %s" % status)
else:
got = [a.get("name") for a in (final.get("assets") or [])]
ok("附件:%s" % (", ".join(got) if got else "无"))
ok("状态:%s" % ("草稿" if final.get("draft") else "已发布"))
say("")
say("Release 地址:%s" % final.get("html_url"))
if final.get("draft"):
say("提示:当前是草稿,网页上只有你自己可见;去掉 --draft 再跑一次即转正式")
if __name__ == "__main__":
main()
+29
View File
@@ -0,0 +1,29 @@
# DNF90 灵宝小竹单机版 v2026.11.05
## 本次更新
- 修复 …
- 新增 …
## 下载
| 渠道 | 提取码 | 链接 |
| --- | --- | --- |
| 百度网盘 | `s33r` | [点击下载](https://pan.baidu.com/s/1j31dVPF_nPKSN2QYHU-Ygg?pwd=s33r) |
| 阿里云盘 | 无需提取码 | [点击下载](https://www.alipan.com/s/xxxx) |
> 百度链接已带 `?pwd=s33r`,点开即可进目录,无需再手输提取码。阿里云盘一行按需替换。
Windows 用户也可以直接点附件里的 `网盘下载.url`,会自动打开网盘页面。
## 安装
1. 解压到非中文路径(例如 `D:\DNF90`),路径不要带空格和特殊字符
2. 双击 `DNF90启动器.exe`,点「开始」按钮开服
3. 首次启动约需 85 秒,等日志出现「服务已就绪」再进游戏
## 说明
- 本模拟器资源禁止倒卖,请到官方微信公众号或社群下载
- 官方 QQ 群:1128511524
- 遇到问题请附上 `Server\runtime\logs\` 下的日志
+3
View File
@@ -0,0 +1,3 @@
[InternetShortcut]
URL=https://pan.baidu.com/s/1j31dVPF_nPKSN2QYHU-Ygg?pwd=s33r
IconIndex=0
+157
View File
@@ -0,0 +1,157 @@
# 启动器「窗口越开越多、里面一片空白」排查记录
> 版本:v2026.11.05 之后修复(启动器 15,945,162 字节)
> 现象只在部分电脑出现,开发机复现不了;本文记录根因、复现手法、修复点与现场取证方法。
>
> 想看「为什么会写出这种代码、为什么长期没被发现」的成因复盘,见 [踩坑记录.md](踩坑记录.md) 的 **P-001**。
## 一、现象
- 双击启动器后,屏幕上每隔一两秒冒出一个新的启动器窗口,都是黑底、没有界面内容
- 内存、CPU 一路涨,任务管理器里 `DNF90启动器.exe` 一片
- 游戏内嵌的 GM 页(`127.0.0.1:18112`)打不开 / 空白
- 开机第一次启动更容易出现;已经有一种「别的东西占着 18112」的电脑反而看不到
## 二、根因:启动器把自己当成了 python.exe
`launcher/server.py` 的 `start_gmweb()` 要拉起 GM 页服务,写法是:
```python
subprocess.Popen([sys.executable, script, "--port", str(GM_WEB_PORT)])
# ^^^^^^^^^^^^^^ 源码运行时是 python.exe,打包后是启动器自己
```
源码运行时 `sys.executable` 是 `python.exe`,`python.exe serve.py --port 18112` 正常。
用 PyInstaller 打成 **单文件** 后,`sys.executable` 变成 `DNF90启动器.exe`,于是实际执行的是:
```
DNF90启动器.exe Z:\90server\gmweb\serve.py --port 18112
```
单文件 bootloader 不认这种「拿参数当脚本」的用法,它只会**再跑一遍内嵌的 server.py**,而 server.py 又没有 `sys.argv` 分支处理,于是:
1. 新进程重新解包 16MB 的单文件包 → 绑定下一个空闲端口(19528)
2. 它也会调用 `start_gmweb()`,发现 18112 还是空的(因为 serve.py 从来没被真正执行过)
3. 于是**又拉起一个自己**
4. 每一代在拉完下一代的瞬间创建自己的窗口
链条是这样的(每代约 1.5 秒,端口从 19527 顺延到 19546):
```
exe(19527) ──spawn──> exe(19528) ──spawn──> exe(19529) ──> … 直到端口用尽
│ │ │
开窗口 开窗口 开窗口 ← 全是没有内容的新窗口
```
端口到 19546 用完后 `ThreadingHTTPServer` 绑定失败,`console=False` + 窗口程序会弹一个 traceback 对话框,所以有些机器上还会看到报错弹窗。
## 三、为什么开发机复现不了
`start_gmweb()` 的第一句是:
```python
if port_open(GM_WEB_PORT):
print("GM 页服务已在运行")
return
```
开发机上很早就有一个 `pythonw.exe serve.py`(或 `gmweb\start_gmweb.bat` 起的)占着 18112,所以这段直接 return,永远走不到那句错误的 spawn。实测把那个进程结束后,**6 秒内就堆出 3 个实例、端口涨到 19531,而 18112 始终没有监听**。
结论:只要一台机器满足「`gmweb\serve.py` 存在」+「18112 空闲」,就一定会套娃;反之会被掩盖。这就是「个别电脑」的来源。
## 四、复现与验证手法
```bash
# 1. 先确认 18112 是空的(否则会被 return 掩盖)
netstat -ano | findstr 127.0.0.1:18112
# 2. 直接跑打包后的 exe,观察端口是否一路顺延
DNF90启动器.exe &
sleep 6; netstat -ano | findstr 127.0.0.1:195
# 3. 反向验证「exe 会不会执行传进去的 py」
printf 'open(r"C:\\temp\\hit.txt","w").write("ok")' > probe.py
DNF90启动器.exe probe.py # 旧版:hit.txt 不生成,却开出一个窗口
```
修复后第 3 步会真的执行 `probe.py`(生成 `hit.txt`)并且**不开窗口**。
## 五、修复内容
| 改动 | 说明 |
|------|------|
| **多调用模式** `run_script_instead()` | `exe <xxx.py> [args]` 时用 `runpy.run_path` 直接执行该脚本,不开窗口、不提权、不占互斥量。打包后 `sys.executable` 拉起 `serve.py` 从此是合法的 |
| **单实例互斥** `acquire_single_instance()` | 命名互斥量 `Local\DNF90Launcher-5E9C1B7A`;重复启动时把已有窗口 `ShowWindow(SW_RESTORE)` + `SetForegroundWindow` 后退出,不再开第二个窗口 |
| **就绪校验 + 进程内兜底** | spawn 后最多等 6 秒确认 18112 真的在监听;没监听就改用 `importlib` 在本进程线程里跑 `serve.py` 的 `main()`,**任何情况下都不会再拉起一个新窗口** |
| **提权带参** `self_elevate()` | 原来 `ShellExecuteW(..., None, ...)` 提权时把参数丢了,重启出来的进程是「干干净净」的启动器;现在用 `list2cmdline(sys.argv[1:])` 原样带上,并且 gmweb 子进程(`DNF90_GMWEB_CHILD=1`)不再提权 |
| **WebView2 数据目录** | 显式指定 `%LOCALAPPDATA%\DNF90Launcher\WebView2` 并 `private_mode=False`。默认位置放在只读目录 / 网络盘 / 映射盘时建不起来,窗口能出但一片空白,且不报错 |
| **运行日志** `启动器日志.txt` | exe 同目录,超过 512KB 自动转 `.old.txt`;`console=False` 下 print 和未捕获异常全部落盘,现场才有据可查 |
### 关于「没内容」的两层含义
1. 套娃出来的窗口是**每代新进程**,它们连的是各自顺延的端口,页面还在加载就被下一代挤爆,观感就是黑框;
2. 真正属于「内容出不来」的是 WebView2 数据目录的问题(只读目录/网络盘),已按上表固定到 `LOCALAPPDATA`。
## 六、现场取证
让用户把 **exe 同目录的 `启动器日志.txt`** 发回来即可,正常启动长这样:
```
[2026-11-05 17:20:38] ===== 启动 pid=24412
[2026-11-05 17:20:38] exe=Z:\90server\DNF90启动器.exe
[2026-11-05 17:20:38] argv=['Z:\\90server\\DNF90启动器.exe']
[2026-11-05 17:20:38] SERVER_ROOT=Z:\90server
[2026-11-05 17:20:38] 启动器端口:19527
[2026-11-05 17:20:38] gmweb:已拉起子进程 pid=20260
[2026-11-05 17:20:39] ===== 启动 pid=20260
[2026-11-05 17:20:39] argv=['Z:\\90server\\DNF90启动器.exe', 'Z:\\90server\\gmweb\\serve.py', '--port', '18112']
[2026-11-05 17:20:39] 多调用模式:执行 Z:\90server\gmweb\serve.py ['--port', '18112']
[2026-11-05 17:20:39] gmweb:http://127.0.0.1:18112/ 就绪
[2026-11-05 17:20:41] WebView2 数据目录:C:\Users\...\AppData\Local\DNF90Launcher\WebView2
[2026-11-05 17:20:41] WebView2 运行时:135.0.3179.73
```
判读要点:
| 日志特征 | 结论 |
|----------|------|
| 出现多条 `===== 启动` 且 pid 各不相同,但**没有**「多调用模式」 | 又在套娃(旧版 exe,或 `serve.py` 参数被吞) |
| `已有启动器在运行,唤起原窗口后退出` | 单实例生效,属正常 |
| `gmweb:子进程未监听,改为进程内启动` | 子进程起不来,已自动兜底;看上一行 `子进程启动失败` 的原因 |
| `警告:注册表里没查到 WebView2 运行时` | 机器缺 Edge WebView2 Runtime,装一下即可(窗口会是空白) |
| 完全没有日志文件 | exe 所在目录不可写(如 Program Files),换目录或给写权限 |
### 18112 上跑的可能是「启动器」本身,别被进程名骗了
多调用模式的代价:gmweb 子进程的**进程名就是 `DNF90启动器.exe`**,真实身份只在命令行里。
```
DNF90启动器.exe Z:\90server\gmweb\serve.py --port 18112
```
实际踩过:看到 18112 被占、进程名是启动器,先判断成「启动器在跑」,又找不到窗口可点
(父进程早退了、只剩这个子进程),于是改口成「残留的 `pythonw serve.py`」——两次都错了。
**名字不可靠,看命令行**:
```powershell
Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -eq <pid> } |
Select-Object Name,ProcessId,ParentProcessId,CommandLine | Format-List
```
| 命令行特征 | 身份 |
|---|---|
| 带 `...\gmweb\serve.py --port 18112` | gmweb 服务进程(正常形态,父进程通常就是启动器) |
| 只有 exe 路径,且自己占着 `19527` | 启动器主进程 |
| 上面那条的 `ParentProcessId` 已不存在 | 父启动器退了,留下的是**遗留 gmweb 服务**(能继续用,但跑的是旧代码) |
> **关键推论:它跑的是「启动那一刻」的 `serve.py`。**
> 静态文件(`index.html` / `loadout.js` / `.css`)每次请求都读盘,替换后立即生效;
> 但 `serve.py` 的路由是进程启动时注册的,**替换文件不会生效**。
> 症状是「页面是新版(按钮都在),接口却是旧版(点了没反应 / 404)」。
> 所以同步过 `gmweb/` 之后,必须让占着 18112 的进程重启一次(关启动器 → 确认端口空了 → 重新打开)。
## 七、相关坑(顺带记录)
- **exe 名字带中文**没问题,日志和 WebView2 目录都能正常处理;但 exe 放在**只读目录**会让日志与 WebView2 同时失效,表现就是「能开窗口但空白、也没有日志」。
- `_kill_webview2_children()` 用的是 `wmic`,Windows 11 24H2 起 `wmic` 默认已被移除、部分安全策略也会拦它;这段现在是 `try/except` 静默失败,不影响主流程,但别再依赖它清理残留进程。
- 启动器单文件每次启动都要解包约 16MB;因此「重复启动」的代价不只是多一个窗口,还会多解包一次。单实例互斥顺带把这件事省了。
+133
View File
@@ -0,0 +1,133 @@
# 客户端补丁自校验(CRC32)与 patch_dll_crc.py
`client-patch/tools/patch_dll_crc.py` 是 `90CN.dll` 的**自校验补齐工具**。DLL 编译出来自带一个 CRC 槽位(初值全 0),必须由这个脚本在构建后把「整个文件的 CRC32 + 槽位偏移」写进去;不写,客户端启动时自校验直接失败。
> 一句话:**改完 DLL 必须跑 patch,跑完 CRC 变了必须重编服务端**(服务端绑的是 DLL 的 SHA-256,见第六节)。
---
## 一、它解决什么问题
90CN 补丁是外挂式 DLL,谁都能拿十六进制编辑器改两个字节。加了自校验之后:
- DLL 每次启动先读**磁盘上自己**的字节,重算 CRC32,与构建时写入的值比对
- 不一致 → 弹「客户端补丁文件已被修改,无法启动」→ `TerminateProcess`
- 因此「patch 掉校验分支」这条路走不通:改分支就等于改 CRC,CRC 对不上直接启动失败
代价是每次重编 DLL 都要补一次 CRC,这就是本工具的职责。
## 二、两端机制
### DLL 侧(`client-patch/90CN.cpp` → `SelfCrcSlot` / `VerifySelfIntegrity`)
```c
static struct SelfCrcSlot {
volatile unsigned int magic; // 固定 0xC0DEC0DE,脚本靠它定位槽位
volatile unsigned int offset; // expected 字段在文件中的绝对偏移(脚本写入)
volatile unsigned int expected; // 整个文件的 CRC32(脚本写入)
} g_selfCrcSlot = { 0xC0DEC0DEu, 0u, 0u };
```
三个要点,都踩过坑:
1. **必须 `volatile`**:这三个字段只由脚本改文件字节,C++ 代码里从不写。不加 `volatile`,`/O2` 会把 `offset`/`expected` 当常量折叠成 0,脚本写对了运行时也读不到。
2. **用数据地址解析自身模块**:`GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, &g_selfCrcSlot, ...)`。用函数地址在部分加载方式下解析不到(实测 `err=126`)。
3. **校验发生在窗口出来之前**:`VerifySelfIntegrity()` 在 `InstallTclsCompatibility()` 之后、影片哈希之前调用,失败即弹框并退出。
CRC 计算严格按下面这套,和脚本必须逐字节一致:
```c
unsigned int crc = 0xFFFFFFFFu;
crc = Crc32Update(crc, image, slotOffset); // 槽位之前的全部字节
crc = Crc32Update(crc, image + slotOffset + 4, size - slotOffset - 4); // 跳过 expected 那 4 字节
crc ^= 0xFFFFFFFFu;
```
即标准的反射 CRC-32(poly `0xEDB88320`、init `0xFFFFFFFF`、final XOR `0xFFFFFFFF`),等价于 `zlib.crc32()` 的**默认**用法。
### 脚本侧(`patch_dll_crc.py`)
| 动作 | 做什么 |
|------|--------|
| 定位槽位 | 解析 PE 节表,只在 `.data` / `.rdata` 里找 `0xC0DEC0DE`,可执行节里的命中一律拒绝 |
| 写 `offset` | `offset = 槽位文件偏移 + 8`(指向 `expected` 自己) |
| 写 `expected` | 先写 `offset` 再算 CRC —— 因为 `offset` 本身也在被哈希的范围内 |
| 复核 | 写完立刻跑一次 `verify`,退出码即结果 |
**为什么不直接扫第一个 `0xC0DEC0DE`**:MSVC 会把 `magic != 0xC0DEC0DEu` 这个比较编译成 `.text` 里的立即数,于是镜像里有多处命中。早期脚本扫到机器码就往上写,运行时表现为「读槽位后立刻访问冲突」。现在按节属性过滤,并且只接受 `offset` 为 0(新编译出来)或已自指(已 patch 过)的候选。
**CRC 种子坑(曾上线一版错误实现)**:不要给 `binascii.crc32(data, 0xFFFFFFFF)` 传种子。那个参数是**已经过 final XOR** 的运行中 CRC,再传进去等于把 conditioning 应用两次,算出来是另一个变体,客户端会拒绝自己的 DLL。正确写法是 `zlib.crc32(part1)` 再用返回值续算 `part2`。
## 三、命令行用法
```bash
# 看节表布局与槽位现状(不改文件)
python patch_dll_crc.py info Client/90CN.dll
# 只校验:当前文件与槽位里存的 CRC 是否一致
python patch_dll_crc.py verify Client/90CN.dll
# 补齐/重写 CRC(原地写)
python patch_dll_crc.py patch Client/90CN.dll
# 写到副本,原文件不动(发布前比对用)
python patch_dll_crc.py patch Client/90CN.dll -o build/90CN_patched.dll
```
输出示例(当前发布版 `90CN.dll` 的真实结果):
```
slot : file 0x0009B2AC in .data offset=0x0009B2B4 expected=0x9AFDA31F
crc32 : computed=0x9AFDA31F stored=0x9AFDA31F
OK 90CN.dll: self-integrity CRC verified
```
**退出码**:`0` 成功 / `1` 校验失败或找不到槽位 / `2` 用法错误(参数或文件不存在)。
## 四、构建集成
`client-patch/tools/build_90cn.ps1`(双击走 `build_90cn.bat`)三步走:
1. MSBuild 编 `Release|Win32` 出 `90CN.dll`
2. 收集产物
3. 调 `patch_dll_crc.py patch <dll>` 写入 CRC(找不到 Python 且没加 `-SkipCrc` 就直接抛错,不会静默产出未 patch 的 DLL)
`-SkipCrc` 只用于「先编出来看看能不能过编译」,**不要**拿这种 DLL 发布。
## 五、手动改过 DLL 之后
任何对 DLL 的字节级改动(换图标、改资源、再打一次补丁)都会让 CRC 失效,按顺序来:
```bash
python patch_dll_crc.py verify Client/90CN.dll # 1. 先确认现在是否已经是坏的
python patch_dll_crc.py patch Client/90CN.dll # 2. 重写 CRC
python patch_dll_crc.py verify Client/90CN.dll # 3. 复核
```
然后**必须重编服务端**,见下一节。
## 六、和「服务端 DLL 指纹」是两回事
| 机制 | 在哪 | 校验什么 | 失败表现 |
|------|------|----------|----------|
| 自校验 CRC32 | `90CN.dll` 内部 | 自己文件字节没被改 | 启动即弹框「客户端补丁文件已被修改」并退出 |
| DLL 指纹 SHA-256 | `DNF90Server.exe`(编译期 `boundDllSHA256`) | 客户端上报的 DLL 哈希是否等于服务端编进去的那一个 | 能进登录,但连接被拒 / 掉线 |
所以**换 DLL 必须重编服务端**,否则 CRC 自己是对了,指纹对不上照样连不上。发布包里 `Client\90CN.dll` 与 `Server\runtime\bin\DNF90Server.exe` 必须成对。
## 七、排查手册
| 现象 | 原因 | 处理 |
|------|------|------|
| 启动弹「客户端补丁文件已被修改」 | CRC 不符,或槽位根本没 patch(`magic`/`offset` 检查不过) | 跑 `patch`;看日志 `self crc check: computed=... expected=...` 两值 |
| 读槽位后立刻访问冲突(AV) | 早期脚本把 `0xC0DEC0DE` 当普通字符串扫,写进了 `.text` 机器码 | 用当前脚本重编重 patch;`info` 确认槽位在 `.data` |
| `verify` 报 `offset does not point at itself` | 文件被别的工具改写,或用了错误版本的脚本 | `patch` 重写后复核 |
| `0xC0DEC0DE only found in an executable section` | 说明槽位没编进数据段(被优化掉/常量折叠) | 检查 `volatile` 是否还在,重编 |
| 每台机器都「校验失败」但本地 `verify` 通过 | 分发过程中文件被改(签壳、压缩包截断、杀软修复) | 对比分发前后 SHA-256,禁用会改二进制的「修复」 |
| 校验通过但登录掉线 | DLL 指纹与 `DNF90Server.exe` 不配套 | 用同一份 DLL 重编服务端 |
日志前缀统一是 `self crc `(`step=` 为流程、`check:` 为结果、`diag:` 为失败原因),排查时直接 grep 这个前缀。
## 八、为什么不用数字签名
自签名证书要装根、要处理时间戳与过期;WinVerifyTrust 还要依赖系统服务。CRC32 不防「有动机的攻击者」——他们可以直接改期望值——它防的是**误改与低级篡改**,对单机模拟器这个场景够用,且零依赖、构建期一次写入。
+316
View File
@@ -0,0 +1,316 @@
# 踩坑记录
> 记录本项目里**踩过、且容易再踩**的坑。每条只回答四个问题:现象是什么、为什么会踩、
> 为什么没早发现、以后怎么绕开。细节排查看对应文档,这里留的是**可复用的判断**。
>
> 新增一条的格式:`现象 → 一句话结论 → 成因 → 为什么没早发现 → 教训`。
| 编号 | 坑 | 一句话教训 |
|------|----|-----------|
| [P-001](#p-001-启动器越开越多窗口里面一片空白) | 启动器越开越多窗口,里面一片空白 | 同一行代码在「源码运行」和「单文件打包」下语义不同,`sys.executable` 是典型 |
| [P-002](#p-002-改了-dll-客户端连不上) | 改了 DLL,客户端连不上 | DLL 与服务端 exe 是一对,指纹不配套就是废的 |
| [P-003](#p-003-crc32-算出另一个变体) | 自校验 CRC 算出来永远对不上 | 别手动补算法里的 final XOR,库函数返回值就是可续算值 |
| [P-004](#p-004-写-crc-槽位把-dll-写坏) | 写 CRC 槽位把 DLL 写坏 | 扫特征值时先按节表限定范围,代码段里的同名立即数是陷阱 |
| [P-005](#p-005-竖线怎么都不显示) | 分隔竖线怎么都不显示 | CSS 特异性:写在低权重选择器上的声明会被高权重规则清掉 |
| [P-006](#p-006-样式在游戏里全乱) | 网页好看,游戏里全乱 | 内嵌 IE11,没有 flex、没有现代选择器 |
| [P-007](#p-007-改了代码却没生效) | 改了代码却没生效 | 单文件包 + 固定端口,旧进程不退就是连到旧服务 |
| [P-008](#p-008-页面永远是旧的) | 页面永远是旧的 | WebView2 会缓存,静态响应要 `no-store` + `?v=` |
| [P-009](#p-009-用户报错但什么都看不到) | 用户报错,但什么都看不到 | `console=False` 等于关掉全部输出,必须自己落盘日志 |
| [P-010](#p-010-缺图占位不生效) | 缺图时占位不生效、只显示破图 | 图片可能在脚本执行前就加载失败了,占位要兜住这个时序 |
| [P-011](#p-011-仓库和实际跑的不是一份) | 仓库改了,实际跑的还是旧的 | 部署目录只是副本,改完必须回灌仓库并 `diff -rq` |
| [P-012](#p-012-批量改文件改成功了其实没改) | 批量改文件「改成功了」其实没改 | 脚本里先 `assert` 再 `replace`,否则静默失败 |
| [P-013](#p-013-补丁包缺文件) | 补丁包发出去用不了 | 发版按清单勾,别凭记忆 |
---
## P-001 启动器越开越多窗口,里面一片空白
> 细节见 [启动器窗口风暴排查.md](启动器窗口风暴排查.md),这里记的是**为什么会踩**。
**现象**:双击 `DNF90启动器.exe`,每隔一两秒冒出一个新窗口,都是黑底、没有界面内容;
任务管理器里一片同名进程;游戏内 GM 页(`127.0.0.1:18112`)打不开。
**只在部分电脑出现,开发机复现不了。**
**一句话结论**:`subprocess.Popen([sys.executable, "serve.py", ...])` 在源码运行时是
「用解释器跑脚本」,打包成单文件后变成「启动器把自己再启动一次,把脚本路径当参数传进去」——
而 PyInstaller 的 bootloader 根本不理这些参数。
### 成因:三层叠加
1. **语义变了,代码没变。** `sys.executable` 在源码下是 `python.exe`,`--onefile` 打包后是
启动器自身。同一行代码,两种运行环境,两条完全不同的执行路径。
2. **`Popen` 只保证「进程创建成功」,不保证「脚本被执行」。** 它返回的 `Popen` 对象只能说明
fork/exec 成功;参数被 bootloader 吞掉这种失败没有任何回传,上一代永远不会知道下一代没干活。
3. **端口顺延把异常伪装成正常。** `while port_open(port): port += 1` 让每一代都能顺利绑定
19528、19529…… 每一代都「启动成功」了,于是每一代都能继续分出下一代。真正让它停下的是
`port < DEFAULT_PORT + 20` 这个**本来就有的**上限,不是为这种情况设计的保护。
链条:
```
exe(19527) ──spawn──> exe(19528) ──spawn──> exe(19529) ──> … 到端口用尽
│ │ │
开窗口 开窗口 开窗口 ← 全是空窗口
```
每一代的执行顺序都是**先 spawn 下一代,再创建自己的窗口**,所以窗口是「一个接一个」冒出来的,
而不是同时出现——这个细节让现象看起来更像「用户自己在反复双击」。
### 为什么长期没人发现
四个掩盖因素同时成立,每一个都足以让问题隐形:
| 掩盖因素 | 效果 |
|---|---|
| `if port_open(18112): return` | 开发机常年有 `pythonw serve.py` 占着 18112,永远走不到出错那句 |
| 开发机常跑着 gmweb 服务 | 于是「18112 被占用」成了常态,反而成了保护伞 |
| `console=False` | `print` 和 traceback 无处可去,现象只剩「窗口变多」 |
| 没有单实例锁 | 看起来像用户手抖多点了两下 |
换个角度,是**四个假设同时不成立**:
| 代码里的隐含假设 | 实际情况 |
|---|---|
| 打包后可执行文件仍是解释器 | 不是,是启动器自己 |
| spawn 成功 == 目的达成 | 只知道进程起来了,不知道它干了什么 |
| 同一时刻只有一个实例 | 想开几个开几个,每个都独立拉一遍 gmweb |
| 用户能描述故障 | 用户只会说「弹了一堆黑窗口」,`console=False` 下也没东西可给 |
前两个是技术假设,后两个是**可观测性缺失**——这才是它能活到用户机器上的真正原因。
### 怎么修的(不只是改那一行)
把四个假设逐个补上,缺一条都会复发:
| 补的假设 | 做法 |
|---|---|
| 打包后也能执行脚本 | 多调用模式 `run_script_instead()`:`exe <xxx.py> [args]` 用 `runpy` 直接跑脚本,不开窗、不提权 |
| spawn 成功 ≠ 目的达成 | spawn 后最多等 6 秒确认 18112 真的在监听,没监听就 `importlib` 在本进程线程里兜底 |
| 只应有一个实例 | 命名互斥量 `Local\DNF90Launcher-5E9C1B7A`,重复启动唤起已有窗口后退出 |
| 出问题要能取证 | exe 同目录 `启动器日志.txt`,pid / argv / 端口 / 子进程全记 |
### 教训(可迁移的部分)
- **凡是往 `subprocess` 里塞 `sys.executable` 的地方,打包前都要重新想一遍它到底指向谁。**
这不止 `Popen`:`multiprocessing`、`os.execv`、某些库的 worker 拉起,全都是同一个坑。
- **拉起子进程后要校验「副作用」,不是校验「返回值」。** 返回值只证明进程存在,端口在监听
才证明事情干了。
- **任何自增/顺延逻辑都会把「必然失败」变成「看起来正常的第 N 次尝试」。** 端口顺延、重试次数、
文件名加序号——同一个模式。加顺延的时候就要同时想「顺延到上限之后怎么报错」。
- **开发环境里「恰好被占用」的路径,等于没测过。** 想让某条分支真的被跑到,得先把常态占用的东西停掉。
- **`console=False` 的窗口程序必须自带日志**,否则所有异常都等于「用户说不行」。
---
## P-002 改了 DLL,客户端连不上
**现象**:换过 `Client\90CN.dll` 后,游戏能启动,一登录就被踢回,服务端日志没有明显报错。
**一句话结论**:`DNF90Server.exe` 编译期就把 DLL 的 SHA-256(`boundDllSHA256`)写死了,
DLL 一换指纹就不配套,服务端在登录阶段拒绝这个客户端。
**成因**:这是有意设计的防篡改绑定,不是 bug。麻烦在于它是**编译期常量**,不走配置。
**为什么没早发现**:单独测 DLL 改动时(比如只改 UI)不会重编服务端,本地能连上是因为当时
用的还是配套的那一对;一旦只更新了其中一半,问题才暴露。
**教训**:`90CN.dll` 和 `DNF90Server.exe` 永远同批构建、同批发。改 DLL 的流程固定为
「改代码 → 编译 → 补 CRC → 重编服务端 → 一起替换」。发版前用 `patch_dll_crc.py info`
和文件大小/哈希核对一遍,别单换一个。
---
## P-003 CRC32 算出另一个变体
**现象**:自校验永远不通过,客户端弹框退出;脚本自己 `verify` 也是不符。
**一句话结论**:给 `binascii.crc32(data, 0xFFFFFFFF)` 传种子等于把 conditioning 做了两次。
**成因**:`crc32` 的第二个参数是**已经过 final XOR 的运行中 CRC**,不是「初始值」。传
`0xFFFFFFFF` 相当于手工再补一次算法内部的翻转,算出来是另一个 variant。分段续算的正确写法
是直接用上一段的返回值:`crc = zlib.crc32(part1)` 然后 `zlib.crc32(part2, crc)`。
**为什么没早发现**:单段数据算出来的值看起来「像那么回事」(是合法 CRC32,只是变体不同),
肉眼比对不出来,只有客户端实际拒绝才暴露。
**教训**:不要「手动补」标准算法里的常量。库函数文档写「可续算」就说明它的返回值已经是可以
直接喂回去的形态。写完先拿一个已知答案的向量自测(比如 CRC32("123456789") = `0xCBF43926`)。
---
## P-004 写 CRC 槽位把 DLL 写坏
**现象**:补完 CRC 后 DLL 一加载就 AV,或者读槽位那行立刻崩。
**一句话结论**:特征值 `0xC0DEC0DE` 在 `.text` 段里也有一份(MSVC 把 `magic != 0xC0DEC0DEu`
编译成立即数),扫到第一个命中就写,把机器码改坏了。
**成因**:定位槽位用的是「全文搜 magic」,但 magic 作为**比较立即数**会出现在代码里。
`.text` 通常是只读的,写进去要么不生效,要么直接破坏指令。
**为什么没早发现**:不同编译器/优化级别下 `.text` 里那份立即数时有时无,本地构建能过,
换个编译参数就崩,看着像随机故障。
**教训**:按 PE 节表限定扫描范围,只在 `.data` / `.rdata` 这类数据节里找;写到 `expected`
字段时把槽位自身 4 字节排除掉,否则「算出来的值」和「写进去的值」互相影响,永远不自洽。
---
## P-005 竖线怎么都不显示
**现象**:关于页左右两栏之间的分隔竖线死活不出来,改 `.box-r` 的 `border-left` 没有任何反应。
**一句话结论**:`.cols .box { border: 0 }` 的权重是 (0,2,0),比 `.box-r` 的 (0,1,0) 高,把边框清掉了。
**成因**:为了去掉分组框默认边框,给 `.cols` 下的 `.box` 统一清零;单类选择器写了
`border-left`,权重不够,被覆盖。
**为什么没早发现**:Inspector 里看到的是「computed border = 0」,很容易以为是没写进去,
反复改同一条规则。
**教训**:排查「样式没生效」先看**哪条规则赢了**(DevTools 里划线的那条),而不是反复重写自己的。
修的时候用同权重选择器(`.cols .box-r`),别靠 `!important` 硬压。
---
## P-006 样式在游戏里全乱
**现象**:浏览器里排版正常,内嵌到游戏里就错位、堆叠、居中失效。
**一句话结论**:游戏内嵌的是 IE11 内核(WebView 老控件),没有 flex、没有 CSS 变量、
没有 `transform`(只有 `-ms-transform`)。
**成因**:GM 页跑在游戏客户端内嵌窗口里,UA 是 IE11。用现代布局等于没写。
**为什么没早发现**:本地调试常用 Edge/Chrome 打开,看起来一切正常。
**教训**:gmweb 的布局规则固定为 `float + box-sizing:border-box`;垂直居中用
`position:absolute; top:50%; -ms-transform:translateY(-50%)`。改完**必须在 800×720 的真实
内嵌尺寸下截图确认**,别只看浏览器。同理,JS 也要避开 IE11 不认的语法(箭头函数、
`let/const` 在部分内核上仍有问题)。
---
## P-007 改了代码却没生效
**现象**:重新打包、替换了 exe,双击后界面还是老样子、旧版本号。
**一句话结论**:单文件包固定用 19527 端口,**旧进程没退出**时新 exe 起不来新服务,
窗口连到的还是旧进程。
**成因**:`--onefile` 每次启动解包一份到临时目录,但端口是固定的;旧进程占着端口,
新进程只能顺延或失败,用户看到的窗口可能来自旧实例。
**为什么没早发现**:现象和「打包没成功」「缓存」完全一样,容易往错的方向查。
**教训**:改完先 `tasklist | findstr DNF90` 确认旧进程真的退了再验证;替换文件前先把运行中的
关掉,否则 `Copy-Item` 可能静默失败(文件被占用)。验证时看日志里的 `pid` 和「启动器端口」,
别用肉眼判断版本。
---
## P-008 页面永远是旧的
**现象**:改了 CSS/JS,重启启动器后页面样式没变。
**一句话结论**:WebView2 会缓存本地页面,静态响应必须显式禁缓存。
**成因**:内嵌浏览器按 URL 缓存,`127.0.0.1` 的静态资源同样会被缓存,重启进程不一定清。
**为什么没早发现**:清一次缓存/换个端口就「好了」,于是误判成偶发。
**教训**:静态响应统一加 `Cache-Control: no-store`;引用资源时再叠一层
`?v=YYYYMMDDx`(改一次资源就换一个字母后缀)。两层都要,一层不够。
---
## P-009 用户报错,但什么都看不到
**现象**:用户说「打不开」「闪一下就没了」,让他截图,什么都截不到。
**一句话结论**:`console=False` 的窗口程序 `sys.stdout` 是 `None`,`print` 和 traceback 全部丢失。
**成因**:PyInstaller 打成无控制台窗口的程序后,标准输出没有载体;未捕获异常也不会落盘。
**为什么没早发现**:开发时始终有控制台,报错看得一清二楚。
**教训**:窗口程序必须自带日志文件(exe 同目录、带时间戳、超限转 `.old.txt`),并且在最开头
就把 `pid`、`argv`、关键路径写进去——**这些在事后无法补录**。装好 tee 之后注意别再重复写:
自己 `print` 一遍又直接写文件一遍,日志会变成双份。
---
## P-010 缺图占位不生效
**现象**:二维码图片缺失时,本该显示「未配置」占位,实际显示的是破图图标。
**一句话结论**:`img` 的 `onerror` 绑定晚于图片加载失败,事件早就过去了。
**成因**:脚本在 `DOMContentLoaded` 之后绑定;图片在 HTML 解析阶段就请求并 404 了,
`onerror` 那时候还不存在。
**为什么没早发现**:本地图片都在,只有用户机器上缺图才复现。
**教训**:绑定后立刻补一次兜底判断——`img.complete && img.naturalWidth === 0` 就当失败处理。
「事件绑定晚于事件发生」这个模式在前端反复出现,凡是依赖 `load`/`error` 的都要补这一手。
---
## P-011 仓库和实际跑的不是一份
**现象**:仓库里明明改了,实际跑的还是旧行为;或者提交时发现少文件。
**一句话结论**:`Z:\90server` 只是**部署副本**,不是工作区;改完必须回灌仓库。
**成因**:调试时常直接在部署目录改,改完忘了同步;或者只提交了源码,漏了配置文件/静态资源。
**为什么没早发现**:两边看起来「都能跑」,直到下次从仓库重新构建才发现差异。
**教训**:改完用 `diff -rq <仓库目录> <部署目录>` 核对,**排除故意的差异**(比如 `serve.py`
只在仓库里);提交前 `git status` 看有没有该进的 ini / 静态资源。曾漏过一次
`90CN_web.ini` 和整个 `gmweb` 目录,教训是「配置文件和代码一样是交付物」。
---
## P-012 批量改文件「改成功了」其实没改
**现象**:脚本打印「patched」,重新打包后行为没变。
**一句话结论**:`str.replace()` 找不到目标时**不报错**,静默返回原串。
**成因**:待匹配文本里空格、引号、换行只要差一个字符,替换就整体失效,而脚本照样往下走、
照样打印成功。
**为什么没早发现**:输出一片「ok」,没人会去 diff 结果。
**教训**:每处替换前 `assert old in content`,替换后再 `assert new in content`;脚本结束打印
实际改动条数(`n` 为 0 就该警惕)。改完务必看一眼 `git diff --stat`,行数对不上就是没改成。
---
## P-013 补丁包缺文件
**现象**:补丁包发出去,用户装上后回退成 1040 宽窗口、GM 页打不开、或者登录被踢。
**一句话结论**:发版是**清单活**,不是「把改过的拖进去」。
**成因**:一次发版踩了三个:DLL 与 exe 指纹不配套、漏了 `gmweb` 整个目录、漏了
`Client\90CN_web.ini`(缺它就回退默认宽度和 `close_action=0`)。
**为什么没早发现**:本地测试用的是完整目录,只验证「能跑」,没验证「只装补丁包能不能跑」。
**教训**:发版固定清单——① `Client\90CN.dll` 与 `Server\runtime\bin\DNF90Server.exe` 成对且
指纹一致;② `gmweb\`(含 `qr\` 四张图);③ `Client\90CN_web.ini`;④ 启动器 exe 与
`更新日志.txt`;⑤ 版本号三处一致(`CURRENT_VERSION`、客户端显示、更新日志)。装完在
**干净目录**里实跑一遍,再对外发。
---
## 从这些坑里提炼的通用规则
1. **打包会改变语义。** 任何依赖 `sys.executable`、`__file__`、工作目录、相对路径的代码,
打包前后都要重新推一遍。
2. **校验副作用,不校验返回值。** 进程起来了 ≠ 事情干了;文件写了 ≠ 内容对了。
3. **顺延/重试会把确定性失败伪装成正常。** 加顺延就同时加「上限之后怎么报错」。
4. **开发环境的常态占用会掩护 bug。** 想测某条分支,先把它前面的短路条件拆掉。
5. **没有输出的程序等于没有可维护性。** 窗口程序一律自带日志,开头就写 pid/argv/路径。
6. **「改完没生效」先怀疑三类**:旧进程没退、缓存没清、文件没同步到实际运行的位置。
7. **成对的东西不能分开动**:DLL 与 exe、`.url` 与发布说明、仓库与部署目录。
8. **发版是清单活。** 清单写下来,别凭记忆。
## 相关文档
| 文档 | 内容 |
|------|------|
| [启动器窗口风暴排查.md](启动器窗口风暴排查.md) | P-001 的完整排查过程、复现步骤、日志判读表 |
| [客户端补丁与CRC校验.md](客户端补丁与CRC校验.md) | P-002 / P-003 / P-004 的机制细节与工具用法 |
| [开发文档.md](开发文档.md) | 启动器与 GM 工具的开发、打包、部署 |
| [服务端开发文档.md](服务端开发文档.md) | 服务端 GM 接口与 DLL 指纹绑定 |
+532
View File
@@ -0,0 +1,532 @@
/* GM 控制台前端 —— 严格 ES5 + XMLHttpRequest,兼容 IE11 内嵌浏览器。
所有请求走同源 /api/*,由本地 serve.py 代理到服务端 admin 口并注入 token。 */
(function () {
'use strict';
var S = {
charId: '', charName: '', chars: [],
itemId: 0, itemName: '', picked: null, items: [],
page: 1, pageSize: 20, total: 0
};
/* ---------------------------------------------------------------- 基础 */
function $(id) { return document.getElementById(id); }
function log(msg, cls) {
var box = $('log');
var d = document.createElement('div');
if (cls) { d.className = cls; }
var t = new Date();
var p = function (n) { return (n < 10 ? '0' : '') + n; };
d.innerHTML = '[' + p(t.getHours()) + ':' + p(t.getMinutes()) + ':' + p(t.getSeconds()) + '] ' + esc(msg);
box.appendChild(d);
box.scrollTop = box.scrollHeight;
while (box.childNodes.length > 120) { box.removeChild(box.firstChild); }
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function api(path, method, body, cb) {
var xhr = new XMLHttpRequest();
xhr.open(method || 'GET', '/api' + path, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) { return; }
if (xhr.status === 0) { log('连不上本地代理(serve.py 没启动?)', 'err'); cb(null); return; }
var data = null;
try { data = JSON.parse(xhr.responseText); } catch (e) { data = null; }
if (!data) { log('响应不是 JSON:' + xhr.responseText.substring(0, 120), 'err'); cb(null); return; }
if (data.success === false || data.ok === false) {
log('失败:' + (data.message || data.error || '未知错误'), 'err');
cb(null);
return;
}
cb(data);
};
xhr.send(body ? JSON.stringify(body) : null);
}
function setOnline(on, text) {
$('dot').className = 'dot ' + (on ? 'on' : 'off');
$('stat').innerHTML = esc(text);
}
/* ---------------------------------------------------------------- 标签页 */
function switchTab(name) {
var tabs = document.getElementsByClassName('tab');
for (var i = 0; i < tabs.length; i++) {
tabs[i].className = 'tab' + (tabs[i].getAttribute('data-tab') === name ? ' on' : '');
}
var panes = document.getElementsByClassName('pane');
for (var j = 0; j < panes.length; j++) {
panes[j].className = 'pane' + (panes[j].id === 'pane-' + name ? ' on' : '');
}
try { if (location.hash !== '#' + name) location.hash = '#' + name; } catch (e) { }
if (name === 'loadout' && typeof window.LoadoutTab === 'function') {
window.LoadoutTab();
}
}
function bindTabs() {
var tabs = document.getElementsByClassName('tab');
for (var i = 0; i < tabs.length; i++) {
tabs[i].onclick = function () { switchTab(this.getAttribute('data-tab')); };
}
// 支持用 #item / #char / #about / #home 直接打开某一页
var h = (location.hash || '').replace('#', '');
if (h && document.getElementById('pane-' + h)) switchTab(h);
window.onhashchange = function () {
var n = (location.hash || '').replace('#', '');
if (n && document.getElementById('pane-' + n)) switchTab(n);
};
}
/* ---------------------------------------------------------------- 状态页 */
function loadStatus() {
api('/dnf/gm/status', 'GET', null, function (d) {
if (!d) { setOnline(false, '服务端未响应'); return; }
var v = d.data || {};
$('v-online').innerHTML = esc(v.online_characters == null ? '-' : v.online_characters);
$('v-repo').innerHTML = v.repository_ready ? '就绪' : '未就绪';
$('v-items').innerHTML = v.item_catalog_ready ? '就绪' : '未就绪';
$('v-cap').innerHTML = esc(v.level_cap == null ? '-' : v.level_cap);
setOnline(true, '已连接 127.0.0.1:18111');
});
}
/* ---------------------------------------------------------------- 角色 */
function renderChars() {
var box = $('char-list');
if (!S.chars.length) { box.innerHTML = '<div class="empty">没有角色</div>'; return; }
var h = [];
for (var i = 0; i < S.chars.length; i++) {
var c = S.chars[i];
h.push('<div class="li' + (String(c.character_id) === String(S.charId) ? ' on' : '') +
'" data-id="' + esc(c.character_id) + '" data-name="' + esc(c.name) + '" data-gold="' + esc(c.gold) +
'" data-level="' + esc(c.level) + '"><span class="nm">' + esc(c.name) +
'</span><span class="meta">Lv.' + esc(c.level) + ' · 金币 ' + esc(c.gold) +
(c.online ? ' · 在线' : '') + '</span></div>');
}
box.innerHTML = h.join('');
var lis = box.getElementsByClassName('li');
for (var k = 0; k < lis.length; k++) {
lis[k].onclick = function () { pickChar(this); };
}
}
function pickChar(el) {
S.charId = el.getAttribute('data-id');
S.charName = el.getAttribute('data-name');
$('in-gold').value = el.getAttribute('data-gold');
$('in-level').value = el.getAttribute('data-level');
$('cur').innerHTML = esc(S.charName) + ' <span style="color:#7c8496">(id=' + esc(S.charId) + ')</span>';
renderChars();
log('已选角色:' + S.charName, 'tip');
}
function loadChars() {
var q = $('q-char').value;
var path = '/local/dnf-gm/characters' + (q ? '?character_name=' + encodeURIComponent(q) : '');
api(path, 'GET', null, function (d) {
if (!d) { return; }
S.chars = d.data || [];
renderChars();
log('角色 ' + S.chars.length + ' 个');
if (S.chars.length === 1) { pickChar($('char-list').getElementsByClassName('li')[0]); }
});
}
function setGold() {
if (!requireChar()) { return; }
var v = parseInt($('in-gold').value, 10);
if (isNaN(v) || v < 0) { log('金币要填非负整数', 'err'); return; }
api('/dnf/gm/characters/' + S.charId + '/gold', 'POST', { value: v }, function (d) {
if (d) { log('金币已改为 ' + v, 'ok'); loadChars(); }
});
}
function setLevel() {
if (!requireChar()) { return; }
var v = parseInt($('in-level').value, 10);
if (isNaN(v) || v < 1) { log('等级要填正整数', 'err'); return; }
api('/dnf/gm/characters/' + S.charId + '/level', 'POST', { level: v }, function (d) {
if (d) { log('等级已改为 ' + v, 'ok'); loadChars(); }
});
}
function requireChar() {
if (!S.charId) { log('先在「角色」页选一个角色', 'err'); return false; }
return true;
}
/* 一键回满疲劳:直接把库里 fatigue 写回上限。
服务端要求角色已下线(在线时写库会被下一个存档覆盖),所以在线角色会报错。 */
function refillFatigue() {
if (!requireChar()) { return; }
api('/dnf/gm/characters/' + S.charId + '/fatigue/refill', 'POST', {}, function (d) {
if (!d) { return; }
log('已回满 ' + S.charName + ' 的疲劳值', 'ok');
loadChars();
});
}
/* ---------------------------------------------------------------- 物品 */
/* 装备类型标签:PVF 里是 "[weapon]" 这种,去掉括号后映射成中文。 */
var EQUIP_LABEL = {
'weapon': '武器', 'coat': '上衣', 'shoulder': '头肩', 'pants': '下装',
'shoes': '鞋', 'waist': '腰带', 'amulet': '项链', 'wrist': '手镯',
'ring': '戒指', 'support': '辅助装备', 'magic stone': '魔法石',
'earring': '耳环', 'support weapon': '辅助武器', 'avatar': '时装',
'creature': '宠物', 'artifact': '神器'
};
/* 与服务器 gmItemSupportsEquipmentAttributes 保持一致:只有这些类型吃得下强化/增幅。 */
var UPGRADABLE = {
'weapon': 1, 'coat': 1, 'shoulder': 1, 'pants': 1, 'shoes': 1, 'waist': 1,
'amulet': 1, 'wrist': 1, 'ring': 1, 'support': 1, 'magic stone': 1,
'earring': 1, 'support weapon': 1
};
function equipTag(value) {
return String(value == null ? '' : value)
.replace(/[\[\]`]/g, '').replace(/^\s+|\s+$/g, '').toLowerCase();
}
function kindText(it) {
if (!it) { return ''; }
if (it.kind !== 'equipment') { return '道具'; }
var tag = equipTag(it.equipment_type);
return '装备 · ' + (EQUIP_LABEL[tag] || tag || '未知');
}
function canUpgrade(it) {
if (!it || it.kind !== 'equipment') { return false; }
return UPGRADABLE[equipTag(it.equipment_type)] === 1;
}
function clamp(v, lo, hi) {
if (isNaN(v)) { return lo; }
return v < lo ? lo : (v > hi ? hi : v);
}
function renderItems() {
var box = $('item-list');
var pages = S.total > 0 ? Math.ceil(S.total / S.pageSize) : 1;
$('page-info').innerHTML = '第 ' + S.page + ' / ' + pages + ' 页 · 共 ' + S.total + ' 条';
$('btn-prev').disabled = S.page <= 1;
$('btn-next').disabled = S.page >= pages;
if (!S.items.length) {
box.innerHTML = '<div class="empty">没有匹配的物品</div>';
return;
}
var h = [];
for (var i = 0; i < S.items.length; i++) {
var it = S.items[i];
h.push('<div class="li' + (it.item_id === S.itemId ? ' on' : '') +
'" data-i="' + i + '">' +
'<span class="nm">' + esc(it.name) + '</span>' +
'<span class="meta">id ' + esc(it.item_id) + ' · ' + esc(kindText(it)) +
(canUpgrade(it) ? '' : '') + '</span></div>');
}
box.innerHTML = h.join('');
var lis = box.getElementsByClassName('li');
for (var k = 0; k < lis.length; k++) {
lis[k].onclick = function () {
var index = parseInt(this.getAttribute('data-i'), 10);
pickItem(S.items[index]);
};
}
}
function renderPicked() {
var box = $('picked');
if (!S.itemId) {
box.innerHTML = '未选择物品 —— 先搜索,再点一行';
} else {
var it = S.picked;
var hint = '';
if (it && it.kind === 'equipment') {
hint = canUpgrade(it) ? '可强化 / 增幅' : '该类型不支持强化(时装类)';
} else {
hint = '普通道具,属性值会被忽略';
}
box.innerHTML = esc(S.itemName) + ' <span class="dim">(id ' + esc(S.itemId) + ')</span> · ' + esc(hint);
}
var off = !canUpgrade(S.picked);
$('in-reinforce').disabled = off;
$('in-amplify').disabled = off;
$('in-amplify-level').disabled = off;
$('in-forging').disabled = off;
$('in-quality').disabled = off;
}
function pickItem(it) {
if (!it) { return; }
S.itemId = it.item_id;
S.itemName = it.name;
S.picked = it;
renderItems();
renderPicked();
log('已选物品:' + it.name + '(id ' + it.item_id + ')', 'tip');
}
/* 分组 -> 类型选项。装备/时装/宠物走 tag(PVF 的 equipment type),
道具走 segment(服务端按 stackable type 归类成中文段)。
有了这两级下拉,不敲键盘也能把范围缩到十几页。 */
var TYPE_OPTIONS = {
equipment: [
['tag:weapon', '武器'], ['tag:coat', '上衣'], ['tag:shoulder', '头肩'],
['tag:pants', '下装'], ['tag:shoes', '鞋'], ['tag:waist', '腰带'],
['tag:amulet', '项链'], ['tag:wrist', '手镯'], ['tag:ring', '戒指'],
['tag:support', '辅助装备'], ['tag:magic stone', '魔法石'],
['tag:earring', '耳环'], ['tag:support weapon', '辅助武器'],
['tag:title name', '称号'], ['tag:name tag', '名牌']
],
avatar: [
['tag:hat avatar', '帽子'], ['tag:hair avatar', '头发'], ['tag:face avatar', '脸部'],
['tag:coat avatar', '上衣'], ['tag:breast avatar', '胸部'], ['tag:waist avatar', '腰部'],
['tag:pants avatar', '下装'], ['tag:shoes avatar', '鞋'], ['tag:skin avatar', '皮肤'],
['tag:aurora avatar', '光环'], ['tag:weapon avatar', '武器装扮']
],
pet: [
['tag:creature', '宠物'], ['tag:artifact red', '神器·红'],
['tag:artifact blue', '神器·蓝'], ['tag:artifact green', '神器·绿']
],
stackable: [
['seg:消耗品', '消耗品'], ['seg:材料', '材料'], ['seg:特殊材料', '特殊材料'],
['seg:任务品', '任务品'], ['seg:徽章', '徽章'], ['seg:副职业材料', '副职业材料']
]
};
function fillTypeOptions() {
var opts = TYPE_OPTIONS[$('in-group').value] || [];
var h = ['<option value="">不限</option>'];
for (var i = 0; i < opts.length; i++) {
h.push('<option value="' + esc(opts[i][0]) + '">' + esc(opts[i][1]) + '</option>');
}
$('in-tag').innerHTML = h.join('');
}
function loadItems(page) {
var q = $('q-item').value;
var size = parseInt($('in-page-size').value, 10) || 20;
var p = page || 1;
if (p < 1) { p = 1; }
S.pageSize = size;
var group = $('in-group').value;
var picked = $('in-tag').value;
var minLv = parseInt($('in-lv-min').value, 10);
var maxLv = parseInt($('in-lv-max').value, 10);
if (isNaN(minLv)) { minLv = 0; }
if (isNaN(maxLv)) { maxLv = 0; }
var qs = 'limit=' + size + '&offset=' + ((p - 1) * size) +
'&q=' + encodeURIComponent(q || '');
if (group) { qs += '&group=' + encodeURIComponent(group); }
if (picked) {
if (picked.indexOf('tag:') === 0) {
qs += '&tag=' + encodeURIComponent(picked.substring(4));
} else if (picked.indexOf('seg:') === 0) {
qs += '&segment=' + encodeURIComponent(picked.substring(4));
}
}
if (minLv > 0) { qs += '&min_level=' + minLv; }
if (maxLv > 0) { qs += '&max_level=' + maxLv; }
api('/dnf/gm/items/page?' + qs, 'GET', null, function (d) {
if (!d) { return; }
var pageData = d.data || {};
S.items = pageData.items || [];
S.total = pageData.total || 0;
S.page = p;
renderItems();
log('物品 ' + S.items.length + ' 条 / 共 ' + S.total + ' 条');
});
}
/* 组装强化/增幅/锻造/品质:增幅优先,红字装不再叠加白字强化。 */
function readOptions() {
var out = { reinforce: 0, amplify_type: 0, amplify_level: 0, forging: 0, equipment_quality: '' };
if (!canUpgrade(S.picked)) { return out; }
var amp = parseInt($('in-amplify').value, 10) || 0;
var ampLevel = parseInt($('in-amplify-level').value, 10) || 0;
var rein = parseInt($('in-reinforce').value, 10) || 0;
var forge = parseInt($('in-forging').value, 10) || 0;
if (amp > 0) {
out.amplify_type = clamp(amp, 1, 4);
out.amplify_level = clamp(ampLevel, 0, 31);
out.forging = 0;
} else {
out.reinforce = clamp(rein, 0, 31);
out.forging = clamp(forge, 0, 8);
}
out.equipment_quality = $('in-quality').value || '';
return out;
}
/* 增幅属性编号取自 etc/amplifyitem.etc 的 [option mapping table]。 */
var AMP_LABEL = { 1: '物防', 2: '魔防', 3: '物攻', 4: '魔攻' };
function optText(o) {
var parts = [];
if (o.amplify_type > 0) {
parts.push('增幅' + (AMP_LABEL[o.amplify_type] || o.amplify_type) + ' +' + o.amplify_level);
} else if (o.reinforce > 0) {
parts.push('强化 +' + o.reinforce);
}
if (o.forging > 0) { parts.push('锻造 +' + o.forging); }
if (o.equipment_quality) {
parts.push('品质' + (o.equipment_quality === 'top' ? '最上级' : '随机'));
}
return parts.length ? '(' + parts.join(',') + ')' : '';
}
function grantItem() {
if (!requireChar()) { return; }
if (!S.itemId) { log('先搜一个物品并选中', 'err'); return; }
var n = parseInt($('in-count').value, 10) || 1;
var o = readOptions();
api('/local/dnf-gm/item/grant-options', 'POST', {
character_id: String(S.charId), item_id: S.itemId, count: n,
reinforce: o.reinforce, amplify_type: o.amplify_type, amplify_level: o.amplify_level,
forging: o.forging, equipment_quality: o.equipment_quality
}, function (d) {
if (!d) { return; }
var data = d.data || {};
log('已发 ' + S.itemName + ' ×' + n + optText(o) +
(data.slot != null ? ',格位 ' + data.list_type + ':' + data.slot : ''), 'ok');
// 回游戏里看背包即可(客户端会自动刷新),这里不再切页打断操作
});
}
function mailItem() {
if (!requireChar()) { return; }
if (!S.itemId) { log('先搜一个物品并选中', 'err'); return; }
var n = parseInt($('in-count').value, 10) || 1;
var o = readOptions();
api('/dnf/gm/characters/' + S.charId + '/mail', 'POST', {
item_id: S.itemId, count: n, title: 'GM 物品', body: 'GM 发放',
expire_days: 7, reinforce: o.reinforce, amplify_type: o.amplify_type,
amplify_level: o.amplify_level, equipment_quality: o.equipment_quality,
forging: o.forging, emblem_item_ids: [], enchant_bead_item_id: 0
}, function (d) {
if (d) { log('已邮寄 ' + S.itemName + ' ×' + n + optText(o), 'ok'); }
});
}
/* ---------------------------------------------------------------- 赞助 */
/* 收款码图片是可选的:放了就显示,没放(404)就把占位提示顶上来。 */
function bindQr(id) {
var img = $(id);
if (!img) { return; }
function showMiss() {
var box = img.parentNode;
if (box) { box.className = 'qr-box miss'; }
img.style.display = 'none';
}
function showImg() {
var box = img.parentNode;
if (box) { box.className = 'qr-box'; }
img.style.display = '';
}
img.onerror = showMiss;
img.onload = showImg;
/* 图片是 HTML 里带的 src,可能在脚本执行前就已经 404 了,onerror 来不及触发 */
if (img.complete && img.naturalWidth === 0) { showMiss(); }
}
/* ---------------------------------------------------------------- 启动 */
function bind(id, fn) {
var el = $(id);
if (el) { el.onclick = fn; }
}
/* 内嵌 IE 控件的输入法常被游戏主窗口占着,focus 时再主动要一次。
ime-mode:active 是 IE 私有属性,别的浏览器会忽略,不影响兼容。 */
function forceIME() {
var els = document.getElementsByTagName('input');
for (var i = 0; i < els.length; i++) {
els[i].onfocus = function () {
try { this.style.imeMode = 'active'; } catch (e) { }
};
}
}
function init() {
bindTabs();
bind('btn-refresh', function () { loadStatus(); log('刷新状态'); });
bind('btn-resync', function () {
if (!requireChar()) { return; }
api('/local/quest-resync', 'POST', { character_id: parseInt(S.charId, 10) }, function (d) {
if (d) { log('角色数据已同步', 'ok'); }
});
});
bind('btn-search-char', loadChars);
bind('btn-gold', setGold);
bind('btn-level', setLevel);
bind('btn-search-item', function () { loadItems(1); });
bind('btn-prev', function () { if (S.page > 1) { loadItems(S.page - 1); } });
bind('btn-next', function () { loadItems(S.page + 1); });
bind('btn-grant', grantItem);
bind('btn-mailitem', mailItem);
bind('btn-fatigue', refillFatigue);
$('in-page-size').onchange = function () { loadItems(1); };
$('in-group').onchange = function () { fillTypeOptions(); loadItems(1); };
$('in-tag').onchange = function () { loadItems(1); };
/* 等级快捷档:点一下就把区间填好并重新查,省得手敲。 */
var lvBtns = document.getElementsByTagName('button');
for (var b = 0; b < lvBtns.length; b++) {
var spec = lvBtns[b].getAttribute('data-lv');
if (!spec) { continue; }
lvBtns[b].onclick = function () {
var pair = this.getAttribute('data-lv').split('-');
$('in-lv-min').value = pair[0];
$('in-lv-max').value = pair[1];
loadItems(1);
};
}
$('q-char').onkeydown = function (e) { if ((e || window.event).keyCode === 13) { loadChars(); } };
$('q-item').onkeydown = function (e) {
e = e || window.event;
// 229 是输入法组合中的键码,这时候的回车是上屏,不能抢。
if (e.keyCode === 229) { return; }
if (e.keyCode === 13) {
if (e.preventDefault) { e.preventDefault(); }
loadItems(1);
return false;
}
};
fillTypeOptions();
bindQr('qr-alipay');
bindQr('qr-wechat');
bindQr('qr-gzh');
bindQr('qr-qq');
renderPicked();
forceIME();
log('GM 控制台已加载', 'tip');
loadStatus();
loadChars();
setInterval(loadStatus, 15000);
}
if (window.addEventListener) { window.addEventListener('load', init, false); }
else { window.attachEvent('onload', init); }
})();
+246
View File
@@ -0,0 +1,246 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8">
<title>GM 控制台</title>
<link rel="stylesheet" href="/style.css?v=20261105C">
</head>
<body>
<div id="topbar">
<div id="tabs">
<a class="tab on" data-tab="home" href="javascript:void(0)">状态</a>
<a class="tab" data-tab="char" href="javascript:void(0)">角色</a>
<a class="tab" data-tab="loadout" href="javascript:void(0)">一键换装</a>
<a class="tab" data-tab="item" href="javascript:void(0)">发物品</a>
<a class="tab" data-tab="about" href="javascript:void(0)">关于</a>
</div>
<div id="hd-s"><span id="dot" class="dot off"></span><span id="stat">连接中…</span></div>
</div>
<div id="body">
<div class="pane on" id="pane-home">
<table class="kv">
<tr><th>在线角色</th><td id="v-online">-</td></tr>
<tr><th>角色库</th><td id="v-repo">-</td></tr>
<tr><th>物品库</th><td id="v-items">-</td></tr>
<tr><th>等级上限</th><td id="v-cap">-</td></tr>
</table>
<div class="sec">当前角色</div>
<div id="cur" class="cur">未选择 —— 到「角色」页选一个</div>
<div class="sec">快捷操作</div>
<div class="row">
<button id="btn-refresh" class="btn">刷新状态</button>
<button id="btn-resync" class="btn">同步角色数据</button>
</div>
</div>
<div class="pane" id="pane-char">
<div class="row">
<input id="q-char" class="inp" placeholder="角色名 / 账号,留空列全部">
<button id="btn-search-char" class="btn">查询</button>
</div>
<div id="char-list" class="list"></div>
<div class="sec">改数值</div>
<div class="row">
<label class="lb">金币</label><input id="in-gold" class="inp sm" value="">
<button id="btn-gold" class="btn">写入</button>
</div>
<div class="row">
<label class="lb">等级</label><input id="in-level" class="inp sm" value="">
<button id="btn-level" class="btn">写入</button>
</div>
<div class="sec">疲劳</div>
<div class="row">
<button id="btn-fatigue" class="btn pri">回满疲劳</button>
<span class="dim">角色需先在游戏里下线(回到角色选择)才能写库</span>
</div>
</div>
<div class="pane" id="pane-item">
<div class="row">
<label class="lb">分组</label>
<select id="in-group" class="inp sm">
<option value="">全部</option>
<option value="equipment">装备</option>
<option value="avatar">时装</option>
<option value="pet">宠物 / 神器</option>
<option value="stackable">道具</option>
<option value="other">其他</option>
</select>
<label class="lb">类型</label>
<select id="in-tag" class="inp sm"><option value="">不限</option></select>
</div>
<div class="row">
<label class="lb">等级</label>
<input id="in-lv-min" class="inp xs" value="">
<span class="dim">~</span>
<input id="in-lv-max" class="inp xs" value="">
<button class="btn sm" data-lv="1-20">1-20</button>
<button class="btn sm" data-lv="21-40">21-40</button>
<button class="btn sm" data-lv="41-60">41-60</button>
<button class="btn sm" data-lv="61-90">61-90</button>
</div>
<div class="row">
<textarea id="q-item" class="inp ta" rows="1" placeholder="物品名关键字(可留空,用上面分类浏览)"></textarea>
<select id="in-page-size" class="inp sm">
<option value="10">每页 10 行</option>
<option value="20" selected>每页 20 行</option>
<option value="50">每页 50 行</option>
</select>
<button id="btn-search-item" class="btn">搜索</button>
</div>
<div id="item-list" class="list fixed"></div>
<div class="pager">
<button id="btn-prev" class="btn sm">上一页</button>
<span id="page-info" class="pg">-</span>
<button id="btn-next" class="btn sm">下一页</button>
</div>
<div id="picked" class="cur">未选择物品 —— 先搜索,再点一行</div>
<div class="sec">装备属性 <span class="dim">(武器 / 防具 / 首饰)</span></div>
<div class="row">
<label class="lb">强化</label><input id="in-reinforce" class="inp xs" value="0">
<label class="lb">增幅</label><select id="in-amplify" class="inp xs2">
<option value="0" selected>不增幅</option>
<option value="1">1 物理防御</option>
<option value="2">2 魔法防御</option>
<option value="3">3 物理攻击</option>
<option value="4">4 魔法攻击</option>
</select>
</div>
<div class="row">
<label class="lb">增幅等级</label><input id="in-amplify-level" class="inp xs" value="0">
<label class="lb">锻造</label><input id="in-forging" class="inp xs" value="0">
</div>
<div class="row">
<label class="lb">品质</label><select id="in-quality" class="inp sm">
<option value="" selected>默认</option>
<option value="random">随机</option>
<option value="top">最上级</option>
</select>
<label class="lb">数量</label><input id="in-count" class="inp xs" value="1">
</div>
<div class="row">
<button id="btn-grant" class="btn pri">发到背包</button>
<button id="btn-mailitem" class="btn">发到邮箱</button>
</div>
<div class="hint">强化 0-31、增幅等级 0-31、锻造 0-8。增幅填了就直接覆盖强化(红字装不能同时白字强化),非装备行忽略这些值。</div>
</div>
<div class="pane" id="pane-loadout">
<div class="row">
<label class="lb">角色</label>
<select id="lo-char" class="inp sm"></select>
<button id="lo-refresh" class="btn">刷新</button>
<span id="lo-msg" class="dim"></span>
</div>
<div class="lo-wrap">
<div class="lo-col lo-left">
<div class="sec">换装页</div>
<div class="row">
<select id="lo-page" class="inp sm"></select>
<button id="lo-page-add" class="btn sm">新增</button>
<button id="lo-page-ren" class="btn sm">重命名</button>
<button id="lo-page-del" class="btn sm">删除</button>
</div>
<div class="sec">方案</div>
<div id="lo-schemes" class="list fixed"></div>
<div class="row">
<button id="lo-save" class="btn pri">保存当前装备为方案</button>
</div>
<div class="row">
<button id="lo-scheme-add" class="btn sm">新增方案</button>
<button id="lo-scheme-ren" class="btn sm">重命名</button>
<button id="lo-scheme-del" class="btn sm">删除</button>
</div>
<div class="hint">保存后会给当前角色创建一套换装方案。</div>
<div id="lo-hotkeys" class="hint"></div>
</div>
<div class="lo-col lo-right">
<div class="sec">方案详情</div>
<div id="lo-detail" class="cur">未选择方案</div>
<div id="lo-slots" class="lo-slots"></div>
<div class="row">
<button id="lo-hotkey" class="btn">设置快捷键</button>
<button id="lo-hotkey-clear" class="btn sm">清除</button>
<span id="lo-hotkey-text" class="dim"></span>
</div>
<div class="row">
<button id="lo-apply" class="btn pri">应用方案</button>
<span id="lo-cooldown" class="dim"></span>
</div>
<div class="hint">每次换装完成后进入 <b style="color:#e8c979">10 秒冷却</b>;冷却期间点击按钮或按全局热键都会被服务端拒绝。</div>
</div>
</div>
</div>
<div class="pane" id="pane-about">
<div class="about-inner">
<div class="about-card">
<div class="about-name">地下城与勇士 · 九零轮回单机</div>
<div class="about-line">汉服电脑宅 · 灵宝小竹模拟器</div>
<div class="about-line">由灵宝仙尊联合淡竹茹开发,由汉服电脑宅团队开发维护</div>
<div class="about-line">本模拟器资源禁止倒卖,请到官方微信公众号或社群下载</div>
</div>
<div class="cols">
<div class="box box-l">
<div class="box-hd">赞助我们</div>
<div class="box-bd">
<div class="qr-card">
<div class="qr-title">支付宝</div>
<div class="qr-box"><img id="qr-alipay" src="/qr/alipay.png" alt="支付宝收款码">
<div class="qr-miss">二维码未配置<div class="qr-path">gmweb\qr\alipay.png</div></div>
</div>
</div>
<div class="qr-card">
<div class="qr-title">微信</div>
<div class="qr-box"><img id="qr-wechat" src="/qr/wechat.png" alt="微信收款码">
<div class="qr-miss">二维码未配置<div class="qr-path">gmweb\qr\wechat.png</div></div>
</div>
</div>
<div class="box-note">金额随意,感谢支持。扫码后备注游戏名,方便记录。</div>
</div>
</div>
<div class="box box-m">
<div class="box-hd">官方QQ群</div>
<div class="box-bd">
<div class="qr-card">
<div class="qr-title">QQ群</div>
<div class="qr-box"><img id="qr-qq" src="/qr/qq.png" alt="官方QQ群二维码">
<div class="qr-miss">二维码未配置<div class="qr-path">gmweb\qr\qq.png</div></div>
</div>
</div>
<div class="box-note">群号 1128511524</div>
</div>
</div>
<div class="box box-r">
<div class="box-hd">团队公众号</div>
<div class="box-bd">
<div class="qr-card">
<div class="qr-title">扫码关注</div>
<div class="qr-box"><img id="qr-gzh" src="/qr/gzh.jpg" alt="团队公众号二维码">
<div class="qr-miss">二维码未配置<div class="qr-path">gmweb\qr\gzh.jpg</div></div>
</div>
</div>
<div class="box-note">更新公告 · 开服时间</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="log"></div>
<script src="/app.js?v=20261105C"></script>
<script src="/loadout.js?v=20261105A"></script>
</body>
</html>
+519
View File
@@ -0,0 +1,519 @@
/* 一键换装(游戏内 GM 窗口)
* IE11 兼容:ES5 + XMLHttpRequest,不使用箭头函数/模板字符串/flex。
* 数据:/api/loadout/config(本地 config.json),角色/装备/应用走 serve.py 代理到 18111。
*/
(function () {
'use strict';
var S = {
cfg: null,
chars: [],
charName: '',
page: 0,
scheme: -1,
equipment: [],
cooldown: {},
timer: null
};
function keyText(code) {
if (code >= 112 && code <= 123) { return 'F' + (code - 111); }
if (code >= 96 && code <= 105) { return 'Num' + (code - 96); }
if (code >= 48 && code <= 57) { return String.fromCharCode(code); }
if (code >= 65 && code <= 90) { return String.fromCharCode(code); }
if (code === 13) { return 'Enter'; }
if (code === 32) { return 'Space'; }
return 'VK' + code;
}
function modText(mods) {
var out = [];
if (mods & 2) { out.push('Ctrl'); }
if (mods & 1) { out.push('Alt'); }
if (mods & 4) { out.push('Shift'); }
return out;
}
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function log(text, cls) {
var box = $('log');
if (!box) { return; }
var d = document.createElement('div');
if (cls) { d.className = cls; }
var t = new Date();
var p = function (n) { return (n < 10 ? '0' : '') + n; };
d.innerHTML = '[' + p(t.getHours()) + ':' + p(t.getMinutes()) + ':' + p(t.getSeconds()) + '] ' + esc(text);
box.appendChild(d);
box.scrollTop = box.scrollHeight;
while (box.childNodes.length > 120) { box.removeChild(box.firstChild); }
}
function api(path, method, body, cb) {
var xhr = new XMLHttpRequest();
xhr.open(method || 'GET', '/api' + path, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) { return; }
if (xhr.status === 0) {
log('连不上本地代理(gmweb/serve.py 未启动?)', 'err');
if (cb) { cb(null); }
return;
}
var data = null;
try { data = JSON.parse(xhr.responseText); } catch (e) { data = null; }
if (!data) {
log('响应不是 JSON:' + String(xhr.responseText || '').substring(0, 120), 'err');
if (cb) { cb(null); }
return;
}
if (data.ok === false || data.success === false) {
log('失败:' + (data.error || data.message || '未知错误'), 'err');
if (cb) { cb(null); }
return;
}
if (cb) { cb(data); }
};
xhr.send(body ? JSON.stringify(body) : null);
}
function msg(text) {
var el = $('lo-msg');
if (el) { el.innerHTML = esc(text || ''); }
}
function currentPage() {
if (!S.cfg || !S.cfg.Pages || !S.cfg.Pages.length) { return null; }
if (S.page < 0 || S.page >= S.cfg.Pages.length) { S.page = 0; }
return S.cfg.Pages[S.page];
}
function normalizeConfig(data) {
if (!data || !data.Pages || !data.Pages.length) {
data = { ServerUrl: 'http://127.0.0.1:18111', AdminToken: '', ActivePage: 0,
Pages: [{ Name: '默认页', Schemes: [] }] };
}
if (!data.Pages[0].Schemes) { data.Pages[0].Schemes = []; }
S.page = parseInt(data.ActivePage, 10) || 0;
if (S.page < 0 || S.page >= data.Pages.length) { S.page = 0; }
return data;
}
function loadHotkeys() {
api('/loadout/hotkeys', 'GET', null, function (d) {
var el = $('lo-hotkeys');
if (!el) { return; }
if (!d || !d.data || !(d.data.schemes || []).length) {
el.innerHTML = '全局热键:当前页没有配置快捷键';
return;
}
var parts = [];
var schemes = d.data.schemes;
for (var i = 0; i < schemes.length; i++) {
var sc = schemes[i];
var mods = modText(sc.modifiers || 0);
mods.push(keyText(sc.keycode));
parts.push(esc(sc.name || '') + ' → ' + esc(mods.join('+')));
}
el.innerHTML = '全局热键(当前页):' + parts.join(' ');
});
}
function loadConfig(cb) {
api('/loadout/config', 'GET', null, function (d) {
if (!d) { msg('读取换装配置失败'); if (cb) { cb(false); } return; }
S.cfg = normalizeConfig(d.data || {});
renderPages();
renderSchemes();
loadHotkeys();
if (cb) { cb(true); }
});
}
function saveConfig(cb) {
if (!S.cfg) { return; }
S.cfg.ActivePage = S.page;
api('/loadout/config', 'POST', S.cfg, function (d) {
if (d) { msg('换装配置已保存'); log('换装配置已保存', 'ok'); loadHotkeys(); }
if (cb) { cb(!!d); }
});
}
function renderPages() {
var sel = $('lo-page');
if (!sel) { return; }
var pages = (S.cfg && S.cfg.Pages) || [];
var h = [];
for (var i = 0; i < pages.length; i++) {
h.push('<option value="' + i + '">' + esc(pages[i].Name || ('页 ' + (i + 1))) + '</option>');
}
sel.innerHTML = h.join('');
sel.value = String(S.page);
}
function renderSchemes() {
var box = $('lo-schemes');
if (!box) { return; }
var page = currentPage();
var schemes = (page && page.Schemes) || [];
if (!schemes.length) {
box.innerHTML = '<div class="lo-empty">当前页还没有方案</div>';
S.scheme = -1;
renderDetail();
return;
}
var h = [];
for (var i = 0; i < schemes.length; i++) {
var sc = schemes[i];
h.push('<div class="li' + (i === S.scheme ? ' on' : '') + '" data-i="' + i + '">' +
'<span class="nm">' + esc(sc.Name || ('方案 ' + (i + 1))) + '</span>' +
'<span class="lo-scheme-meta">' + esc(sc.CharacterName || '') +
' · ' + ((sc.Slots || []).length) + ' 件</span></div>');
}
box.innerHTML = h.join('');
var lis = box.getElementsByClassName('li');
for (var k = 0; k < lis.length; k++) {
lis[k].onclick = function () {
S.scheme = parseInt(this.getAttribute('data-i'), 10);
renderSchemes();
};
}
if (S.scheme < 0 || S.scheme >= schemes.length) { S.scheme = 0; }
var active = box.getElementsByClassName('li')[S.scheme];
if (active) { active.className = 'li on'; }
renderDetail();
}
function renderDetail() {
var page = currentPage();
var schemes = (page && page.Schemes) || [];
var detail = $('lo-detail');
var slotsBox = $('lo-slots');
if (!detail || !slotsBox) { return; }
var sc = schemes[S.scheme];
if (!sc) {
detail.innerHTML = '未选择方案';
slotsBox.innerHTML = '';
var emptyHotkey = $('lo-hotkey-text');
if (emptyHotkey) { emptyHotkey.innerHTML = ''; }
return;
}
detail.innerHTML = '<b>' + esc(sc.Name || '') + '</b>' +
'<span class="lo-scheme-meta">' + esc(sc.CharacterName || '未绑定角色') + '</span>';
var hotkeyTextEl = $('lo-hotkey-text');
if (hotkeyTextEl) { hotkeyTextEl.innerHTML = '快捷键:' + esc(hotkeyText(sc)); }
var slots = sc.Slots || [];
if (!slots.length) {
slotsBox.innerHTML = '<div class="lo-empty">该方案没有保存装备</div>';
return;
}
var h = [];
for (var i = 0; i < slots.length; i++) {
h.push('<div class="lo-slot">部位 <b>' + esc(slots[i].slot) + '</b>' +
'<span class="id">ID ' + esc(slots[i].item_id) + '</span></div>');
}
slotsBox.innerHTML = h.join('');
}
function hotkeyText(sc) {
if (!sc || !sc.KeyCode) { return '未设置'; }
var parts = modText(sc.Modifiers || 0);
parts.push(keyText(sc.KeyCode));
return parts.join('+');
}
function currentScheme() {
var page = currentPage();
var schemes = (page && page.Schemes) || [];
return schemes[S.scheme] || null;
}
function setHotkey() {
var sc = currentScheme();
if (!sc) { msg('请先选择方案'); return; }
S.capture = true;
msg('请按下快捷键,Esc 取消');
var handler = function (ev) {
ev = ev || window.event;
var code = ev.keyCode || ev.which;
if (code === 27) {
S.capture = false;
document.removeEventListener('keydown', handler, true);
msg('已取消设置快捷键');
return false;
}
if (!code || code === 16 || code === 17 || code === 18) {
return false;
}
var mods = 0;
if (ev.ctrlKey) { mods |= 2; }
if (ev.altKey) { mods |= 1; }
if (ev.shiftKey) { mods |= 4; }
sc.KeyCode = code;
sc.Modifiers = mods;
S.capture = false;
document.removeEventListener('keydown', handler, true);
saveConfig(function (ok) {
if (ok) {
renderDetail();
loadHotkeys();
msg('快捷键已保存:' + hotkeyText(sc));
}
});
if (ev.preventDefault) { ev.preventDefault(); }
return false;
};
document.addEventListener('keydown', handler, true);
}
function clearHotkey() {
var sc = currentScheme();
if (!sc) { msg('请先选择方案'); return; }
sc.KeyCode = 0;
sc.Modifiers = 0;
saveConfig(function (ok) {
if (ok) {
renderDetail();
loadHotkeys();
msg('快捷键已清除');
}
});
}
function addScheme() {
var page = currentPage();
if (!page) { return; }
var name = window.prompt('方案名称', '方案' + ((page.Schemes || []).length + 1));
if (!name) { return; }
var character = window.prompt('绑定角色游戏内名称', S.charName || '') || '';
page.Schemes = page.Schemes || [];
page.Schemes.push({
Name: String(name),
CharacterName: String(character),
Modifiers: 0,
KeyCode: 0,
Slots: []
});
S.scheme = page.Schemes.length - 1;
saveConfig(function (ok) {
if (ok) {
renderSchemes();
msg('已新增方案:' + name);
}
});
}
function renameScheme() {
var sc = currentScheme();
if (!sc) { msg('请先选择方案'); return; }
var name = window.prompt('重命名方案', sc.Name || '');
if (!name) { return; }
sc.Name = String(name);
saveConfig(function (ok) {
if (ok) {
renderSchemes();
msg('方案已重命名');
}
});
}
function deleteScheme() {
var page = currentPage();
var sc = currentScheme();
if (!page || !sc) { msg('请先选择方案'); return; }
if (!window.confirm('删除方案“' + (sc.Name || '') + '”?')) { return; }
page.Schemes.splice(S.scheme, 1);
S.scheme = -1;
saveConfig(function (ok) {
if (ok) {
renderSchemes();
msg('方案已删除');
}
});
}
function loadChars() {
api('/loadout/characters', 'GET', null, function (d) {
if (!d) { msg('角色读取失败,确认服务端已启动'); return; }
S.chars = d.data || [];
var sel = $('lo-char');
if (!sel) { return; }
var old = S.charName;
var h = ['<option value="">选择角色</option>'];
for (var i = 0; i < S.chars.length; i++) {
var c = S.chars[i];
h.push('<option value="' + esc(c.name) + '">' + esc(c.name) +
'(Lv.' + esc(c.level) + (c.online ? ' · 在线' : '') + ')</option>');
}
sel.innerHTML = h.join('');
if (old) { sel.value = old; }
if (!sel.value && S.chars.length === 1) { sel.value = S.chars[0].name; }
S.charName = sel.value || '';
msg('角色 ' + S.chars.length + ' 个');
});
}
function fetchEquipment(characterName, cb) {
api('/local/dnf-gm/loadout/equipment?character_id=' + encodeURIComponent(characterName), 'GET', null, function (d) {
if (!d) { cb(null); return; }
cb((d.data && d.data.slots) || []);
});
}
function saveCurrent() {
if (!S.charName) { msg('请先选择角色'); return; }
if (!S.cfg) { return; }
msg('正在读取当前装备…');
fetchEquipment(S.charName, function (slots) {
if (!slots) { msg('读取当前装备失败'); return; }
if (!slots.length) { msg('当前没有可保存的装备'); return; }
var name = window.prompt('方案名称', S.charName + ' 当前装备');
if (!name) { msg('已取消'); return; }
var page = currentPage();
if (!page) { return; }
if (!page.Schemes) { page.Schemes = []; }
page.Schemes.push({
Name: String(name),
CharacterName: S.charName,
Modifiers: 0,
KeyCode: 0,
Slots: slots
});
S.scheme = page.Schemes.length - 1;
saveConfig(function (ok) {
if (ok) { renderSchemes(); msg('已保存方案:' + name); }
});
});
}
function cooldownRemaining(name) {
var until = S.cooldown[name] || 0;
return Math.max(0, Math.ceil((until - Date.now()) / 1000));
}
function tickCooldown() {
var el = $('lo-cooldown');
var page = currentPage();
var schemes = (page && page.Schemes) || [];
var sc = schemes[S.scheme];
if (!el) { return; }
if (!sc) { el.innerHTML = ''; return; }
var remain = cooldownRemaining(sc.CharacterName || '');
el.innerHTML = remain > 0 ? ('换装冷却 ' + remain + ' 秒') : '';
}
function applyScheme() {
var page = currentPage();
var schemes = (page && page.Schemes) || [];
var sc = schemes[S.scheme];
if (!sc) { msg('请先选择方案'); return; }
if (!sc.CharacterName || !(sc.Slots || []).length) { msg('方案没有角色或装备'); return; }
var remain = cooldownRemaining(sc.CharacterName);
if (remain > 0) { msg('换装冷却中,剩余 ' + remain + ' 秒'); return; }
msg('正在应用方案…');
api('/local/dnf-gm/loadout/apply', 'POST', { character_id: sc.CharacterName, slots: sc.Slots }, function (d) {
if (!d) { msg('应用失败'); return; }
var r = d.data || {};
var applied = (r.applied || []).length;
var skipped = (r.skipped || []).length;
var sec = parseInt(r.cooldown_seconds, 10) || 0;
if (sec > 0) {
S.cooldown[sc.CharacterName] = Date.now() + sec * 1000;
}
msg('应用完成:成功 ' + applied + ' 件,跳过 ' + skipped + ' 件' +
(sec > 0 ? (',冷却 ' + sec + ' 秒') : ''));
log('换装 ' + sc.Name + ':成功 ' + applied + ',跳过 ' + skipped, 'ok');
tickCooldown();
});
}
function addPage() {
var name = window.prompt('新换装页名称', '新页面');
if (!name || !S.cfg) { return; }
S.cfg.Pages.push({ Name: String(name), Schemes: [] });
S.page = S.cfg.Pages.length - 1;
S.scheme = -1;
saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } });
}
function renamePage() {
var page = currentPage();
if (!page || !S.cfg) { return; }
var name = window.prompt('重命名换装页', page.Name || '');
if (!name) { return; }
page.Name = String(name);
saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } });
}
function deletePage() {
if (!S.cfg || S.cfg.Pages.length <= 1) { msg('至少保留一个换装页'); return; }
var page = currentPage();
if (!page) { return; }
if (!window.confirm('删除换装页「' + page.Name + '」及其方案?')) { return; }
S.cfg.Pages.splice(S.page, 1);
S.page = Math.max(0, S.page - 1);
S.scheme = -1;
saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } });
}
function bind() {
var sel = $('lo-page');
if (sel) {
sel.onchange = function () {
S.page = parseInt(this.value, 10) || 0;
S.scheme = -1;
if (S.cfg) { S.cfg.ActivePage = S.page; }
renderSchemes();
loadHotkeys();
// The launcher hotkey worker reads ActivePage from config.json. Persist
// the page switch here, otherwise a hotkey assigned on this page never
// becomes the active page's hotkey.
saveConfig(function (ok) {
if (ok) { loadHotkeys(); }
});
};
}
var ch = $('lo-char');
if (ch) { ch.onchange = function () { S.charName = this.value; }; }
var b;
if ((b = $('lo-refresh'))) { b.onclick = function () { loadChars(); }; }
if ((b = $('lo-save'))) { b.onclick = saveCurrent; }
if ((b = $('lo-apply'))) { b.onclick = applyScheme; }
if ((b = $('lo-page-add'))) { b.onclick = addPage; }
if ((b = $('lo-page-ren'))) { b.onclick = renamePage; }
if ((b = $('lo-page-del'))) { b.onclick = deletePage; }
if ((b = $('lo-scheme-add'))) { b.onclick = addScheme; }
if ((b = $('lo-scheme-ren'))) { b.onclick = renameScheme; }
if ((b = $('lo-scheme-del'))) { b.onclick = deleteScheme; }
if ((b = $('lo-hotkey'))) { b.onclick = setHotkey; }
if ((b = $('lo-hotkey-clear'))) { b.onclick = clearHotkey; }
}
function loadTab() {
msg('正在读取换装配置…');
loadConfig(function (ok) {
if (!ok) { return; }
loadChars();
if (!S.timer) { S.timer = setInterval(tickCooldown, 500); }
});
}
window.LoadoutTab = loadTab;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bind);
} else {
bind();
}
// 通过 #loadout 直接打开时,app.js 可能已经在 loadout.js 之前切过页
if ((location.hash || '').replace('#', '') === 'loadout') {
setTimeout(loadTab, 0);
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+11
View File
@@ -0,0 +1,11 @@
这个目录存放三张二维码图片,文件名与页面一一对应(已配置好,直接覆盖同名文件即可换图):
alipay.png 支付宝收款码 (关于页 · 赞助我们 · 左)
wechat.png 微信收款码 (关于页 · 赞助我们 · 中)
gzh.jpg 团队公众号二维码 (关于页 · 团队公众号 · 右)
说明:
- 三张图都按正方形显示(页面 132x132 等比缩放),原图 600x600 / 430x430 都正常,非正方形会被拉伸变形
- 支持 png / jpg / jpeg / gif / bmp / webp;若换成别的后缀,需同步改 gmweb\index.html 里对应 <img src="/qr/xxx"> 的后缀
- 换图后刷新控制台页面(标题栏刷新按钮或 Ctrl+F5)就能看到,不用重启服务
- 图片缺失时,关于页会显示「二维码未配置 + 文件路径」的占位,不影响其他功能
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""90CN GM 页本地托管 + 接口代理。
为什么需要这一层:
1. 客户端里那个窗口是 IE11 内核,页面要同源才能发 XHR,跨域到 18111 会被拦;
2. admin token 不应该写进页面 JS(会被任何人读到),由本进程在服务端注入。
用法:
python serve.py # 默认 127.0.0.1:18112
python serve.py --port 18112
python serve.py --runtime Z:\\90server\\Server\\runtime
页面访问:http://127.0.0.1:18112/
接口转发:/api/<path> -> http://127.0.0.1:18111/<path>
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib import error, request
HERE = Path(__file__).resolve().parent
MIME = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
}
CONFIG = {"admin": "127.0.0.1:18111", "token": ""}
# 关键:本机回环不能走系统/环境代理。环境里存在 http_proxy 时 urllib 会照单全收,
# 结果连 127.0.0.1:18111 都被转发到代理,报 "upstream connect failed"。
OPENER = request.build_opener(request.ProxyHandler({}))
# --------------------------------------------------------------------- 发现
def _candidate_runtimes() -> list[Path]:
out: list[Path] = []
env = os.environ.get("DNF90_RUNTIME")
if env:
out.append(Path(env))
out += [
Path(r"Z:\90server\Server\runtime"),
HERE.parent / "server" / "runtime",
HERE.parent / "runtime",
]
return out
def discover_admin() -> tuple[str, str]:
"""从 instance.json / dnfbridge.toml 里读 adminListen 与 adminToken。"""
token_env = os.environ.get("DNF90_ADMIN_TOKEN")
for root in _candidate_runtimes():
inst = root / "config" / "instance.json"
if inst.is_file():
try:
cfg = json.loads(inst.read_text(encoding="utf-8"))
srv = cfg.get("server", {})
listen = srv.get("adminListen") or CONFIG["admin"]
token = token_env or srv.get("adminToken") or ""
if token:
return listen, token
except (OSError, ValueError):
pass
toml = root / "configs" / "dnfbridge.toml"
if toml.is_file():
try:
txt = toml.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
m = re.search(r'^\s*listen\s*=\s*"([^"]+)"', txt, re.M)
listen = m.group(1) if m else CONFIG["admin"]
m = re.search(r'^\s*token\s*=\s*"([^"]+)"', txt, re.M)
token = token_env or (m.group(1) if m else "")
if token:
return listen, token
return CONFIG["admin"], token_env or ""
# --------------------------------------------------------------------- 代理
def proxy(method: str, path: str, body: bytes | None) -> tuple[int, bytes, str]:
url = "http://%s%s" % (CONFIG["admin"], path)
headers = {"X-Admin-Token": CONFIG["token"]}
if method == "POST":
headers["Content-Type"] = "application/json"
# 服务端对写操作要求确认头 + 幂等键
headers["X-Admin-Confirm"] = "gmweb"
headers["X-Admin-Idempotency-Key"] = str(uuid.uuid4())
req = request.Request(url, data=body, headers=headers, method=method)
try:
with OPENER.open(req, timeout=20) as resp:
return resp.status, resp.read(), resp.headers.get("Content-Type", "application/json")
except error.HTTPError as exc:
return exc.code, exc.read(), exc.headers.get("Content-Type", "application/json")
except error.URLError as exc:
msg = json.dumps({"success": False, "message": "服务端未响应:%s" % exc.reason},
ensure_ascii=False).encode()
return 502, msg, "application/json; charset=utf-8"
# --------------------------------------------------------------------- 换装配置
def _loadout_config_path() -> Path:
"""LoadoutTool/config.json,优先部署根目录,其次运行目录。"""
roots = [HERE.parent]
env = os.environ.get("DNF90_SERVER_ROOT")
if env:
roots.insert(0, Path(env))
for root in roots:
p = root / "LoadoutTool" / "config.json"
if p.is_file():
return p
return roots[0] / "LoadoutTool" / "config.json"
def _default_loadout_config() -> dict:
return {
"ServerUrl": "http://127.0.0.1:18111",
"AdminToken": "",
"Pages": [{"Name": "默认页", "Schemes": []}],
"ActivePage": 0,
}
def _read_loadout_config() -> dict:
p = _loadout_config_path()
try:
if p.is_file():
data = json.loads(p.read_text(encoding="utf-8-sig"))
if isinstance(data, dict):
if not isinstance(data.get("Pages"), list) or not data["Pages"]:
data["Pages"] = [{"Name": "默认页", "Schemes": []}]
data["ActivePage"] = max(0, min(int(data.get("ActivePage") or 0), len(data["Pages"]) - 1))
# AdminToken 只留在本地文件,网页端永远拿不到。
data["AdminToken"] = ""
return data
except (OSError, ValueError, TypeError):
pass
return _default_loadout_config()
def _write_loadout_config(data: dict) -> tuple[int, bytes, str]:
if not isinstance(data, dict):
return 400, b'{"ok":false,"error":"config must be an object"}', "application/json"
pages = data.get("Pages")
if not isinstance(pages, list) or not pages:
return 400, b'{"ok":false,"error":"Pages must be a non-empty list"}', "application/json"
p = _loadout_config_path()
try:
p.parent.mkdir(parents=True, exist_ok=True)
# 网页提交的配置里没有 token;保留磁盘上原来的 token。
existing = {}
if p.is_file():
try:
existing = json.loads(p.read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
existing = {}
data = dict(data)
data["AdminToken"] = existing.get("AdminToken", "") if isinstance(existing, dict) else ""
tmp = p.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(p)
except OSError as exc:
return 500, json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False).encode(), "application/json"
return 200, b'{"ok":true}', "application/json"
def _loadout_hotkeys() -> dict:
"""Return the active page's configured global hotkeys for the UI hint."""
cfg = _read_loadout_config()
pages = cfg.get("Pages") or []
try:
page = pages[int(cfg.get("ActivePage") or 0)]
except (IndexError, TypeError, ValueError):
page = pages[0] if pages else {}
out = []
for scheme in (page.get("Schemes") or []):
keycode = int(scheme.get("KeyCode") or 0)
if keycode <= 0:
continue
out.append({
"name": scheme.get("Name") or "",
"character": scheme.get("CharacterName") or "",
"keycode": keycode,
"modifiers": int(scheme.get("Modifiers") or 0),
})
return {"page": page.get("Name") or "", "schemes": out}
def _loadout_characters() -> tuple[int, bytes, str]:
"""Character list for the loadout page.
The admin endpoint without a selector is bound to the logged-in account and
can be empty when the game client is still at the login screen. The
launcher's local DB API already exposes the full local character list, so
reuse it here and keep the loadout page usable before entering the game.
"""
try:
req = request.Request("http://127.0.0.1:19527/api/characters",
headers={"Accept": "application/json"})
with OPENER.open(req, timeout=8) as resp:
data = json.loads(resp.read().decode("utf-8"))
chars = data.get("data") if isinstance(data, dict) else None
out = []
for c in chars or []:
if not isinstance(c, dict):
continue
out.append({
"character_id": str(c.get("character_id") or ""),
"name": c.get("name") or "",
"level": c.get("level") or 0,
"online": bool(c.get("online")),
})
return 200, json.dumps({"ok": True, "data": out}, ensure_ascii=False).encode(), "application/json; charset=utf-8"
except (OSError, ValueError) as exc:
return 502, json.dumps({"ok": False, "error": "读取角色失败:%s" % exc},
ensure_ascii=False).encode(), "application/json; charset=utf-8"
# --------------------------------------------------------------------- HTTP
class Handler(BaseHTTPRequestHandler):
server_version = "90CN-GMWeb/1.0"
def log_message(self, fmt, *args): # 静默,避免刷屏
pass
def _send(self, code: int, payload: bytes, ctype: str) -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
try:
self.wfile.write(payload)
except OSError:
pass
def _static(self, path: str) -> None:
rel = "index.html" if path in ("/", "") else path.lstrip("/")
target = (HERE / rel).resolve()
if HERE not in target.parents and target != HERE:
self._send(403, b"forbidden", "text/plain")
return
if not target.is_file():
self._send(404, b"not found", "text/plain")
return
self._send(200, target.read_bytes(), MIME.get(target.suffix.lower(), "application/octet-stream"))
def do_GET(self) -> None:
route = self.path.split("?", 1)[0]
if route == "/api/loadout/config":
payload = json.dumps({"ok": True, "data": _read_loadout_config()}, ensure_ascii=False).encode()
self._send(200, payload, "application/json; charset=utf-8")
return
if route == "/api/loadout/characters":
code, payload, ctype = _loadout_characters()
self._send(code, payload, ctype)
return
if route == "/api/loadout/hotkeys":
payload = json.dumps({"ok": True, "data": _loadout_hotkeys()}, ensure_ascii=False).encode()
self._send(200, payload, "application/json; charset=utf-8")
return
if self.path.startswith("/api/"):
code, data, ctype = proxy("GET", self.path[4:], None)
self._send(code, data, ctype)
return
self._static(route)
def do_POST(self) -> None:
route = self.path.split("?", 1)[0]
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b"{}"
if route == "/api/loadout/config":
try:
data = json.loads(body.decode("utf-8"))
except ValueError:
self._send(400, b'{"ok":false,"error":"invalid json"}', "application/json")
return
code, payload, ctype = _write_loadout_config(data)
self._send(code, payload, ctype)
return
if not self.path.startswith("/api/"):
self._send(404, b"not found", "text/plain")
return
code, data, ctype = proxy("POST", self.path[4:], body)
self._send(code, data, ctype)
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description="90CN GM 页本地托管 + 接口代理")
ap.add_argument("--port", type=int, default=18112)
ap.add_argument("--host", default="127.0.0.1")
args = ap.parse_args(argv)
listen, token = discover_admin()
CONFIG["admin"], CONFIG["token"] = listen, token
if not token:
print("警告:没找到 adminToken,接口会 401。可用 DNF90_ADMIN_TOKEN 环境变量指定。", file=sys.stderr)
srv = ThreadingHTTPServer((args.host, args.port), Handler)
print("GM 页: http://%s:%d/" % (args.host, args.port))
print("代理到: http://%s/api/ -> http://%s/" % (args.host + ":" + str(args.port), listen))
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+10
View File
@@ -0,0 +1,10 @@
@echo off
rem 启动 GM 页本地服务(127.0.0.1:18112)。pythonw 不留黑窗口。
rem token 与 admin 端口由 serve.py 自动从 runtime\config\instance.json 读取。
setlocal
cd /d "%~dp0"
set PYW=pythonw.exe
where pythonw.exe >nul 2>nul || set PYW=python.exe
start "" %PYW% "%~dp0serve.py" --port 18112
echo 已在后台启动:http://127.0.0.1:18112/
timeout /t 2 >nul
+179
View File
@@ -0,0 +1,179 @@
/* IE11 兼容:不用 CSS 变量、不用 flex、不用 grid。深色底 + 高对比文字。 */
html, body {
margin: 0; padding: 0; height: 100%;
background: #14161c; color: #d8dbe2;
font: 12px/1.5 "Microsoft YaHei", "SimSun", Tahoma, sans-serif;
overflow: hidden;
}
/* 顶部一行:左边页面切换,右边连接状态(绿点 + 文字) */
#topbar {
height: 32px; padding-left: 4px;
background: #191c24; border-bottom: 1px solid #2b3040;
overflow: hidden;
}
#tabs { float: left; }
#hd-s {
float: right; height: 31px; line-height: 31px;
padding: 0 12px; color: #8d94a6;
border-left: 1px solid #262b36;
}
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 4px; margin-right: 6px; }
.dot.on { background: #4ec46a; }
.dot.off { background: #b04a4a; }
.tab {
display: block; float: left; padding: 0 14px;
height: 29px; line-height: 29px;
color: #98a0b3;
text-decoration: none; border-bottom: 2px solid transparent;
}
.tab:hover { color: #d8dbe2; }
.tab.on { color: #e8c979; border-bottom-color: #e8c979; background: #20242e; }
#body { position: absolute; top: 33px; bottom: 71px; left: 0; right: 0; overflow: auto; }
.pane { display: none; padding: 10px; }
.pane.on { display: block; }
table.kv { width: 100%; border-collapse: collapse; }
table.kv th {
text-align: left; width: 84px; color: #8d94a6; font-weight: normal;
padding: 4px 0; border-bottom: 1px solid #232734;
}
table.kv td { padding: 4px 0; border-bottom: 1px solid #232734; color: #dfe3ea; }
.sec {
margin: 12px 0 6px; color: #e8c979; font-size: 11px;
border-left: 3px solid #e8c979; padding-left: 6px; letter-spacing: 1px;
}
.cur {
padding: 8px; background: #1c2029; border: 1px solid #2b3040;
color: #9fd8a8;
}
.row { margin: 6px 0; }
.inp {
background: #0f1116; border: 1px solid #2f3547; color: #dfe3ea;
padding: 4px 6px; font: 12px "Microsoft YaHei", Tahoma, sans-serif;
width: 240px; outline: none;
/* 内嵌 IE 控件里输入法默认是关的,这里强制激活,否则只能打英文 */
ime-mode: active;
}
.inp:focus { border-color: #e8c979; ime-mode: active; }
input[type="text"], textarea { ime-mode: active; }
/* 搜索框用 textarea:MSHTML 里它的 IME 走编辑引擎,比 input 更容易触发候选框。
单行用法,所以要自己压高度、禁掉拉伸和平滚动条。 */
.inp.ta {
height: 26px; line-height: 18px; resize: none; overflow: hidden;
padding: 3px 6px; vertical-align: middle;
}
.inp.sm { width: 110px; }
.inp.xs { width: 60px; }
.inp.xs2 { width: 84px; }
select.inp { padding: 3px 2px; }
.inp[disabled] { opacity: .4; }
.lb { display: inline-block; width: 56px; color: #8d94a6; }
.btn {
background: #262c3a; border: 1px solid #39415a; color: #d8dbe2;
padding: 5px 12px; cursor: pointer; font: 12px "Microsoft YaHei", Tahoma, sans-serif;
margin-right: 6px;
}
.btn:hover { background: #303849; border-color: #4d5877; }
.btn.pri { background: #6b4f18; border-color: #a3781f; color: #ffe6ae; }
.btn.pri:hover { background: #7d5d1e; }
.btn[disabled] { opacity: .45; cursor: default; }
.list {
margin-top: 8px; max-height: 190px; overflow: auto;
border: 1px solid #2b3040; background: #101319;
}
/* 物品列表固定 6 行高度:行数再多也只占这一块,靠滚动 + 翻页看更多 */
.list.fixed { height: 156px; max-height: 156px; }
.li {
padding: 3px 8px; line-height: 20px; border-bottom: 1px solid #1c212b; cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.li:hover { background: #1b2130; }
.li.on { background: #2a3550; color: #ffe6ae; }
.li .nm { color: #dfe3ea; }
.li .meta { color: #7c8496; font-size: 11px; margin-left: 6px; }
.empty { padding: 12px; color: #666e80; text-align: center; }
.dim { color: #7c8496; }
.pager { margin: 6px 0 2px; }
.btn.sm { padding: 3px 10px; font-size: 11px; }
.pg { color: #8d94a6; font-size: 11px; margin: 0 8px; }
#log {
position: absolute; bottom: 0; left: 0; right: 0; height: 62px;
background: #0d0f14; border-top: 1px solid #2b3040;
padding: 4px 8px; overflow: auto; font: 11px/1.45 Consolas, monospace;
color: #8d94a6;
}
#log .ok { color: #6fcf87; }
#log .err { color: #e07070; }
#log .tip { color: #e8c979; }
.hint { color: #666e80; font-size: 11px; margin-top: 6px; }
/* 一键换装:IE11 兼容,float 两栏 */
.lo-wrap { margin-top: 4px; overflow: hidden; }
.lo-col { float: left; box-sizing: border-box; }
.lo-left { width: 42%; padding-right: 10px; }
.lo-right { width: 58%; border-left: 1px solid #2b3040; padding-left: 10px; min-height: 260px; }
.lo-slots { margin-top: 6px; border: 1px solid #2b3040; background: #101319; max-height: 166px; overflow: auto; }
.lo-slot { padding: 3px 8px; border-bottom: 1px solid #1c212b; font-size: 11px; color: #9aa2b4; }
.lo-slot b { color: #dfe3ea; font-weight: normal; }
.lo-slot .id { color: #e8c979; margin-left: 8px; }
.lo-scheme-meta { color: #7c8496; font-size: 11px; margin-left: 6px; }
.lo-empty { padding: 10px; color: #666e80; text-align: center; }
#lo-cooldown { margin-left: 8px; }
/* 背包页:刚发进去那一行的「新」标记 */
.li .new {
font-style: normal; font-size: 11px; padding: 0 4px; margin-left: 4px;
background: #e8c979; color: #1a1e27; border-radius: 2px;
}
/* 关于页:整体是一列居中内容(介绍卡片 + 组合面板),宽度固定 692 */
/* 关于页整块在可视区里垂直居中:绝对定位 + translateY(IE11 支持 transform) */
#pane-about.on { display: block; position: relative; height: 100%; box-sizing: border-box; }
#pane-about .about-inner {
position: absolute; top: 50%; left: 10px; right: 10px;
-ms-transform: translateY(-50%); transform: translateY(-50%);
}
.about-card {
width: 712px; margin: 0 auto; padding: 10px 12px; box-sizing: border-box;
background: #1a1e27; border: 1px solid #2b3040;
}
.about-name { color: #e8c979; font-size: 14px; letter-spacing: 1px; }
.about-line { color: #8d94a6; margin-top: 4px; }
.box { margin-top: 12px; border: 1px solid #2b3040; background: #171b23; }
/* 三个分组框合成一个整体面板:外框统一,中间竖线分隔 */
.cols {
width: 712px; margin: 12px auto 0;
border: 1px solid #2b3040; background: #171b23; overflow: hidden;
}
.cols .box { margin-top: 0; min-height: 264px; float: left; border: 0; background: transparent; box-sizing: border-box; }
.cols .box-l { width: 353px; }
.cols .box-m { width: 178px; border-left: 1px solid #3f4759; background: #14171d; }
.cols .box-r { width: 179px; border-left: 1px solid #3f4759; background: #14171d; }
.box-hd {
height: 26px; line-height: 26px; padding: 0 10px;
background: #1d2230; border-bottom: 1px solid #2b3040;
color: #e8c979; letter-spacing: 1px;
}
.box-bd { padding: 10px; overflow: hidden; }
.box-note { clear: both; color: #666e80; font-size: 11px; padding-top: 8px; }
.qr-card {
float: left; width: 158px; margin: 0 8px 0 0; box-sizing: border-box;
background: #1a1e27; border: 1px solid #2b3040; padding: 8px;
}
.qr-title { color: #e8c979; letter-spacing: 1px; margin-bottom: 8px; }
.qr-box {
width: 140px; height: 140px; position: relative; overflow: hidden; box-sizing: border-box;
background: #0f1116; border: 1px dashed #3a4154; padding: 3px; text-align: center;
}
.qr-box img { display: block; width: 132px; height: 132px; background: #fff; }
.qr-miss { display: none; padding-top: 44px; color: #666e80; line-height: 1.8; }
.qr-box.miss .qr-miss { display: block; }
.qr-path { color: #4c5365; font-size: 11px; margin-top: 2px; }
+3 -1
View File
@@ -1,7 +1,9 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all from PyInstaller.utils.hooks import collect_all
datas = [('index.html', '.'), ('style.css', '.'), ('shell.css', '.'), ('app.js', '.'), ('shell.js', '.'), ('sponsor.png', '.'), ('db.py', '.'), ('native.py', '.'), ('launcher.ico', '.')] datas = [('index.html', '.'), ('style.css', '.'), ('shell.css', '.'), ('app.js', '.'), ('shell.js', '.'), ('sponsor.png', '.'), ('db.py', '.'), ('native.py', '.'), ('launcher.ico', '.'),
('qr/alipay.png', 'qr'), ('qr/wechat.png', 'qr'), ('qr/gzh.jpg', 'qr'),
('qr/qq.png', 'qr')]
binaries = [] binaries = []
hiddenimports = ['clr_loader', 'pythonnet'] hiddenimports = ['clr_loader', 'pythonnet']
tmp_ret = collect_all('webview') tmp_ret = collect_all('webview')
+10
View File
@@ -18,6 +18,16 @@
| `index.html` / `style.css` / `app.js` | 双视图前端 | | `index.html` / `style.css` / `app.js` | 双视图前端 |
| `DNF90启动器.spec` | PyInstaller 打包配置 | | `DNF90启动器.spec` | PyInstaller 打包配置 |
| `sponsor.png` / `sponsor_qr.png` | 赞助二维码 | | `sponsor.png` / `sponsor_qr.png` | 赞助二维码 |
| `qr/` | 关于页二维码(alipay / wechat / gzh / qq,可被 exe 同目录的同名文件覆盖) |
| `启动器日志.txt` | 运行日志(运行时生成,exe 同目录,超过 512KB 转 `.old.txt`) |
## 运行时约定
- **单实例**:命名互斥量 `Local\DNF90Launcher-5E9C1B7A`,重复启动只会把已有窗口提到前台
- **多调用模式**:`DNF90启动器.exe <xxx.py> [args]` 会用 `runpy` 直接执行该脚本。打包后 `sys.executable` 就是 exe 自己,`start_gmweb()` 正是靠这条路径拉起 `gmweb/serve.py` 的
- **提权**:UAC 自提权重启会带上原始参数;`DNF90_GMWEB_CHILD=1` 的子进程不再提权
- **WebView2 数据目录**:固定在 `%LOCALAPPDATA%\DNF90Launcher\WebView2`,避免 exe 在只读目录/网络盘时窗口空白
- 排障与现场取证见 [`docs/启动器窗口风暴排查.md`](../docs/启动器窗口风暴排查.md)
## 打包 ## 打包
+80 -94
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>90轮回</title> <title>DNF90 灵宝小竹单机版</title>
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="/shell.css"> <link rel="stylesheet" href="/shell.css">
</head> </head>
@@ -22,18 +22,16 @@
<header class="shell-topnav"> <header class="shell-topnav">
<div class="shell-brand"> <div class="shell-brand">
<div class="shell-logo">90</div> <div class="shell-logo">90</div>
<div><b>90轮回</b><span>启动器 <em>v2.1.0</em></span></div> <div><b>灵宝小竹</b><span>启动器 <em>v2.1.0</em></span></div>
</div> </div>
<nav class="shell-nav"> <nav class="shell-nav">
<button class="shell-nav-item active" data-shell-page="start"><span class="nav-ico">▶</span>开始</button> <button class="shell-nav-item active" data-shell-page="start"><span class="nav-ico">▶</span>开始</button>
<button class="shell-nav-item" data-shell-page="changelog"><span class="nav-ico">📜</span>游戏更新</button>
<button class="shell-nav-item" data-shell-page="gm"><span class="nav-ico">⚙</span>GM工具</button> <button class="shell-nav-item" data-shell-page="gm"><span class="nav-ico">⚙</span>GM工具</button>
<button class="shell-nav-item" data-shell-page="loadout"><span class="nav-ico">🔄</span>一键换装</button> <button class="shell-nav-item" data-shell-page="loadout"><span class="nav-ico">🔄</span>一键换装</button>
<button class="shell-nav-item" data-shell-page="servertool"><span class="nav-ico">🧰</span>服务端工具</button> <button class="shell-nav-item" data-shell-page="gamesettings"><span class="nav-ico">🎮</span>游戏设置</button>
<button class="shell-nav-item" data-shell-page="companion"><span class="nav-ico">👥</span>支援兵</button> <button class="shell-nav-item" data-shell-page="about"><span class="nav-ico">💝</span>关于我们</button>
<button class="shell-nav-item" data-shell-page="plugins"><span class="nav-ico">🧩</span>插件</button>
<div class="shell-nav-gap"></div> <div class="shell-nav-gap"></div>
<button class="shell-nav-item" data-shell-page="settings"><span class="nav-ico">⚙</span>设置</button>
<button class="shell-nav-item" data-shell-page="changelog"><span class="nav-ico">📜</span>更新日志</button>
</nav> </nav>
<div class="shell-win-controls"> <div class="shell-win-controls">
<button class="tb-btn" id="shellWinMin" title="最小化">—</button> <button class="tb-btn" id="shellWinMin" title="最小化">—</button>
@@ -51,7 +49,7 @@
<div class="hero-bg"></div> <div class="hero-bg"></div>
<div class="hero-content"> <div class="hero-content">
<div class="hero-kicker">DNF · 90级 · 本地服务端</div> <div class="hero-kicker">DNF · 90级 · 本地服务端</div>
<h1 id="shellServerName">Server90轮回</h1> <h1 id="shellServerName">DNF90 灵宝小竹单机版</h1>
<div class="hero-status"> <div class="hero-status">
<span class="live-dot" id="shellStatusDot"></span> <span class="live-dot" id="shellStatusDot"></span>
<span id="shellStatusText">检测中…</span> <span id="shellStatusText">检测中…</span>
@@ -81,7 +79,7 @@
</div> </div>
<div class="stat-grid"> <div class="stat-grid">
<div class="stat-card"><div class="stat-label">版本</div><div class="stat-row"><span>本地</span><b id="statVersion">v2026.11.03</b></div><div class="stat-row"><span>远程最新</span><b class="green" id="statRemote">检查中</b></div><div class="stat-row"><span>状态</span><b class="green" id="statState">—</b></div></div> <div class="stat-card"><div class="stat-label">版本</div><div class="stat-row"><span>本地</span><b id="statVersion">v2026.11.05</b></div><div class="stat-row"><span>远程最新</span><b class="green" id="statRemote">检查中</b></div><div class="stat-row"><span>状态</span><b class="green" id="statState">—</b></div></div>
<div class="stat-card"><div class="stat-label">运行</div><div class="stat-row"><span>客户端</span><b id="statClient">—</b></div><div class="stat-row"><span>游戏端口</span><b id="statPorts">—</b></div><div class="stat-row"><span>资源</span><b id="statResource">—</b></div></div> <div class="stat-card"><div class="stat-label">运行</div><div class="stat-row"><span>客户端</span><b id="statClient">—</b></div><div class="stat-row"><span>游戏端口</span><b id="statPorts">—</b></div><div class="stat-row"><span>资源</span><b id="statResource">—</b></div></div>
<div class="stat-card"><div class="stat-label">数据</div><div class="stat-row"><span>PVF 文件</span><b id="statPvf">—</b></div><div class="stat-row"><span>客户端目录</span><b class="small" id="statClientDir">—</b></div><div class="stat-row"><span>版本记录</span><b id="statDb">—</b></div></div> <div class="stat-card"><div class="stat-label">数据</div><div class="stat-row"><span>PVF 文件</span><b id="statPvf">—</b></div><div class="stat-row"><span>客户端目录</span><b class="small" id="statClientDir">—</b></div><div class="stat-row"><span>版本记录</span><b id="statDb">—</b></div></div>
<div class="stat-card"><div class="stat-label">工具</div><div class="stat-row"><span>内置 GM</span><b class="green">已开启</b></div><div class="stat-row"><span>GM 端口</span><b id="statGmPort">18111</b></div><div class="stat-row"><span>接口端口</span><b id="statApiPort">19527</b></div></div> <div class="stat-card"><div class="stat-label">工具</div><div class="stat-row"><span>内置 GM</span><b class="green">已开启</b></div><div class="stat-row"><span>GM 端口</span><b id="statGmPort">18111</b></div><div class="stat-row"><span>接口端口</span><b id="statApiPort">19527</b></div></div>
@@ -111,98 +109,84 @@
</div> </div>
</section> </section>
<section class="shell-page" id="page-servertool"> <section class="shell-page" id="page-gamesettings">
<div class="page-pad"> <div class="page-pad">
<div class="page-heading"><div><h2>服务端工具</h2><p class="muted">按原 ServerTool 页签组织,配置写入 server-features.json / activity.json。</p></div><span class="tag" id="stSource">文件配置</span></div> <div class="page-heading"><div><h2>游戏设置</h2><p class="muted">调整服务端频道与活动;改动实时生效,无需重启。</p></div><button class="shell-btn" id="gsRefresh">刷新</button></div>
<div class="st-tabs" id="stTabs"> <div class="st-tabs" id="gsTabs">
<button class="st-tab active" data-st="global">全局游戏参数</button> <button class="st-tab active" data-gs="channels">频道开关</button>
<button class="st-tab" data-st="activity">在线活动</button> <button class="st-tab" data-gs="activities">活动开关</button>
<button class="st-tab" data-st="gameplay">游戏参数</button>
<button class="st-tab" data-st="growth">疲劳/成长</button>
<button class="st-tab" data-st="raid">团本配置</button>
<button class="st-tab" data-st="launch">启动控制</button>
</div> </div>
<div class="st-subtabs" id="stSubTabs"></div> <div id="gsChannelsPane">
<div class="st-toolbar"><button class="shell-btn primary" id="stSave">保存当前页</button><button class="shell-btn" id="stReload">重新读取</button><span id="stStatus" class="muted"></span></div> <div class="gs-summary muted" id="gsChannelSummary">正在加载频道…</div>
<div class="st-body" id="stBody"></div> <div class="gs-grid" id="gsChannelGrid"><div class="empty-box">加载中…</div></div>
</div>
</section>
<section class="shell-page" id="page-companion">
<div class="page-pad">
<div class="page-heading"><div><h2>支援兵</h2><p class="muted">按账号配置支援兵;配置只影响下一次进入副本。</p></div><button class="shell-btn primary" id="cpSave">保存配置</button></div>
<div class="cp-toolbar">
<label>账号<select id="cpAccount" class="shell-input"></select></label>
<label>出战角色<select id="cpOwner" class="shell-input"></select></label>
<button class="shell-btn" id="cpReload">刷新</button>
<span id="cpStatus" class="muted"></span>
</div> </div>
<div class="cp-flags"> <div id="gsActivitiesPane" style="display:none">
<label class="st-check"><input type="checkbox" id="cpEnabled">启用支援兵</label> <div class="gs-summary muted" id="gsActivitySummary">正在加载活动…</div>
<label class="st-check"><input type="checkbox" id="cpBalance">读取佩戴装备</label> <div class="gs-list" id="gsActivityList"><div class="empty-box">加载中…</div></div>
<label class="st-check"><input type="checkbox" id="cpProgression">启用成长推进</label>
<label class="st-check"><input type="checkbox" id="cpRandom">随机武器装备</label>
<label class="st-check"><input type="checkbox" id="cpBasicAttack">允许支援兵普攻</label>
</div>
<h3 class="cp-title">选择支援兵角色</h3>
<div class="cp-grid" id="cpGrid"><div class="empty-box">选择出战角色后加载支援兵列表</div></div>
<div class="cp-ai">
<div class="cp-ai-head"><h3>AI 策略</h3><select id="cpAISource" class="shell-input"></select></div>
<div class="st-form">
<label>行为模式<select id="cpAIBehavior"><option value="assist">辅助</option><option value="aggressive">好战</option></select></label>
<label>BUFF 优先级<select id="cpAIBuff"><option value="leader_first">优先队长</option><option value="assist_only">仅辅助</option><option value="off">关闭</option></select></label>
<label>职业连招<select id="cpAIComboPreset"><option value="auto">按职业自动</option><option value="sword_guard">鬼剑士:控制连招</option><option value="fighter_chain">格斗家:近身连招</option><option value="gunner_burst">神枪手:远程爆发</option><option value="mage_control">魔法师:范围控制</option><option value="priest_support">圣职者:辅助连招</option></select></label>
<label>范围技能最少目标<input id="cpAIAoe" type="number" min="2" max="6" step="1"></label>
<label class="st-check"><input type="checkbox" id="cpAIComboEnabled">启用连招</label>
<label class="st-check"><input type="checkbox" id="cpAIProtect">保护队长</label>
</div>
<details class="st-json">
<summary>高级调参:距离、追击、视野、重试和治疗阈值</summary>
<div class="st-form" id="cpAITuning">
<label>思考间隔(ms)<input data-cp-tuning="think_interval_ms" type="number" min="50" max="2000"></label>
<label>辅助攻击间隔(ms)<input data-cp-tuning="assist_attack_interval_ms" type="number" min="200" max="10000"></label>
<label>辅助横向范围<input data-cp-tuning="assist_leash_x" type="number" min="30" max="500"></label>
<label>辅助纵向范围<input data-cp-tuning="assist_leash_y" type="number" min="20" max="300"></label>
<label>追击范围<input data-cp-tuning="chase_range" type="number" min="20" max="1000"></label>
<label>视野范围<input data-cp-tuning="vision_range" type="number" min="100" max="20000"></label>
<label>目标黏性(ms)<input data-cp-tuning="target_stickiness_ms" type="number" min="0" max="10000"></label>
<label>队长治疗阈值(%)<input data-cp-tuning="heal_leader_percent" type="number" min="0" max="100"></label>
<label>自身治疗阈值(%)<input data-cp-tuning="heal_self_percent" type="number" min="0" max="100"></label>
<label>重试间隔(ms)<input data-cp-tuning="retry_ms" type="number" min="50" max="5000"></label>
<label>辅助重试(ms)<input data-cp-tuning="support_retry_ms" type="number" min="100" max="10000"></label>
<label>忙碌移动(ms)<input data-cp-tuning="busy_move_ms" type="number" min="100" max="10000"></label>
<label>卡住判定(ms)<input data-cp-tuning="stuck_ms" type="number" min="100" max="5000"></label>
<label>绕行等待(ms)<input data-cp-tuning="detour_ms" type="number" min="100" max="5000"></label>
<label>放弃目标(ms)<input data-cp-tuning="give_up_ms" type="number" min="500" max="20000"></label>
<label>跟随距离<input data-cp-tuning="follow_distance" type="number" min="20" max="300"></label>
</div>
</details>
<div class="st-note">AI 策略按角色保存;保存后下一次进入副本生效。</div>
</div>
<div class="st-note" id="cpHint">支援兵按账号隔离,只能选择同一账号下的其他角色。</div>
</div>
</section>
<section class="shell-page" id="page-plugins">
<div class="page-pad"><h2>插件</h2><div class="empty-box">插件目录:启动器扩展位,后续接入 LoadoutTool / ServerTool 之外的模块。</div></div>
</section>
<section class="shell-page" id="page-settings">
<div class="page-pad">
<div class="page-heading"><div><h2>设置</h2><p class="muted">当前启动器实际绑定的服务端、数据库和游戏端。</p></div><button class="shell-btn" id="setRefresh">刷新</button></div>
<div class="settings-grid">
<div class="settings-card"><div class="settings-label">服务端根目录</div><div class="settings-value" id="setServerRoot">—</div></div>
<div class="settings-card"><div class="settings-label">服务端控制</div><div class="settings-value" id="setControl">—</div></div>
<div class="settings-card"><div class="settings-label">数据库</div><div class="settings-value" id="setDb">—</div></div>
<div class="settings-card"><div class="settings-label">游戏客户端</div><div class="settings-value" id="setClient">—</div></div>
<div class="settings-card"><div class="settings-label">服务端状态</div><div class="settings-value" id="setRun">—</div></div>
<div class="settings-card"><div class="settings-label">Native API</div><div class="settings-value" id="setNative">—</div></div>
</div> </div>
</div> </div>
</section> </section>
<section class="shell-page" id="page-changelog"> <section class="shell-page" id="page-changelog">
<div class="page-pad"><h2>更新日志</h2><p class="muted">从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。</p><div id="changelogBody"></div></div> <div class="page-pad">
<div class="page-heading"><div><h2>游戏更新</h2><p class="muted">优先读取仓库(Lingbao/90CNServer)的发布记录,并用本地「更新日志.txt」补齐仓库还没发布的版本;断网时只显示本地记录。</p></div><button class="shell-btn" id="updRefresh">刷新</button></div>
<div class="st-tabs" id="updTabs">
<button class="st-tab active" data-upd="latest">当前更新</button>
<button class="st-tab" data-upd="history">历史更新 <span class="upd-count" id="updHistoryCount"></span></button>
</div>
<div id="updLatestPane"><div class="upd-body" id="updLatest"><div class="empty-box">正在获取更新日志…</div></div></div>
<div id="updHistoryPane" style="display:none"><div class="upd-body" id="updHistory"></div></div>
</div>
</section>
<section class="shell-page" id="page-about">
<div class="page-pad about-pad">
<div class="about-hero">
<h2>地下城与勇士 · 九零轮回单机</h2>
<p class="about-line">汉服电脑宅 · 灵宝小竹模拟器</p>
<p class="about-line">由灵宝仙尊联合淡竹茹开发,由汉服电脑宅团队开发维护</p>
<p class="about-line">本模拟器资源禁止倒卖,请到官方微信公众号或社群下载</p>
</div>
<div class="about-panel">
<div class="about-col about-col-l">
<div class="about-col-hd">赞助我们</div>
<div class="about-col-bd">
<figure class="about-qr" data-qr="支付宝">
<img src="/qr/alipay.png" alt="支付宝收款码">
<figcaption>支付宝</figcaption>
</figure>
<figure class="about-qr" data-qr="微信">
<img src="/qr/wechat.png" alt="微信收款码">
<figcaption>微信</figcaption>
</figure>
</div>
<div class="about-col-note">金额随意,感谢支持;扫码后备注游戏名,方便记录。</div>
</div>
<div class="about-col about-col-m">
<div class="about-col-hd">官方QQ群</div>
<div class="about-col-bd">
<figure class="about-qr" data-qr="官方QQ群">
<img src="/qr/qq.png" alt="官方QQ群二维码">
<figcaption>QQ群</figcaption>
</figure>
</div>
<div class="about-col-note">群号 1128511524</div>
</div>
<div class="about-col about-col-r">
<div class="about-col-hd">团队公众号</div>
<div class="about-col-bd">
<figure class="about-qr" data-qr="团队公众号">
<img src="/qr/gzh.jpg" alt="团队公众号二维码">
<figcaption>扫码关注</figcaption>
</figure>
</div>
<div class="about-col-note">更新公告 · 开服时间 · 玩法说明</div>
</div>
</div>
<p class="about-tip">点击任意二维码可放大查看;把同名图片放进启动器目录下的 qr 文件夹即可替换。</p>
</div>
</section> </section>
<section class="shell-page" id="page-gm"> <section class="shell-page" id="page-gm">
@@ -239,7 +223,9 @@
<div class="win-resize nw" data-edge="topleft"></div><div class="win-resize ne" data-edge="topright"></div> <div class="win-resize nw" data-edge="topleft"></div><div class="win-resize ne" data-edge="topright"></div>
<div class="win-resize sw" data-edge="bottomleft"></div><div class="win-resize se" data-edge="bottomright"></div> <div class="win-resize sw" data-edge="bottomleft"></div><div class="win-resize se" data-edge="bottomright"></div>
<div class="modal-mask hidden" id="sponsorModal"><div class="modal"><div class="modal-head"><span>赞助我们</span><button class="modal-close" id="btnCloseSponsor">×</button></div><div class="modal-body"><p class="sponsor-tip">感谢支持 DNF90 单机模拟器!</p><div class="qrcode-box" id="qrcodeBox"></div></div></div></div> <div class="modal-mask hidden" id="sponsorModal"><div class="modal"><div class="modal-head"><span>赞助我们</span><button class="modal-close" id="btnCloseSponsor">×</button></div><div class="modal-body"><p class="sponsor-tip">感谢支持 DNF90 灵宝小竹单机版!</p><div class="qrcode-box" id="qrcodeBox"></div></div></div></div>
<div class="qr-lightbox hidden" id="qrLightbox"><img id="qrLightboxImg" src="" alt=""><div class="qr-lightbox-tip" id="qrLightboxTip"></div></div>
<div class="modal-mask hidden" id="accountsModal"><div class="modal" style="width:520px"><div class="modal-head"><span>账号管理</span><button class="modal-close" id="btnCloseAccounts" onclick="document.getElementById('accountsModal').classList.add('hidden')">×</button></div><div class="modal-body" style="text-align:left"><div id="accountsList" style="margin-bottom:16px"></div><div class="accounts-add"><div class="accounts-add-title">添加新账号</div><div class="gm-form-row"><div class="gm-field"><label>用户名</label><input class="gm-input" id="newAccUser"></div><div class="gm-field"><label>密码</label><input class="gm-input" id="newAccPass" type="password"></div></div><button class="gm-btn gm-btn-primary" id="btnAddAccount">+ 添加账号</button></div></div></div></div> <div class="modal-mask hidden" id="accountsModal"><div class="modal" style="width:520px"><div class="modal-head"><span>账号管理</span><button class="modal-close" id="btnCloseAccounts" onclick="document.getElementById('accountsModal').classList.add('hidden')">×</button></div><div class="modal-body" style="text-align:left"><div id="accountsList" style="margin-bottom:16px"></div><div class="accounts-add"><div class="accounts-add-title">添加新账号</div><div class="gm-form-row"><div class="gm-field"><label>用户名</label><input class="gm-input" id="newAccUser"></div><div class="gm-field"><label>密码</label><input class="gm-input" id="newAccPass" type="password"></div></div><button class="gm-btn gm-btn-primary" id="btnAddAccount">+ 添加账号</button></div></div></div></div>
<script src="/app.js"></script> <script src="/app.js"></script>
+15 -9
View File
@@ -623,15 +623,21 @@ class NativeClient(object):
return self._unwrap(self._post("/local/dnf-gm/loadout/apply", { return self._unwrap(self._post("/local/dnf-gm/loadout/apply", {
"character_id": str(character_id), "slots": slots})) "character_id": str(character_id), "slots": slots}))
def get_server_features(self): # ---- 游戏设置:活动开关 / 频道开关(Native,服务端在线)--------
return self._unwrap(self._get("/dnf/gm/features")) def get_activity_switches(self):
"""GET /dnf/gm/activity-switches -> GMActivitySwitches"""
return self._unwrap(self._get("/dnf/gm/activity-switches"))
def save_server_features(self, features): def set_activity_switch(self, key, enabled):
return self._unwrap(self._post("/dnf/gm/features", features)) """POST /dnf/gm/activity-switches {key:str, enabled:bool}"""
return self._unwrap(self._post("/dnf/gm/activity-switches", {
"key": str(key), "enabled": bool(enabled)}))
def companion_state(self, character_id): def get_channel_switches(self):
qs = urlencode({"characterId": str(character_id)}) """GET /dnf/gm/channel-switches -> GMChannelSwitches"""
return self._unwrap(self._get("/local/companion?" + qs)) return self._unwrap(self._get("/dnf/gm/channel-switches"))
def companion_save(self, payload): def set_channel_switch(self, channel_id, enabled):
return self._unwrap(self._post("/local/companion", payload)) """POST /dnf/gm/channel-switch {channel_id:int, enabled:bool}"""
return self._unwrap(self._post("/dnf/gm/channel-switch", {
"channel_id": int(channel_id), "enabled": bool(enabled)}))
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+570 -244
View File
File diff suppressed because it is too large. Load diff
+85 -86
View File
@@ -38,10 +38,6 @@ button,input,select{font:inherit}
padding:0 18px;border-bottom:1px solid #14291f;color:var(--muted);font-size:12px; padding:0 18px;border-bottom:1px solid #14291f;color:var(--muted);font-size:12px;
cursor:move;user-select:none} cursor:move;user-select:none}
.shell-topbar button,.shell-topbar select,.shell-topbar input{cursor:pointer} .shell-topbar button,.shell-topbar select,.shell-topbar input{cursor:pointer}
.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:10px;margin-top:14px}
.settings-card{border:1px solid #1d3a2d;background:#0b1712;padding:12px 14px;border-radius:4px}
.settings-label{font-size:11px;color:#83a393;margin-bottom:5px}
.settings-value{font:11px/1.6 Consolas,"Microsoft YaHei",monospace;color:#d5e8de;word-break:break-all}
.shell-top-actions{display:flex;gap:8px;align-items:center} .shell-top-actions{display:flex;gap:8px;align-items:center}
.shell-top-actions select{background:#0c1b16;border:1px solid #25483a;color:#bfe4d2;padding:4px 8px;border-radius:2px} .shell-top-actions select{background:#0c1b16;border:1px solid #25483a;color:#bfe4d2;padding:4px 8px;border-radius:2px}
.tb-btn{width:28px;height:24px;border:1px solid #25483a;background:#0c1b16;color:#a8c7b6;cursor:pointer} .tb-btn{width:28px;height:24px;border:1px solid #25483a;background:#0c1b16;color:#a8c7b6;cursor:pointer}
@@ -165,32 +161,6 @@ button,input,select{font:inherit}
.lo-slot b{display:block;color:#d5ece0;font-weight:400;font-family:Consolas,monospace} .lo-slot b{display:block;color:#d5ece0;font-weight:400;font-family:Consolas,monospace}
/* server tool */ /* server tool */
.st-tabs{display:flex;flex-wrap:wrap;gap:2px;border-bottom:1px solid #244435;margin-bottom:10px}
.st-tab{background:none;border:1px solid transparent;border-bottom:0;color:#8dac9c;padding:8px 13px;cursor:pointer;font-size:13px}
.st-tab:hover{color:#d8eee3}
.st-tab.active{background:#10251c;border-color:#2a503d;color:#8fe5b8}
.st-subtabs{display:flex;gap:4px;margin:-2px 0 10px;padding:6px 0;border-bottom:1px solid #1d3a2d}
.st-subtab{background:transparent;border:1px solid transparent;color:#8dac9c;padding:3px 9px;cursor:pointer;font-size:11px;border-radius:5px}
.st-subtab.active{background:rgba(124,92,255,.16);border-color:rgba(124,92,255,.35);color:#dcd4ff}
.st-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.st-body{border:1px solid #1d3a2d;background:#0b1712;padding:14px;min-height:300px}
.st-section{margin-bottom:16px}
.st-section h3{margin:0 0 8px;color:#9cd8b8;font-size:15px;font-weight:400;border-left:2px solid #4d9f77;padding-left:8px}
.st-form{display:grid;grid-template-columns:repeat(2,minmax(260px,1fr));gap:12px 18px}
.st-form label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:#83a393}
.st-hint{font-size:11.5px;color:#687f73;line-height:1.4;margin-bottom:1px}
.st-form input,.st-form select{background:#08130f;border:1px solid #254636;color:#d2e7db;padding:8px 9px;border-radius:2px;font-size:13px;height:34px}
.st-check{display:flex;align-items:center;gap:6px;font-size:13px;color:#b7d4c5;padding:5px 0}
.st-check-row{display:flex;gap:28px;flex-wrap:wrap;border:1px solid #1d3a2d;background:#0b1712;padding:8px 12px;margin-bottom:10px;border-radius:3px}
.st-check-row .st-check{color:#d5e8de;font-size:13px}
.st-check input{width:15px;height:15px}
.st-note{font-size:12.5px;color:#638373;margin:6px 0}
.st-json{margin-top:14px;border-top:1px solid #1d3a2d;padding-top:10px;color:#8eae9d;font-size:12px}
.st-raw{width:100%;height:300px;background:#07110e;border:1px solid #254636;color:#cfe5d9;
font:11px/1.5 Consolas,monospace;padding:8px;resize:vertical;border-radius:3px;margin:6px 0}
.st-json summary{cursor:pointer;color:#a9d8bf;margin-bottom:8px}
.st-json textarea{width:100%;height:220px;background:#07110e;border:1px solid #254636;color:#cfe5d9;
font:11px/1.5 Consolas,monospace;padding:8px;resize:vertical;border-radius:2px}
/* GM embedded */ /* GM embedded */
#page-gm{padding:0} #page-gm{padding:0}
@@ -210,7 +180,6 @@ button,input,select{font:inherit}
@media (max-width:1000px){ @media (max-width:1000px){
.stat-grid{grid-template-columns:repeat(2,1fr)} .stat-grid{grid-template-columns:repeat(2,1fr)}
.loadout-layout{grid-template-columns:1fr} .loadout-layout{grid-template-columns:1fr}
.st-form{grid-template-columns:repeat(2,1fr)}
} }
/* ================= 沿用原启动器配色与图标 ================= */ /* ================= 沿用原启动器配色与图标 ================= */
@@ -277,7 +246,7 @@ button,input,select{font:inherit}
.shell-input:focus{outline:1px solid var(--primary);border-color:var(--primary)} .shell-input:focus{outline:1px solid var(--primary);border-color:var(--primary)}
.tag{border-color:rgba(124,92,255,.45);color:#c9beff;background:rgba(124,92,255,.1)} .tag{border-color:rgba(124,92,255,.45);color:#c9beff;background:rgba(124,92,255,.1)}
.empty-box{border-color:rgba(255,255,255,.12);color:#70748d;background:rgba(20,20,36,.45)} .empty-box{border-color:rgba(255,255,255,.12);color:#70748d;background:rgba(20,20,36,.45)}
.loadout-side,.loadout-main,.st-body{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px} .loadout-side,.loadout-main{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px}
.lo-page{border-color:rgba(255,255,255,.12);color:#aeb2c8;border-radius:6px} .lo-page{border-color:rgba(255,255,255,.12);color:#aeb2c8;border-radius:6px}
.lo-page.active{background:rgba(124,92,255,.2);color:#eee9ff;border-color:rgba(124,92,255,.5)} .lo-page.active{background:rgba(124,92,255,.2);color:#eee9ff;border-color:rgba(124,92,255,.5)}
.lo-list{border-top-color:rgba(255,255,255,.08)} .lo-list{border-top-color:rgba(255,255,255,.08)}
@@ -285,38 +254,9 @@ button,input,select{font:inherit}
.lo-item:hover,.lo-item.active{background:rgba(124,92,255,.16);color:#f2f3f8} .lo-item:hover,.lo-item.active{background:rgba(124,92,255,.16);color:#f2f3f8}
.lo-slot{background:rgba(0,0,0,.18);border-color:rgba(255,255,255,.09);color:#aeb2c8} .lo-slot{background:rgba(0,0,0,.18);border-color:rgba(255,255,255,.09);color:#aeb2c8}
.lo-slot b{color:#eef0fb} .lo-slot b{color:#eef0fb}
.st-tabs{border-bottom-color:rgba(255,255,255,.1)}
.st-tab{color:#9a9eb5}.st-tab:hover{color:#fff}
.st-tab.active{background:rgba(124,92,255,.15);border-color:rgba(124,92,255,.35);color:#dcd4ff;border-radius:8px 8px 0 0}
.st-section h3{color:#c9beff;border-left-color:var(--primary)}
.st-form label,.st-json{color:#9a9eb5}
.st-form input,.st-form select{background:rgba(0,0,0,.22);border-color:rgba(255,255,255,.13);color:#eef0fb;border-radius:6px}
.st-form input:focus,.st-form select:focus{outline:1px solid var(--primary)}
.st-check{color:#c4c8dc}
.st-json summary{color:#c9beff}
.st-json textarea{background:rgba(0,0,0,.25);border-color:rgba(255,255,255,.13);color:#e6e8f5}
.shell-statusbar{background:#08080f;border-top-color:rgba(255,255,255,.08);color:#7d8198} .shell-statusbar{background:#08080f;border-top-color:rgba(255,255,255,.08);color:#7d8198}
.shell-statusbar b{color:#b8bcd0} .shell-statusbar b{color:#b8bcd0}
#page-gm #gmView{background:#0b0b14} #page-gm #gmView{background:#0b0b14}
.settings-card{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px}
.settings-label{color:#8d91a8}.settings-value{color:#e2e4f2}
.cp-toolbar{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:16px}
.cp-toolbar label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:#9a9eb5}
.cp-toolbar select{min-width:220px}
.cp-flags{display:flex;flex-wrap:wrap;gap:28px;border:1px solid rgba(255,255,255,.1);border-radius:10px;
padding:10px 16px;margin-bottom:18px;background:rgba(20,20,36,.45)}
.cp-title{font-size:15px;font-weight:400;color:#c9beff;margin:0 0 10px}
.cp-grid{display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px}
.cp-card{border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:12px;background:rgba(20,20,36,.55);cursor:pointer}
.cp-card.selected{border-color:rgba(124,92,255,.7);background:rgba(124,92,255,.16)}
.cp-card b{font-size:14px;display:block;color:#eef0fb}
.cp-card small{font-size:12px;color:#9a9eb5}
.cp-card input{margin-right:7px}
.cp-ai{margin-top:20px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(20,20,36,.45);padding:14px}
.cp-ai-head{display:flex;align-items:center;gap:14px;margin-bottom:12px}
.cp-ai-head h3{margin:0;font-size:15px;font-weight:400;color:#c9beff}
.cp-ai-head select{width:240px}
.cp-ai .st-form{margin-bottom:10px}
/* ================= 字号整体 +2,间距放宽 ================= */ /* ================= 字号整体 +2,间距放宽 ================= */
body{font-size:16px} body{font-size:16px}
@@ -381,34 +321,9 @@ body{font-size:16px}
.lo-cooldown.active{color:#ffcf7a} .lo-cooldown.active{color:#ffcf7a}
.lo-slot{font-size:13px;padding:9px} .lo-slot{font-size:13px;padding:9px}
.lo-slot b{font-size:13px} .lo-slot b{font-size:13px}
.st-tabs{gap:4px;margin-bottom:14px}
.st-tab{font-size:15px;padding:9px 14px}
.st-subtabs{gap:6px;padding:8px 0;margin-bottom:12px}
.st-subtab{font-size:13px;padding:5px 11px}
.st-toolbar{gap:10px;margin-bottom:14px}
.st-body{padding:18px;min-height:380px}
.st-section{margin-bottom:20px}
.st-section h3{font-size:17px;margin-bottom:12px;padding-left:10px}
.st-form{grid-template-columns:repeat(2,minmax(280px,1fr));gap:16px 22px}
.st-form label{font-size:15px;gap:6px}
.st-form input,.st-form select{font-size:15px;padding:9px 10px;height:38px}
.st-hint{font-size:13px;line-height:1.5}
.st-note{font-size:14px;line-height:1.6}
.st-check{font-size:15px;gap:9px;padding:8px 0}
.st-check input{width:17px;height:17px}
.st-check-row{gap:34px;padding:12px 16px;margin-bottom:14px}
.st-check-row .st-check{font-size:15px}
.st-json{font-size:14px;padding-top:14px;margin-top:18px}
.st-json summary{font-size:14px;margin-bottom:10px}
.st-json textarea,.st-raw{font-size:13px;height:260px}
.shell-statusbar{height:34px;font-size:13px;gap:22px;padding:0 18px} .shell-statusbar{height:34px;font-size:13px;gap:22px;padding:0 18px}
.settings-grid{gap:14px;margin-top:18px}
.settings-card{padding:16px 18px}
.settings-label{font-size:13px}
.settings-value{font-size:13px;line-height:1.7}
@media (max-width:1100px){ @media (max-width:1100px){
.stat-grid{grid-template-columns:repeat(2,1fr)} .stat-grid{grid-template-columns:repeat(2,1fr)}
.st-form{grid-template-columns:1fr}
.hero-actions{flex-wrap:wrap;gap:10px} .hero-actions{flex-wrap:wrap;gap:10px}
} }
.win-resize{position:fixed;z-index:10000;display:none} .win-resize{position:fixed;z-index:10000;display:none}
@@ -446,3 +361,87 @@ body.gm-fullscreen .win-resize{display:none}
.shell-win-controls .tb-btn:hover{background:rgba(255,255,255,.1);color:#fff} .shell-win-controls .tb-btn:hover{background:rgba(255,255,255,.1);color:#fff}
.shell-win-controls .tb-close:hover{background:var(--danger);color:#fff} .shell-win-controls .tb-close:hover{background:var(--danger);color:#fff}
/* ================= 游戏设置(频道开关 / 活动开关) ================= */
.st-tabs{display:flex;gap:6px;margin:14px 0 16px;border-bottom:1px solid var(--line)}
.st-tab{appearance:none;background:transparent;border:0;border-bottom:2px solid transparent;
color:var(--muted);padding:8px 16px;cursor:pointer;font-size:14px}
.st-tab:hover{color:var(--text)}
.st-tab.active{color:var(--primary);border-bottom-color:var(--primary);font-weight:500}
.gs-summary{font-size:12px;margin-bottom:12px}
.gs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
.gs-card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
.gs-card.gs-off{opacity:.72}
.gs-card-head{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px}
.gs-card-name{font-weight:600;color:var(--text)}
.gs-card-meta{font-size:12px;margin-bottom:12px;min-height:17px}
.gs-card-actions{display:flex;gap:8px}
.gs-list{display:flex;flex-direction:column;gap:10px}
.gs-row{display:flex;align-items:center;justify-content:space-between;gap:16px;
background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
.gs-row.gs-off{opacity:.72}
.gs-row-main{flex:1;min-width:0}
.gs-row-name{font-weight:600;color:var(--text);margin-bottom:3px}
.gs-row-desc{font-size:12px}
.gs-switch{position:relative;width:44px;height:24px;border-radius:12px;border:0;
background:rgba(255,255,255,.12);cursor:pointer;flex-shrink:0;transition:background .2s;padding:0}
.gs-switch .gs-knob{position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;
background:#fff;transition:left .2s;box-shadow:0 1px 3px rgba(0,0,0,.3)}
.gs-switch.on{background:var(--primary)}
.gs-switch.on .gs-knob{left:22px}
/* 简易 GM 控制台运行时,完整 GM 工具入口置灰不可点 */
.shell-nav-item.nav-disabled{opacity:.38;cursor:not-allowed;filter:grayscale(1)}
.shell-nav-item.nav-disabled:hover{background:transparent;color:inherit}
/* ================= 游戏更新:当前 / 历史 两个子页 ================= */
.upd-body{display:flex;flex-direction:column;gap:12px;margin-top:14px}
.upd-count{color:#8d91a8;font-size:12px;margin-left:2px}
.upd-source{margin-bottom:10px;padding:8px 12px;font-size:12px;color:#ffc96b;
background:rgba(255,201,77,.08);border:1px solid rgba(255,201,77,.28);border-radius:8px}
.changelog-item{border:1px solid rgba(255,255,255,.09);background:rgba(20,20,36,.62);
border-radius:10px;padding:16px 18px}
.changelog-item.featured{border-color:rgba(124,92,255,.5);
background:linear-gradient(160deg,rgba(124,92,255,.16),rgba(20,20,36,.7));
box-shadow:0 6px 22px rgba(124,92,255,.15)}
.changelog-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px}
.changelog-tag{font-family:Consolas,monospace;font-size:12px;color:#c9beff;background:rgba(124,92,255,.16);
padding:2px 8px;border-radius:5px;border:1px solid rgba(124,92,255,.32)}
.changelog-name{font-size:14px;font-weight:600;color:#e2e4f2}
.changelog-time{font-size:11px;color:#6d7188;margin-left:auto}
.changelog-body{font-size:13px;color:#b8bcd0;line-height:1.9;word-break:break-word}
.changelog-assets{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.changelog-asset{font-size:11px;color:#9fe6c4;text-decoration:none;border:1px solid rgba(255,255,255,.12);
padding:3px 10px;border-radius:6px;background:rgba(255,255,255,.04)}
.changelog-asset:hover{background:rgba(255,255,255,.1)}
.changelog-link{display:inline-block;margin-top:12px;font-size:11px;color:#8d91a8;text-decoration:none}
.changelog-link:hover{color:#c9beff}
/* ================= 关于我们 ================= */
.about-pad{max-width:880px;margin:0 auto;padding-top:12px}
.about-hero{padding:14px 20px;border:1px solid rgba(255,255,255,.09);border-radius:12px;
background:linear-gradient(160deg,rgba(124,92,255,.18),rgba(20,20,36,.6))}
.about-hero h2{margin:0 0 10px;font-size:21px;color:#f2f3f8;letter-spacing:.5px}
.about-line{margin:3px 0;color:#b8bcd0;font-size:13px;line-height:1.9}
.about-panel{display:flex;margin-top:12px;border:1px solid rgba(255,255,255,.09);border-radius:12px;
background:rgba(20,20,36,.6);overflow:hidden}
.about-col{padding:16px 20px}
.about-col-l{flex:1 1 auto}
.about-col-r{width:224px;flex:none;border-left:1px solid rgba(255,255,255,.09);background:rgba(0,0,0,.2)}
.about-col-m{width:184px;flex:none;border-left:1px solid rgba(255,255,255,.09)}
.about-col-hd{color:var(--gold);font-size:14px;letter-spacing:1px;margin-bottom:12px}
.about-col-bd{display:flex;gap:16px;justify-content:center}
.about-qr{margin:0;cursor:zoom-in}
.about-qr img{display:block;width:136px;height:136px;background:#fff;border-radius:8px;padding:4px;
transition:transform .15s,box-shadow .15s}
.about-qr:hover img{transform:translateY(-2px);box-shadow:0 6px 18px rgba(124,92,255,.35)}
.about-qr figcaption{margin-top:7px;text-align:center;font-size:12px;color:#9a9eb5}
.about-col-note{margin-top:12px;font-size:11px;color:#6d7188;line-height:1.8}
.about-tip{margin-top:10px;font-size:11px;color:#5d6178}
/* 二维码放大 */
.qr-lightbox{position:fixed;inset:0;z-index:300;background:rgba(5,5,12,.85);
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;cursor:zoom-out}
.qr-lightbox img{width:min(340px,56vh);height:auto;background:#fff;padding:10px;border-radius:12px;
box-shadow:0 12px 40px rgba(0,0,0,.55)}
.qr-lightbox-tip{color:#b8bcd0;font-size:12px}
+283 -526
View File
@@ -1,30 +1,27 @@
/* 90轮回启动器 · 单窗口页面与工具整合 */ /* 90轮回启动器 · 单窗口页面与工具整合 */
(function () { (function () {
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const pages = ['start','gm','loadout','companion','servertool','plugins','settings','changelog']; const pages = ['start','changelog','gm','loadout','gamesettings','about'];
let shellGmInit = false; let shellGmInit = false;
let loConfig = null, loPage = 0, loScheme = -1; let loConfig = null, loPage = 0, loScheme = -1;
let loCooldownUntil = 0, loCooldownCharacter = '', loCooldownTimer = null; let loCooldownUntil = 0, loCooldownCharacter = '', loCooldownTimer = null;
let stFeatures = {}, stActivity = {}, stTab = 'global';
let stGameplay = {};
let stBatchPickup = true;
let hotkeyCapture = false; let hotkeyCapture = false;
let stRaidTab = 'anton';
let stFeatureTab = 'instance.json';
let stRawText = '';
const crumb = {start:'开始 / 本地服务端',gm:'GM工具',loadout:'工具 / 一键换装',companion:'工具 / 支援兵',servertool:'工具 / 服务端工具',plugins:'插件',settings:'设置',changelog:'更新日志'}; const crumb = {start:'开始 / 本地服务端',changelog:'游戏更新',gm:'GM工具',loadout:'工具 / 一键换装',gamesettings:'工具 / 游戏设置',about:'关于我们'};
function shellGo(page) { function shellGo(page) {
if (!pages.includes(page)) page = 'start'; if (!pages.includes(page)) page = 'start';
// 控制台在跑时不允许进 GM(除了点按钮,开始页等地方也可能跳过去)
if (page === 'gm' && window.shellConsoleBusy && window.shellConsoleBusy()) {
shellTip('游戏内简易 GM 控制台正在运行,先关掉它再用完整 GM 工具', 'err');
return;
}
document.querySelectorAll('.shell-page').forEach(el => el.classList.toggle('active', el.id === 'page-' + page)); document.querySelectorAll('.shell-page').forEach(el => el.classList.toggle('active', el.id === 'page-' + page));
document.querySelectorAll('.shell-nav-item').forEach(el => el.classList.toggle('active', el.dataset.shellPage === page)); document.querySelectorAll('.shell-nav-item').forEach(el => el.classList.toggle('active', el.dataset.shellPage === page));
$('shellCrumb').textContent = crumb[page] || page; $('shellCrumb').textContent = crumb[page] || page;
if (page === 'start') loadStart(); if (page === 'start') loadStart();
if (page === 'loadout') loadLoadout(); if (page === 'loadout') loadLoadout();
if (page === 'companion') loadCompanion(); if (page === 'gamesettings') loadGameSettings();
if (page === 'servertool') loadServerTool();
if (page === 'settings') loadSettings();
if (page === 'changelog') loadChangelog(); if (page === 'changelog') loadChangelog();
if (page === 'gm') { if (page === 'gm') {
// 进入 GM:GM 覆盖整个窗口(隐藏顶部导航/面包屑/底部状态栏),并铺满工作区 // 进入 GM:GM 覆盖整个窗口(隐藏顶部导航/面包屑/底部状态栏),并铺满工作区
@@ -59,9 +56,50 @@
window.shellExitGm = shellExitGm; window.shellExitGm = shellExitGm;
document.querySelectorAll('.shell-nav-item').forEach(el => { document.querySelectorAll('.shell-nav-item').forEach(el => {
el.addEventListener('click', () => shellGo(el.dataset.shellPage)); el.addEventListener('click', () => {
if (el.classList.contains('nav-disabled')) {
shellTip(el.getAttribute('title') || '暂时不可用', 'err');
return;
}
shellGo(el.dataset.shellPage);
});
}); });
// 简易 GM 控制台(游戏内那个自绘窗口)在运行时,锁掉完整 GM 工具入口:
// 两边改的是同一份角色数据,同时开会互相覆盖。控制台是精简版,优先保它。
let consoleBusy = false;
function shellTip(msg, type) {
if (typeof gmToast === 'function') { gmToast(msg, type || 'info'); return; }
alert(msg);
}
function applyConsoleBusy(busy) {
const btn = document.querySelector('.shell-nav-item[data-shell-page="gm"]');
if (!btn) return;
btn.classList.toggle('nav-disabled', busy);
btn.setAttribute('title', busy
? '游戏内简易 GM 控制台正在运行,先关掉它再用完整 GM 工具'
: '');
if (busy && document.body.classList.contains('gm-fullscreen')) {
shellExitGm();
shellTip('简易 GM 控制台已启动,完整 GM 工具已自动退出', 'info');
}
}
function pollConsoleStatus() {
fetch('/console-status', { cache: 'no-store' })
.then(r => r.json())
.then(d => {
const busy = !!(d && d.running);
if (busy !== consoleBusy) {
consoleBusy = busy;
applyConsoleBusy(busy);
}
})
.catch(() => {});
}
window.shellConsoleBusy = () => consoleBusy;
setInterval(pollConsoleStatus, 1500);
setTimeout(pollConsoleStatus, 300);
// 顶部窗口按钮 // 顶部窗口按钮
const win = () => (window.pywebview && window.pywebview.api) || null; const win = () => (window.pywebview && window.pywebview.api) || null;
const bMin = $('shellWinMin'); if (bMin) bMin.addEventListener('click', () => { const w = win(); if (w && w.minimize) w.minimize(); }); const bMin = $('shellWinMin'); if (bMin) bMin.addEventListener('click', () => { const w = win(); if (w && w.minimize) w.minimize(); });
@@ -220,43 +258,60 @@
} }
} }
// 更新日志页面:拉取所有 Release 的 changelog // 更新日志:当前更新 = 最新一条;历史更新 = 其余。优先 Gitea,断网退回本地 更新日志.txt
async function loadChangelog() { function updDate(rel) {
const box = $('changelogBody'); return rel.published_at ? rel.published_at.replace('T', ' ').slice(0, 16) : '';
if (!box) return; }
box.innerHTML = '<div class="empty-box">正在获取更新日志…</div>';
function updCard(rel, featured) {
const badge = rel.prerelease ? '<span class="gm-tag gm-tag-gray">预发布</span>' : '';
const body = (rel.body || '').replace(/\r?\n/g, '<br>');
const assets = (rel.assets || []).map(a =>
`<a class="changelog-asset" href="${escapeHtml(a.download_url)}" target="_blank">⬇ ${escapeHtml(a.name)} (${fmt(a.size)} B)</a>`
).join('');
const when = updDate(rel);
return `<div class="changelog-item${featured ? ' featured' : ''}">
<div class="changelog-head">
<span class="changelog-tag">${escapeHtml(rel.tag || rel.name || '')}</span>
<span class="changelog-name">${escapeHtml(rel.name || '')}</span>
${badge}
<span class="changelog-time">${escapeHtml(when)}</span>
</div>
<div class="changelog-body">${body || '<span class="muted">(这次没有填写更新说明)</span>'}</div>
${assets ? '<div class="changelog-assets">' + assets + '</div>' : ''}
${featured ? `<a class="changelog-link" href="${escapeHtml(rel.html_url)}" target="_blank">在浏览器中查看 ↗</a>` : ''}
</div>`;
}
async function loadChangelog(force) {
const latestBox = $('updLatest'), historyBox = $('updHistory');
if (!latestBox || !historyBox) return;
latestBox.innerHTML = '<div class="empty-box">正在获取更新日志…</div>';
historyBox.innerHTML = '';
try { try {
const r = await api('/api/update/changelog'); const r = await api('/api/update/changelog' + (force ? '?force=1' : ''));
if (!r.ok) { if (!r.ok) {
box.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(r.error || '未知错误') + '</div>'; latestBox.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(r.error || '未知错误') + '</div>';
if ($('updHistoryCount')) $('updHistoryCount').textContent = '';
return; return;
} }
const rels = r.releases || []; const rels = r.releases || [];
if (!rels.length) { if (!rels.length) {
box.innerHTML = '<div class="empty-box">暂无发布记录。<br>在 Gitea 仓库「发布 Release」并填写更新说明后,这里会自动显示。</div>'; latestBox.innerHTML = '<div class="empty-box">暂无更新记录。<br>在仓库发布 Release,或把「更新日志.txt」放在启动器目录,这里就会显示。</div>';
if ($('updHistoryCount')) $('updHistoryCount').textContent = '';
return; return;
} }
box.innerHTML = rels.map(rel => { const source = r.source === 'local'
const badge = rel.prerelease ? '<span class="gm-tag gm-tag-gray">预发布</span>' : ''; ? '<div class="upd-source">未连接更新仓库,以下内容读取自本地「更新日志.txt」。</div>'
const body = (rel.body || '').replace(/\r?\n/g, '<br>'); : '';
const assets = (rel.assets || []).map(a => latestBox.innerHTML = source + updCard(rels[0], true);
`<a class="changelog-asset" href="${escapeHtml(a.download_url)}" target="_blank">⬇ ${escapeHtml(a.name)} (${fmt(a.size)} B)</a>` const rest = rels.slice(1);
).join(''); historyBox.innerHTML = rest.length
const when = rel.published_at ? rel.published_at.replace('T', ' ').slice(0, 16) : ''; ? rest.map(rel => updCard(rel, false)).join('')
return `<div class="changelog-item"> : '<div class="empty-box">还没有更早的更新记录。</div>';
<div class="changelog-head"> if ($('updHistoryCount')) $('updHistoryCount').textContent = rest.length ? '(' + rest.length + ')' : '';
<span class="changelog-tag">${escapeHtml(rel.tag || rel.name || '')}</span>
<span class="changelog-name">${escapeHtml(rel.name || '')}</span>
${badge}
<span class="changelog-time">${escapeHtml(when)}</span>
</div>
<div class="changelog-body">${body}</div>
${assets ? '<div class="changelog-assets">' + assets + '</div>' : ''}
<a class="changelog-link" href="${escapeHtml(rel.html_url)}" target="_blank">在浏览器中查看 ↗</a>
</div>`;
}).join('');
} catch (e) { } catch (e) {
box.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(e && e.message ? e.message : e) + '</div>'; latestBox.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(e && e.message ? e.message : e) + '</div>';
} }
} }
@@ -483,504 +538,171 @@
saveLoadout(); saveLoadout();
}); });
// ---------------- 支援兵 ---------------- // ==================== 游戏设置(频道开关 / 活动开关) ====================
let cpAccounts = [], cpCharacters = [], cpState = null, cpSelected = new Set(); let gsCurrentTab = 'channels';
let cpAIProfiles = {}, cpAISource = ''; const gsToast = (msg, type = 'info') => {
let cpLoading = false; if (typeof gmToast === 'function') gmToast(msg, type);
const CP_AI_DEFAULTS = { else console.log(msg);
behavior:'aggressive', buff_policy:'leader_first', combo_preset:'auto',
combo_enabled:true, aoe_min_targets:3, protect_leader:true,
tuning:{think_interval_ms:120,assist_attack_interval_ms:1800,assist_leash_x:180,assist_leash_y:90,
chase_range:50,vision_range:10000,target_stickiness_ms:1200,heal_leader_percent:85,
heal_self_percent:85,retry_ms:300,support_retry_ms:1500,busy_move_ms:2500,stuck_ms:700,
detour_ms:900,give_up_ms:3500,follow_distance:75}
}; };
async function loadCompanion() { function gsSwitchTab(tab) {
if (cpLoading) return; gsCurrentTab = tab;
cpLoading = true; document.querySelectorAll('#gsTabs .st-tab').forEach(el =>
$('cpStatus').textContent = '读取账号和角色…'; el.classList.toggle('active', el.dataset.gs === tab));
$('gsChannelsPane').style.display = tab === 'channels' ? '' : 'none';
$('gsActivitiesPane').style.display = tab === 'activities' ? '' : 'none';
}
function gsChannelTypeText(type) {
const t = Number(type);
if (t === 22) return '特殊区域';
if (t === 11) return '普通城镇';
if (t === 2 || t === 3) return '引导';
return '频道 ' + t;
}
async function gsLoadChannels() {
const grid = $('gsChannelGrid');
const summary = $('gsChannelSummary');
if (!grid) return;
grid.innerHTML = '<div class="empty-box">加载中…</div>';
try { try {
const a = await api('/api/accounts'); const r = await api('/api/game-settings/channel-switches');
const ba = await api('/api/basic-attack'); if (!r.ok) {
$('cpBasicAttack').checked = !ba || ba.enabled !== false; grid.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(r.error || '未知错误') + '</div>';
cpAccounts = (a && a.data) || []; summary.textContent = '';
const sel = $('cpAccount'); return;
sel.innerHTML = cpAccounts.map(x => `<option value="${x.account_id || x.username || ''}">${x.username || x.account_id}</option>`).join('');
if (!cpAccounts.length) { $('cpStatus').textContent = '没有账号'; return; }
await loadCompanionCharacters();
$('cpStatus').textContent = '';
} catch (e) {
$('cpStatus').textContent = '读取失败:' + e.message;
} finally { cpLoading = false; }
}
async function loadCompanionCharacters() {
const account = $('cpAccount').value;
const r = await api('/api/characters?account=' + encodeURIComponent(account));
cpCharacters = (r && r.data) || [];
const owner = $('cpOwner');
owner.innerHTML = cpCharacters.map(c => `<option value="${c.character_id}">${c.name} · Lv${c.level}</option>`).join('');
if (cpCharacters.length) await loadCompanionState();
else { $('cpGrid').innerHTML = '<div class="empty-box">当前账号没有角色</div>'; }
}
async function loadCompanionState() {
const owner = $('cpOwner').value;
if (!owner) return;
$('cpStatus').textContent = '读取支援兵配置…';
try {
const r = await api('/api/companion?character=' + encodeURIComponent(owner));
if (!r.ok) { $('cpStatus').textContent = r.error || '读取失败'; return; }
cpState = r.data || {};
const cfg = (cpState.state && cpState.state.config) || {};
$('cpEnabled').checked = !!cfg.enabled;
$('cpBalance').checked = !!cfg.balance_enabled;
$('cpProgression').checked = !!cfg.progression_enabled;
$('cpRandom').checked = !!cfg.random_equipment_enabled;
cpSelected = new Set((cfg.source_character_ids || []).map(String));
cpAIProfiles = JSON.parse(JSON.stringify(cfg.ai_profiles || {}));
cpAISource = [...cpSelected][0] || '';
renderCompanionGrid();
$('cpStatus').textContent = (cpState.state && cpState.state.preflight && cpState.state.preflight.reason_message) || '已读取';
} catch (e) { $('cpStatus').textContent = '读取失败:' + e.message; }
}
function renderCompanionGrid() {
const owner = String($('cpOwner').value || '');
const list = cpCharacters.filter(c => String(c.character_id) !== owner);
const grid = $('cpGrid');
if (!list.length) { grid.innerHTML = '<div class="empty-box">当前账号没有其他角色可作为支援兵</div>'; renderCpAI(); return; }
grid.innerHTML = list.map(c => {
const id = String(c.character_id), selected = cpSelected.has(id);
return `<label class="cp-card ${selected?'selected':''}">
<input type="checkbox" data-cp-source="${id}" ${selected?'checked':''}>
<b>${c.name || id}</b><small>Lv${c.level ?? '-'} · 职业 ${c.job ?? '-'}</small>
</label>`;
}).join('');
grid.querySelectorAll('input[data-cp-source]').forEach(inp => {
inp.onchange = () => {
const id = inp.dataset.cpSource;
if (inp.checked) cpSelected.add(id); else cpSelected.delete(id);
inp.closest('.cp-card').classList.toggle('selected', inp.checked);
renderCpAI();
};
});
renderCpAI();
}
function cpProfile(id) {
const raw = cpAIProfiles[String(id)] || {};
return {
...CP_AI_DEFAULTS, ...raw,
tuning: {...CP_AI_DEFAULTS.tuning, ...(raw.tuning || {})}
};
}
function renderCpAI() {
const select = $('cpAISource');
const ids = [...cpSelected];
if (!ids.includes(cpAISource)) cpAISource = ids[0] || '';
select.innerHTML = ids.map(id => {
const c = cpCharacters.find(x => String(x.character_id) === id);
return `<option value="${id}">${c ? c.name : id}</option>`;
}).join('');
if (cpAISource) select.value = cpAISource;
if (!cpAISource) {
$('cpAIBehavior').value = 'assist'; $('cpAIBuff').value = 'leader_first';
$('cpAIComboPreset').value = 'auto'; $('cpAIAoe').value = 3;
$('cpAIComboEnabled').checked = true; $('cpAIProtect').checked = true;
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
inp.value = CP_AI_DEFAULTS.tuning[inp.dataset.cpTuning] ?? 0;
});
return;
}
const p = cpProfile(cpAISource);
$('cpAIBehavior').value = ['assist','aggressive'].includes(p.behavior) ? p.behavior : 'aggressive';
$('cpAIBuff').value = p.buff_policy || 'leader_first';
$('cpAIComboPreset').value = p.combo_preset || 'auto';
$('cpAIAoe').value = Number(p.aoe_min_targets) || 3;
$('cpAIComboEnabled').checked = !!p.combo_enabled;
$('cpAIProtect').checked = !!p.protect_leader;
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
inp.value = p.tuning[inp.dataset.cpTuning] ?? 0;
});
}
function readCpAI() {
if (!cpAISource) return;
const p = cpProfile(cpAISource);
p.behavior = $('cpAIBehavior').value;
p.buff_policy = $('cpAIBuff').value;
p.combo_preset = $('cpAIComboPreset').value;
p.combo_enabled = $('cpAIComboEnabled').checked;
p.aoe_min_targets = Math.max(2, Math.min(6, Number($('cpAIAoe').value) || 3));
p.protect_leader = $('cpAIProtect').checked;
p.tuning = {...p.tuning};
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
const n = Number(inp.value);
if (Number.isFinite(n)) p.tuning[inp.dataset.cpTuning] = n;
});
cpAIProfiles[String(cpAISource)] = p;
}
$('cpAccount').addEventListener('change', async () => {
try { await loadCompanionCharacters(); } catch (e) { $('cpStatus').textContent = e.message; }
});
$('cpOwner').addEventListener('change', loadCompanionState);
$('cpReload').addEventListener('click', loadCompanion);
$('cpAISource').addEventListener('change', () => { cpAISource = $('cpAISource').value; renderCpAI(); });
['cpAIBehavior','cpAIBuff','cpAIComboPreset','cpAIAoe','cpAIComboEnabled','cpAIProtect'].forEach(id => {
$(id).addEventListener('change', readCpAI);
});
document.querySelectorAll('[data-cp-tuning]').forEach(inp => inp.addEventListener('change', readCpAI));
$('cpBasicAttack').addEventListener('change', async () => {
try {
const r = await post('/api/basic-attack', {enabled: $('cpBasicAttack').checked});
if (!r.ok) $('cpStatus').textContent = r.error || '普攻开关保存失败';
else $('cpStatus').textContent = $('cpBasicAttack').checked
? '已允许支援兵普攻;重启客户端后生效。'
: '已关闭支援兵普攻,技能释放不受影响;重启客户端后生效。';
} catch (e) { $('cpStatus').textContent = '普攻开关保存失败:' + e.message; }
});
$('cpSave').addEventListener('click', async () => {
const owner = $('cpOwner').value;
if (!owner) { $('cpStatus').textContent = '请先选择出战角色'; return; }
if ($('cpEnabled').checked && cpSelected.size === 0) {
$('cpStatus').textContent = '启用支援兵时至少选择一个支援兵角色'; return;
}
const state = (cpState && cpState.state) || {};
const cfg = state.config || {};
const sources = [...cpSelected];
readCpAI();
const payload = {
characterId: String(owner),
expectedRevision: Number(cfg.revision || 0),
enabled: $('cpEnabled').checked,
balanceEnabled: $('cpBalance').checked,
progressionEnabled: $('cpProgression').checked,
randomEquipmentEnabled: $('cpRandom').checked,
sourceCharacterId: sources[0] || '',
sourceCharacterIds: sources,
ai: cfg.ai || {},
aiProfiles: cpAIProfiles
};
$('cpStatus').textContent = '保存中…';
try {
const r = await post('/api/companion', payload);
if (!r.ok) { $('cpStatus').textContent = r.error || '保存失败'; return; }
cpState = r.data || cpState;
$('cpStatus').textContent = '支援兵配置已保存,下一次进入副本生效。';
} catch (e) { $('cpStatus').textContent = '保存失败:' + e.message; }
});
// ---------------- 服务端工具 ----------------
async function loadServerTool() {
try {
$('stBody').innerHTML = '<div class="empty-box">正在读取服务端配置…</div>';
const [f, a, s, g, b] = await Promise.all([api('/api/servertool/features'), api('/api/servertool/activity'), api('/api/status'), api('/api/servertool/gameplay'), api('/api/servertool/batch-pickup')]);
stFeatures = f.data || {}; stActivity = a.data || {}; stGameplay = g.data || {};
stBatchPickup = !!(b.data || {}).enabled;
window.__stStatusInfo = s || {};
$('stSource').textContent = f.source === 'native' ? 'Native API' : '文件配置';
renderServerTool();
} catch (e) {
$('stBody').innerHTML = '<div class="empty-box">读取失败:' + (e && e.message ? e.message : e) + '</div>';
$('stStatus').textContent = '读取失败';
}
}
document.querySelectorAll('.st-tab').forEach(b => b.addEventListener('click', () => {
stTab = b.dataset.st;
document.querySelectorAll('.st-tab').forEach(x => x.classList.toggle('active', x === b));
renderServerTool();
}));
const LABEL = {
raidReviveSeconds:'副本复活秒数', allDeadReturnSeconds:'全员死亡返回秒数', allowFreeRevive:'允许免费复活',
hellOrbChance:'深渊宝珠概率', hellDropChanceDenominator:'掉落分母', equipmentRollMultiplier:'装备倍率',
epicPieceRollMultiplier:'史诗碎片倍率', lukeEnergyRecoveryPercent:'卢克能量恢复%', fiendWarSquadCount:'超时空队伍数',
roomFatigueCost:'每房间疲劳', dailyFatigueResetHour:'每日疲劳重置点', adventureGroupBonusPercent:'冒险团加成%',
rentalMaxReinforce:'租赁最大强化', rentalMaxForge:'租赁最大锻造', soloTeleportCooldownSeconds:'单人传送冷却秒',
soloTeleportCooldownIndices:'单人传送索引', auctionStandardListingLimit:'普通拍卖上架数',
auctionPremiumListingLimit:'高级拍卖上架数', auctionStandardDurationHours:'普通拍卖时长',
auctionPremiumDurationHours:'高级拍卖时长', auctionStandardFeePercent:'普通手续费%', auctionPremiumFeePercent:'高级手续费%',
antonBlackFogRecoverySeconds:'安徒恩黑雾恢复秒', antonBlackFogActiveSeconds:'安徒恩黑雾持续秒',
antonQuakeRecoverySeconds:'安徒恩震颤恢复秒', antonQuakeActiveSeconds:'安徒恩震颤持续秒',
antonNavalOpenDelaySeconds:'安徒恩舰炮延迟秒', antonNavalRecoverySeconds:'安徒恩舰炮恢复秒',
antonNavalActiveSeconds:'安徒恩舰炮持续秒', lukeEnergyRecovery0Seconds:'卢克1阶段恢复秒',
lukeEnergyRecovery1Seconds:'卢克2阶段恢复秒', lukeEnergyRecovery2Seconds:'卢克3阶段恢复秒',
lukePhaseTimeoutPercent:'卢克阶段时限%', fiendWarAreaLimitPercent:'超时空区域限制%',
fiendWarDieImprisonPercent:'超时空死亡禁锢%', antonMemberMax:'安徒恩人数上限',
antonStartMinimumMembers:'安徒恩最少开局人数', antonSingleStartMinimumMembers:'安徒恩单人开局人数',
antonStartDelaySeconds:'安徒恩开始延迟', antonPhaseBreakSeconds:'安徒恩阶段间隔',
antonEntryCostItem:'安徒恩入场物品', antonEntryCostCount:'安徒恩入场数量',
antonWeeklyEnterCountA:'安徒恩A每周次数', antonWeeklyEnterCountB:'安徒恩B每周次数',
antonPhaseTimeoutSeconds:'安徒恩阶段时限', lukeDailyAccountEnterLimit:'卢克账号每日次数',
lukeSanctumTimerCount:'卢克圣所计时', lukeEntryCostItem:'卢克入场物品', lukeEntryCostCount:'卢克入场数量',
fiendWarDailyEnterLimit:'超时空每日次数', fiendWarMemberMax:'超时空人数上限',
fiendWarStartMinimumMembers:'超时空最少开局人数', fiendWarDisconnectWaitSeconds:'超时空掉线等待',
fiendWarEntryCostItem:'超时空入场物品', fiendWarEntryCostCount:'超时空入场数量', fiendWarRetry:'超时空重试',
reviveByDungeon:'按副本复活配置'
};
const ST_DESC = {
soloTeleportCooldownSeconds:'单人传送冷却时间,单位秒;服务端读取该值限制重复传送。',
soloTeleportCooldownIndices:'允许触发单人传送的技能索引,多个用逗号分隔。',
auctionStandardListingLimit:'普通拍卖行同时可上架数量。',
auctionStandardDurationHours:'普通拍卖行上架时长,单位小时。',
auctionPremiumListingLimit:'高级拍卖行同时可上架数量。',
auctionPremiumDurationHours:'高级拍卖行上架时长,单位小时。',
auctionStandardFeePercent:'普通拍卖成交手续费百分比。',
auctionPremiumFeePercent:'高级拍卖成交手续费百分比。',
raidReviveSeconds:'团本死亡后等待复活的秒数,有效范围 1-60;不是无限复活选项。',
allDeadReturnSeconds:'队伍全员死亡后返回城镇的等待秒数。',
hellOrbChance:'深渊宝珠掉落判定值。',
hellDropChanceDenominator:'深渊基础掉率分母,数值越大掉率越低。',
equipmentRollMultiplier:'史诗装备掉落 roll 次数倍率。',
epicPieceRollMultiplier:'史诗碎片掉落 roll 次数倍率。',
allowFreeRevive:'允许不使用复活币直接复活。',
roomFatigueCost:'进入每个房间消耗的疲劳值。',
dailyFatigueResetHour:'每日疲劳重置小时,0-23。',
adventureGroupBonusPercent:'冒险团经验加成百分比。',
rentalMaxReinforce:'租赁装备允许的最大强化等级。',
rentalMaxForge:'租赁装备允许的最大锻造等级。',
antonBlackFogRecoverySeconds:'安徒恩黑雾之源恢复等待秒数。',
antonBlackFogActiveSeconds:'安徒恩黑雾之源持续时间秒数。',
antonQuakeRecoverySeconds:'安徒恩震颤之地恢复等待秒数。',
antonQuakeActiveSeconds:'安徒恩震颤之地持续时间秒数。',
antonNavalOpenDelaySeconds:'安徒恩舰炮防御战开启延迟秒数。',
antonNavalRecoverySeconds:'安徒恩舰炮防御战恢复等待秒数。',
antonNavalActiveSeconds:'安徒恩舰炮防御战持续时间秒数。',
lukeEnergyRecovery0Seconds:'卢克能量控制一阶段恢复秒数。',
lukeEnergyRecovery1Seconds:'卢克能量控制二阶段恢复秒数。',
lukeEnergyRecovery2Seconds:'卢克能量控制三阶段恢复秒数。',
lukePhaseTimeoutPercent:'卢克各阶段时限百分比。',
lukeEnergyRecoveryPercent:'卢克能量恢复速度百分比。',
fiendWarSquadCount:'超时空之战小队编制数量。',
fiendWarAreaLimitPercent:'超时空区域进入限制百分比。',
fiendWarDieImprisonPercent:'超时空死亡禁锢触发百分比。'
};
const ST_FIELDS = {
global: [
'soloTeleportCooldownSeconds','soloTeleportCooldownIndices',
'auctionStandardListingLimit','auctionStandardDurationHours',
'auctionPremiumListingLimit','auctionPremiumDurationHours',
'auctionStandardFeePercent','auctionPremiumFeePercent'
],
gameplay: [
'allDeadReturnSeconds','hellOrbChance',
'hellDropChanceDenominator','equipmentRollMultiplier',
'epicPieceRollMultiplier','allowFreeRevive'
],
growth: [
'roomFatigueCost','dailyFatigueResetHour','adventureGroupBonusPercent',
'rentalMaxReinforce','rentalMaxForge'
],
raid: {
anton: [
'antonBlackFogRecoverySeconds','antonBlackFogActiveSeconds',
'antonQuakeRecoverySeconds','antonQuakeActiveSeconds',
'antonNavalOpenDelaySeconds','antonNavalRecoverySeconds','antonNavalActiveSeconds'
],
luke: [
'lukeEnergyRecovery0Seconds','lukeEnergyRecovery1Seconds',
'lukeEnergyRecovery2Seconds','lukePhaseTimeoutPercent',
'lukeEnergyRecoveryPercent'
],
fiend: [
'fiendWarSquadCount','fiendWarAreaLimitPercent','fiendWarDieImprisonPercent'
]
}
};
function allAssignedKeys() {
return new Set([...ST_FIELDS.global, ...ST_FIELDS.gameplay, ...ST_FIELDS.growth,
...ST_FIELDS.raid.anton, ...ST_FIELDS.raid.luke, ...ST_FIELDS.raid.fiend]);
}
function renderServerTool() {
const body = $('stBody');
if (stTab === 'activity') { renderActivity(body); return; }
if (stTab === 'launch') {
body.innerHTML = `<div class="st-section"><h3>启动控制</h3><p class="st-note">配置文件:server-features.json / activity.json。服务端运行状态请以开始页为准。</p><div class="lo-toolbar"><button class="shell-btn primary" id="stStart">开始游戏</button><button class="shell-btn" id="stStop">关闭服务端</button></div><div class="st-note" id="stLaunchInfo"></div></div>`;
$('stStart').onclick = () => doAction('start');
$('stStop').onclick = () => doAction('stop');
api('/api/status').then(s => {
$('stLaunchInfo').textContent = `服务端:${s.server_running ? '运行中' : '未运行'} · 目录:${s.server_root || '未绑定'}`;
}).catch(() => {});
return;
}
let sub = '';
let keys;
if (stTab === 'features') {
keys = Object.keys(stFeatures).filter(k => !allAssignedKeys().has(k));
} else if (stTab === 'raid') {
const names = {anton:'安徒恩机制', luke:'卢克', fiend:'超时空'};
$('stSubTabs').innerHTML = Object.keys(names).map(k =>
`<button class="st-subtab ${stRaidTab===k?'active':''}" data-raid="${k}">${names[k]}</button>`).join('');
$('stSubTabs').querySelectorAll('.st-subtab').forEach(b => b.onclick = () => {
stRaidTab = b.dataset.raid; renderServerTool();
});
keys = ST_FIELDS.raid[stRaidTab] || [];
} else {
$('stSubTabs').innerHTML = '';
keys = ST_FIELDS[stTab] || [];
}
let html = `<div class="st-section"><h3>${tabTitle(stTab)}</h3>`;
if (stTab === 'features') {
const info = window.__stStatusInfo || {};
const rawNames = ['instance.json','dnfbridge.toml','logic.toml','plan.json'];
$('stSubTabs').innerHTML = rawNames.map(n =>
`<button class="st-subtab ${stFeatureTab===n?'active':''}" data-raw="${n}">${n}</button>`).join('');
$('stSubTabs').querySelectorAll('.st-subtab').forEach(b => b.onclick = () => {
stFeatureTab = b.dataset.raw; renderServerTool();
});
html += `<div class="st-form">
<label>服务端目录<input value="${(info.server_root || '').replace(/"/g,'&quot;')}" readonly></label>
<label>Admin 端口<input value="18111" readonly></label>
<label>数据库<input value="${(info.db || '').replace(/"/g,'&quot;')}" readonly></label>
<label>客户端目录<input value="${(info.client_dir || '').replace(/"/g,'&quot;')}" readonly></label>
</div>`;
html += `<div class="st-note">当前配置文件:${stFeatureTab}</div>`;
html += `<textarea id="stRaw" class="st-raw" spellcheck="false">${stRawText.replace(/</g,'&lt;')}</textarea>`;
html += `<div class="st-note">服务端参数页保留实例与配置文件;下方列出未归入其他页的字段。</div>`;
}
html += `<div class="st-form">${groupFields(stFeatures, stTab === 'gameplay' ? keys.filter(k => k !== 'allowFreeRevive') : keys)}</div>`;
if (stTab === 'gameplay') {
html += `<div class="st-form">
<label>深渊史诗掉率(%)<small class="st-hint">100 = 原版,250 = 2.5 倍</small><input data-game-key="hellEpicDropRatePercent" type="number" value="${stGameplay.hell_epic_drop_rate_percent ?? 0}"></label>
<label>史诗药剂测试倍率(%)<small class="st-hint">0 = 关闭测试倍率</small><input data-game-key="epicPotionMultiplierPercent" type="number" value="${stGameplay.epic_drop_rate_potion_test_multiplier_percent ?? 0}"></label>
</div>`;
html += `<div class="st-check-row">
<label class="st-check"><input type="checkbox" data-st-key="allowFreeRevive" ${stFeatures.allowFreeRevive === true ? 'checked' : ''}>允许无复活币免费复活</label>
<label class="st-check"><input type="checkbox" data-batch-pickup ${stBatchPickup ? 'checked' : ''}>服务端允许一键拾取</label>
</div>`;
}
html += `</div>`;
html += `<details class="st-json"><summary>高级:完整 JSON 配置</summary><textarea id="stJson" spellcheck="false"></textarea><div class="lo-toolbar"><button class="shell-btn" id="stJsonApply">应用 JSON</button><span class="muted">可直接编辑全部服务端字段</span></div></details>`;
body.innerHTML = html;
if (stTab === 'features') loadRawConfig(stFeatureTab);
const jsonBox = $('stJson');
if (jsonBox) jsonBox.value = JSON.stringify(stFeatures, null, 2);
$('stJsonApply').onclick = () => {
try {
const obj = JSON.parse($('stJson').value);
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('需要 JSON 对象');
stFeatures = obj; renderServerTool();
$('stStatus').textContent = 'JSON 已载入,点击“保存当前页”写入';
} catch (e) { $('stStatus').textContent = 'JSON 错误:' + e.message; }
};
bindFeatureInputs(body);
}
async function loadRawConfig(name) {
try {
const r = await api('/api/servertool/raw?name=' + encodeURIComponent(name));
stRawText = r.text || '';
const box = $('stRaw');
if (box) box.value = stRawText;
} catch (e) { /* ignore */ }
}
function bindFeatureInputs(body) {
body.querySelectorAll('input[data-st-key]').forEach(inp => {
inp.addEventListener('change', () => {
const key = inp.dataset.stKey;
let v = inp.value;
if (inp.type === 'checkbox') v = inp.checked;
else if (inp.dataset.type === 'number') { const n = Number(v); v = Number.isFinite(n) ? n : 0; }
stFeatures[key] = v;
});
});
body.querySelectorAll('input[data-game-key]').forEach(inp => {
inp.addEventListener('change', () => {
stGameplay[inp.dataset.gameKey] = Number(inp.value) || 0;
});
});
body.querySelectorAll('input[data-batch-pickup]').forEach(inp => {
inp.addEventListener('change', () => { stBatchPickup = inp.checked; });
});
}
function groupFields(obj, keys) {
return keys.map(k => {
const val = obj[k];
const label = LABEL[k] || k;
if (typeof val === 'boolean' || /Retry|Revive|Pickup/.test(k)) {
return `<label class="st-check"><input type="checkbox" data-st-key="${k}" ${val===true?'checked':''}>${label}</label>`;
} }
const numeric = (typeof val === 'number') || /Seconds|Percent|Count|Hour|Limit|Cost|Max|Min|Delay|Item/.test(k); const d = r.data || {};
const type = numeric ? 'number' : 'text'; const list = d.channels || [];
const shown = val === undefined || val === null ? '' : String(val); summary.textContent = '共 ' + (d.total_channels ?? list.length) + ' 个频道 · 已启用 ' +
const hint = ST_DESC[k] ? `<small class="st-hint">${ST_DESC[k]}</small>` : ''; (d.enabled_channels ?? '—') + ' · 已停用 ' + (d.disabled_channels ?? '—');
return `<label>${label}${hint}<input data-st-key="${k}" data-type="${type}" type="${type}" value="${shown.replace(/"/g,'&quot;')}"></label>`; if (!list.length) {
}).join(''); grid.innerHTML = '<div class="empty-box">暂无频道数据。请确认服务端已启动。</div>';
return;
}
grid.innerHTML = list.map(ch => {
const on = !!ch.enabled;
return `<div class="gs-card ${on ? '' : 'gs-off'}">
<div class="gs-card-head">
<span class="gs-card-name">${escapeHtml(ch.name || ('频道 ' + ch.id))}</span>
<span class="gm-tag ${on ? 'gm-tag-green' : 'gm-tag-gray'}">${on ? '启用' : '停用'}</span>
</div>
<div class="gs-card-meta muted">ID ${ch.id} · ${gsChannelTypeText(ch.type)}${ch.group ? ' · ' + escapeHtml(ch.group) : ''}${ch.port ? ' · 端口 ' + ch.port : ''}</div>
<div class="gs-card-actions">
<button class="shell-btn ${on ? '' : 'primary'}" data-gs-channel="${ch.id}" data-gs-enable="${!on}">${on ? '停用' : '启用'}</button>
</div>
</div>`;
}).join('');
} catch (e) {
grid.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(e && e.message ? e.message : e) + '</div>';
}
} }
function tabTitle(tab) { async function gsLoadActivities() {
return ({features:'服务端参数',global:'全局游戏参数',gameplay:'游戏参数',growth:'疲劳/成长',raid:'团本配置'})[tab] || tab; const list = $('gsActivityList');
} const summary = $('gsActivitySummary');
function tabKeys(tab) { if (!list) return;
const all = Object.keys(stFeatures); list.innerHTML = '<div class="empty-box">加载中…</div>';
const match = {
features: ['raidReviveSeconds','allDeadReturnSeconds','allowFreeRevive','hellOrbChance','hellDropChanceDenominator','equipmentRollMultiplier','epicPieceRollMultiplier','lukeEnergyRecoveryPercent','fiendWarSquadCount'],
global: [],
gameplay: ['hellOrbChance','hellDropChanceDenominator','equipmentRollMultiplier','epicPieceRollMultiplier','roomFatigueCost','rentalMaxReinforce','rentalMaxForge'],
growth: ['roomFatigueCost','dailyFatigueResetHour','adventureGroupBonusPercent','rentalMaxReinforce','rentalMaxForge'],
raid: ['antonBlackFogRecoverySeconds','antonBlackFogActiveSeconds','antonQuakeRecoverySeconds','antonQuakeActiveSeconds','antonNavalOpenDelaySeconds','antonNavalRecoverySeconds','antonNavalActiveSeconds','lukeEnergyRecovery0Seconds','lukeEnergyRecovery1Seconds','lukeEnergyRecovery2Seconds','lukePhaseTimeoutPercent','fiendWarAreaLimitPercent','fiendWarDieImprisonPercent','antonMemberMax','antonStartMinimumMembers','lukeDailyAccountEnterLimit','fiendWarDailyEnterLimit'],
};
if (tab === 'global') return all.filter(k => /soloTeleport|auction|allDead|revive/i.test(k));
return (match[tab] || []).filter(k => all.includes(k));
}
function renderActivity(body) {
const keys = Object.keys(stActivity);
body.innerHTML = `<div class="st-section"><h3>在线活动开关</h3><div class="st-form">${keys.map(k => {
const v = !!stActivity[k]; const label = ({daily:'每日签到',weekly:'每周签到',monthly:'每月签到',marble:'签到弹珠',returnee:'回归活动',returneeGift:'回归赠礼',lucky:'幸运活动',levelAiming:'升级预约',laborDay:'劳动节',legacyLevelup:'旧版升级',pcRoom:'网吧时间点',spendTime:'在线时长'})[k] || k;
return `<label class="st-check"><input type="checkbox" data-act-key="${k}" ${v?'checked':''}>${label}</label>`;
}).join('')}</div></div>`;
body.querySelectorAll('input[data-act-key]').forEach(inp => {
inp.addEventListener('change', () => { stActivity[inp.dataset.actKey] = inp.checked; });
});
}
$('stReload').addEventListener('click', loadServerTool);
$('stSave').addEventListener('click', async () => {
try { try {
let r; const r = await api('/api/game-settings/activity-switches');
if (stTab === 'activity') { if (!r.ok) {
r = await post('/api/servertool/activity', stActivity); list.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(r.error || '未知错误') + '</div>';
} else if (stTab === 'features' && $('stRaw')) { summary.textContent = '';
r = await post('/api/servertool/raw', {name: stFeatureTab, text: $('stRaw').value}); return;
} else if (stTab === 'gameplay') { }
r = await post('/api/servertool/features', stFeatures); const d = r.data || {};
const g = await post('/api/servertool/gameplay', stGameplay); const items = d.switches || [];
const bp = await post('/api/servertool/batch-pickup', {enabled: stBatchPickup}); const onCount = items.filter(x => x.enabled).length;
if (!g.ok) r = g; summary.textContent = '共 ' + items.length + ' 个活动 · 已开启 ' + onCount;
if (!bp.ok) r = bp; if (!items.length) {
list.innerHTML = '<div class="empty-box">暂无活动数据。请确认服务端已启动。</div>';
return;
}
list.innerHTML = items.map(it => {
const on = !!it.enabled;
return `<div class="gs-row ${on ? '' : 'gs-off'}">
<div class="gs-row-main">
<div class="gs-row-name">${escapeHtml(it.title || it.key)}</div>
<div class="gs-row-desc muted">${escapeHtml(it.description || '')}</div>
</div>
<button class="gs-switch ${on ? 'on' : ''}" data-gs-activity="${escapeHtml(it.key)}" data-gs-enable="${!on}" role="switch" aria-checked="${on}">
<span class="gs-knob"></span>
</button>
</div>`;
}).join('');
} catch (e) {
list.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(e && e.message ? e.message : e) + '</div>';
}
}
async function loadGameSettings() {
gsSwitchTab(gsCurrentTab);
if (gsCurrentTab === 'channels') await gsLoadChannels();
else await gsLoadActivities();
}
// 刷新按钮
$('gsRefresh').addEventListener('click', () => {
if (gsCurrentTab === 'channels') gsLoadChannels();
else gsLoadActivities();
});
// Tab 切换
document.querySelectorAll('#gsTabs .st-tab').forEach(el => {
el.addEventListener('click', () => {
gsSwitchTab(el.dataset.gs);
if (gsCurrentTab === 'channels') gsLoadChannels();
else gsLoadActivities();
});
});
// 频道开关点击(事件委托)
$('gsChannelGrid').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-gs-channel]');
if (!btn) return;
const id = Number(btn.dataset.gsChannel);
const enable = btn.dataset.gsEnable === 'true';
btn.disabled = true;
btn.textContent = '处理中…';
try {
const r = await post('/api/game-settings/channel-switch', { channel_id: id, enabled: enable });
if (r.ok) {
gsToast(enable ? '频道已启用' : '频道已停用', 'success');
await gsLoadChannels();
} else { } else {
r = await post('/api/servertool/features', stFeatures); gsToast('操作失败:' + (r.error || '未知错误'), 'error');
btn.disabled = false;
btn.textContent = enable ? '启用' : '停用';
} }
$('stStatus').textContent = r.ok ? ('已保存' + (r.warning ? '(' + r.warning + ')' : '')) : (r.error || '保存失败'); } catch (e) {
} catch (e) { $('stStatus').textContent = '保存失败:' + e.message; } gsToast('操作失败:' + (e && e.message ? e.message : e), 'error');
btn.disabled = false;
btn.textContent = enable ? '启用' : '停用';
}
}); });
async function loadSettings() { // 活动开关点击(事件委托)
$('gsActivityList').addEventListener('click', async (e) => {
const sw = e.target.closest('button[data-gs-activity]');
if (!sw) return;
const key = sw.dataset.gsActivity;
const enable = sw.dataset.gsEnable === 'true';
sw.disabled = true;
try { try {
const s = await api('/api/status'); const r = await post('/api/game-settings/activity-switch', { key: key, enabled: enable });
$('setServerRoot').textContent = s.server_root || '未绑定'; if (r.ok) {
$('setControl').textContent = s.control_exists ? '已找到 DNF90Control.exe' : '缺失'; gsToast(enable ? '活动已开启' : '活动已关闭', 'success');
$('setDb').textContent = s.db || '未找到'; await gsLoadActivities();
$('setClient').textContent = s.client_dir || '未找到'; } else {
$('setRun').textContent = s.server_running ? '运行中' : '未运行'; gsToast('操作失败:' + (r.error || '未知错误'), 'error');
$('setNative').textContent = s.native_available ? '可用' : '不可用'; sw.disabled = false;
} catch (e) { /* ignore */ } }
} } catch (e) {
$('setRefresh').addEventListener('click', loadSettings); gsToast('操作失败:' + (e && e.message ? e.message : e), 'error');
sw.disabled = false;
}
});
// 顶栏拖动窗口(JS move 兜底,兼容 pywebview EdgeChromium) // 顶栏拖动窗口(JS move 兜底,兼容 pywebview EdgeChromium)
// 同时支持第一行标题栏(.shell-topnav)与第二行面包屑栏(.shell-topbar) // 同时支持第一行标题栏(.shell-topnav)与第二行面包屑栏(.shell-topbar)
@@ -1040,6 +762,41 @@
}); });
}); });
// ---------------- 游戏更新:当前 / 历史 两个子页 ----------------
(function bindUpdTabs() {
const tabs = document.querySelectorAll('#updTabs .st-tab');
tabs.forEach(btn => {
btn.addEventListener('click', () => {
const which = btn.dataset.upd;
tabs.forEach(b => b.classList.toggle('active', b === btn));
$('updLatestPane').style.display = which === 'latest' ? '' : 'none';
$('updHistoryPane').style.display = which === 'history' ? '' : 'none';
});
});
const refresh = $('updRefresh');
if (refresh) refresh.addEventListener('click', () => loadChangelog(true));
})();
// ---------------- 关于我们:二维码放大 ----------------
(function bindQrLightbox() {
const box = $('qrLightbox'), img = $('qrLightboxImg'), tip = $('qrLightboxTip');
if (!box || !img) return;
document.querySelectorAll('#page-about .about-qr').forEach(fig => {
fig.addEventListener('click', () => {
const src = fig.querySelector('img');
if (!src) return;
img.src = src.getAttribute('src');
img.alt = src.getAttribute('alt') || '';
tip.textContent = (fig.dataset.qr || '') + ' · 点击任意处关闭';
box.classList.remove('hidden');
});
});
box.addEventListener('click', () => box.classList.add('hidden'));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') box.classList.add('hidden');
});
})();
// 兼容旧 GM 页面里的返回 // 兼容旧 GM 页面里的返回
window.shellGo = shellGo; window.shellGo = shellGo;
loadStart(); loadStart();
+404
View File
@@ -0,0 +1,404 @@
# 支援兵页面模板(Companion Page Template)
> 从 DNF90 启动器 v2.1.0 中完整导出的「支援兵」页面模板。
> 导出日期:2026-11-04。
> 用途:日后如需恢复「支援兵」功能,按本文档把 HTML / JS / CSS / 后端接口 四个部分分别粘贴回对应文件即可。
---
## 一、HTML(index.html 中的 section)
插入位置:`<section class="shell-page" id="page-changelog">` 之前(或任意 shell-page 之间)。
```html
<section class="shell-page" id="page-companion">
<div class="page-pad">
<div class="page-heading"><div><h2>支援兵</h2><p class="muted">按账号配置支援兵;配置只影响下一次进入副本。</p></div><button class="shell-btn primary" id="cpSave">保存配置</button></div>
<div class="cp-toolbar">
<label>账号<select id="cpAccount" class="shell-input"></select></label>
<label>出战角色<select id="cpOwner" class="shell-input"></select></label>
<button class="shell-btn" id="cpReload">刷新</button>
<span id="cpStatus" class="muted"></span>
</div>
<div class="cp-flags">
<label class="st-check"><input type="checkbox" id="cpEnabled">启用支援兵</label>
<label class="st-check"><input type="checkbox" id="cpBalance">读取佩戴装备</label>
<label class="st-check"><input type="checkbox" id="cpProgression">启用成长推进</label>
<label class="st-check"><input type="checkbox" id="cpRandom">随机武器装备</label>
<label class="st-check"><input type="checkbox" id="cpBasicAttack">允许支援兵普攻</label>
</div>
<h3 class="cp-title">选择支援兵角色</h3>
<div class="cp-grid" id="cpGrid"><div class="empty-box">选择出战角色后加载支援兵列表</div></div>
<div class="cp-ai">
<div class="cp-ai-head"><h3>AI 策略</h3><select id="cpAISource" class="shell-input"></select></div>
<div class="st-form">
<label>行为模式<select id="cpAIBehavior"><option value="assist">辅助</option><option value="aggressive">好战</option></select></label>
<label>BUFF 优先级<select id="cpAIBuff"><option value="leader_first">优先队长</option><option value="assist_only">仅辅助</option><option value="off">关闭</option></select></label>
<label>职业连招<select id="cpAIComboPreset"><option value="auto">按职业自动</option><option value="sword_guard">鬼剑士:控制连招</option><option value="fighter_chain">格斗家:近身连招</option><option value="gunner_burst">神枪手:远程爆发</option><option value="mage_control">魔法师:范围控制</option><option value="priest_support">圣职者:辅助连招</option></select></label>
<label>范围技能最少目标<input id="cpAIAoe" type="number" min="2" max="6" step="1"></label>
<label class="st-check"><input type="checkbox" id="cpAIComboEnabled">启用连招</label>
<label class="st-check"><input type="checkbox" id="cpAIProtect">保护队长</label>
</div>
<details class="st-json">
<summary>高级调参:距离、追击、视野、重试和治疗阈值</summary>
<div class="st-form" id="cpAITuning">
<label>思考间隔(ms)<input data-cp-tuning="think_interval_ms" type="number" min="50" max="2000"></label>
<label>辅助攻击间隔(ms)<input data-cp-tuning="assist_attack_interval_ms" type="number" min="200" max="10000"></label>
<label>辅助横向范围<input data-cp-tuning="assist_leash_x" type="number" min="30" max="500"></label>
<label>辅助纵向范围<input data-cp-tuning="assist_leash_y" type="number" min="20" max="300"></label>
<label>追击范围<input data-cp-tuning="chase_range" type="number" min="20" max="1000"></label>
<label>视野范围<input data-cp-tuning="vision_range" type="number" min="100" max="20000"></label>
<label>目标黏性(ms)<input data-cp-tuning="target_stickiness_ms" type="number" min="0" max="10000"></label>
<label>队长治疗阈值(%)<input data-cp-tuning="heal_leader_percent" type="number" min="0" max="100"></label>
<label>自身治疗阈值(%)<input data-cp-tuning="heal_self_percent" type="number" min="0" max="100"></label>
<label>重试间隔(ms)<input data-cp-tuning="retry_ms" type="number" min="50" max="5000"></label>
<label>辅助重试(ms)<input data-cp-tuning="support_retry_ms" type="number" min="100" max="10000"></label>
<label>忙碌移动(ms)<input data-cp-tuning="busy_move_ms" type="number" min="100" max="10000"></label>
<label>卡住判定(ms)<input data-cp-tuning="stuck_ms" type="number" min="100" max="5000"></label>
<label>绕行等待(ms)<input data-cp-tuning="detour_ms" type="number" min="100" max="5000"></label>
<label>放弃目标(ms)<input data-cp-tuning="give_up_ms" type="number" min="500" max="20000"></label>
<label>跟随距离<input data-cp-tuning="follow_distance" type="number" min="20" max="300"></label>
</div>
</details>
<div class="st-note">AI 策略按角色保存;保存后下一次进入副本生效。</div>
</div>
<div class="st-note" id="cpHint">支援兵按账号隔离,只能选择同一账号下的其他角色。</div>
</div>
</section>
```
---
## 二、侧边栏导航入口(index.html 的 nav 内)
```html
<button class="shell-nav-item" data-shell-page="companion"><span class="nav-ico">👥</span>支援兵</button>
```
---
## 三、JavaScript(shell.js 中的支援兵模块)
插入位置:`// ---------------- 一键换装 ----------------` 区块之后、`// ---------------- 服务端工具 ----------------` 区块之前。
```javascript
// ---------------- 支援兵 ----------------
let cpAccounts = [], cpCharacters = [], cpState = null, cpSelected = new Set();
let cpAIProfiles = {}, cpAISource = '';
let cpLoading = false;
const CP_AI_DEFAULTS = {
behavior:'aggressive', buff_policy:'leader_first', combo_preset:'auto',
combo_enabled:true, aoe_min_targets:3, protect_leader:true,
tuning:{think_interval_ms:120,assist_attack_interval_ms:1800,assist_leash_x:180,assist_leash_y:90,
chase_range:50,vision_range:10000,target_stickiness_ms:1200,heal_leader_percent:85,
heal_self_percent:85,retry_ms:300,support_retry_ms:1500,busy_move_ms:2500,stuck_ms:700,
detour_ms:900,give_up_ms:3500,follow_distance:75}
};
async function loadCompanion() {
if (cpLoading) return;
cpLoading = true;
$('cpStatus').textContent = '读取账号和角色…';
try {
const a = await api('/api/accounts');
const ba = await api('/api/basic-attack');
$('cpBasicAttack').checked = !ba || ba.enabled !== false;
cpAccounts = (a && a.data) || [];
const sel = $('cpAccount');
sel.innerHTML = cpAccounts.map(x => `<option value="${x.account_id || x.username || ''}">${x.username || x.account_id}</option>`).join('');
if (!cpAccounts.length) { $('cpStatus').textContent = '没有账号'; return; }
await loadCompanionCharacters();
$('cpStatus').textContent = '';
} catch (e) {
$('cpStatus').textContent = '读取失败:' + e.message;
} finally { cpLoading = false; }
}
async function loadCompanionCharacters() {
const account = $('cpAccount').value;
const r = await api('/api/characters?account=' + encodeURIComponent(account));
cpCharacters = (r && r.data) || [];
const owner = $('cpOwner');
owner.innerHTML = cpCharacters.map(c => `<option value="${c.character_id}">${c.name} · Lv${c.level}</option>`).join('');
if (cpCharacters.length) await loadCompanionState();
else { $('cpGrid').innerHTML = '<div class="empty-box">当前账号没有角色</div>'; }
}
async function loadCompanionState() {
const owner = $('cpOwner').value;
if (!owner) return;
$('cpStatus').textContent = '读取支援兵配置…';
try {
const r = await api('/api/companion?character=' + encodeURIComponent(owner));
if (!r.ok) { $('cpStatus').textContent = r.error || '读取失败'; return; }
cpState = r.data || {};
const cfg = (cpState.state && cpState.state.config) || {};
$('cpEnabled').checked = !!cfg.enabled;
$('cpBalance').checked = !!cfg.balance_enabled;
$('cpProgression').checked = !!cfg.progression_enabled;
$('cpRandom').checked = !!cfg.random_equipment_enabled;
cpSelected = new Set((cfg.source_character_ids || []).map(String));
cpAIProfiles = JSON.parse(JSON.stringify(cfg.ai_profiles || {}));
cpAISource = [...cpSelected][0] || '';
renderCompanionGrid();
$('cpStatus').textContent = (cpState.state && cpState.state.preflight && cpState.state.preflight.reason_message) || '已读取';
} catch (e) { $('cpStatus').textContent = '读取失败:' + e.message; }
}
function renderCompanionGrid() {
const owner = String($('cpOwner').value || '');
const list = cpCharacters.filter(c => String(c.character_id) !== owner);
const grid = $('cpGrid');
if (!list.length) { grid.innerHTML = '<div class="empty-box">当前账号没有其他角色可作为支援兵</div>'; renderCpAI(); return; }
grid.innerHTML = list.map(c => {
const id = String(c.character_id), selected = cpSelected.has(id);
return `<label class="cp-card ${selected?'selected':''}">
<input type="checkbox" data-cp-source="${id}" ${selected?'checked':''}>
<b>${c.name || id}</b><small>Lv${c.level ?? '-'} · 职业 ${c.job ?? '-'}</small>
</label>`;
}).join('');
grid.querySelectorAll('input[data-cp-source]').forEach(inp => {
inp.onchange = () => {
const id = inp.dataset.cpSource;
if (inp.checked) cpSelected.add(id); else cpSelected.delete(id);
inp.closest('.cp-card').classList.toggle('selected', inp.checked);
renderCpAI();
};
});
renderCpAI();
}
function cpProfile(id) {
const raw = cpAIProfiles[String(id)] || {};
return {
...CP_AI_DEFAULTS, ...raw,
tuning: {...CP_AI_DEFAULTS.tuning, ...(raw.tuning || {})}
};
}
function renderCpAI() {
const select = $('cpAISource');
const ids = [...cpSelected];
if (!ids.includes(cpAISource)) cpAISource = ids[0] || '';
select.innerHTML = ids.map(id => {
const c = cpCharacters.find(x => String(x.character_id) === id);
return `<option value="${id}">${c ? c.name : id}</option>`;
}).join('');
if (cpAISource) select.value = cpAISource;
if (!cpAISource) {
$('cpAIBehavior').value = 'assist'; $('cpAIBuff').value = 'leader_first';
$('cpAIComboPreset').value = 'auto'; $('cpAIAoe').value = 3;
$('cpAIComboEnabled').checked = true; $('cpAIProtect').checked = true;
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
inp.value = CP_AI_DEFAULTS.tuning[inp.dataset.cpTuning] ?? 0;
});
return;
}
const p = cpProfile(cpAISource);
$('cpAIBehavior').value = ['assist','aggressive'].includes(p.behavior) ? p.behavior : 'aggressive';
$('cpAIBuff').value = p.buff_policy || 'leader_first';
$('cpAIComboPreset').value = p.combo_preset || 'auto';
$('cpAIAoe').value = Number(p.aoe_min_targets) || 3;
$('cpAIComboEnabled').checked = !!p.combo_enabled;
$('cpAIProtect').checked = !!p.protect_leader;
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
inp.value = p.tuning[inp.dataset.cpTuning] ?? 0;
});
}
function readCpAI() {
if (!cpAISource) return;
const p = cpProfile(cpAISource);
p.behavior = $('cpAIBehavior').value;
p.buff_policy = $('cpAIBuff').value;
p.combo_preset = $('cpAIComboPreset').value;
p.combo_enabled = $('cpAIComboEnabled').checked;
p.aoe_min_targets = Math.max(2, Math.min(6, Number($('cpAIAoe').value) || 3));
p.protect_leader = $('cpAIProtect').checked;
p.tuning = {...p.tuning};
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
const n = Number(inp.value);
if (Number.isFinite(n)) p.tuning[inp.dataset.cpTuning] = n;
});
cpAIProfiles[String(cpAISource)] = p;
}
$('cpAccount').addEventListener('change', async () => {
try { await loadCompanionCharacters(); } catch (e) { $('cpStatus').textContent = e.message; }
});
$('cpOwner').addEventListener('change', loadCompanionState);
$('cpReload').addEventListener('click', loadCompanion);
$('cpAISource').addEventListener('change', () => { cpAISource = $('cpAISource').value; renderCpAI(); });
['cpAIBehavior','cpAIBuff','cpAIComboPreset','cpAIAoe','cpAIComboEnabled','cpAIProtect'].forEach(id => {
$(id).addEventListener('change', readCpAI);
});
document.querySelectorAll('[data-cp-tuning]').forEach(inp => inp.addEventListener('change', readCpAI));
$('cpBasicAttack').addEventListener('change', async () => {
try {
const r = await post('/api/basic-attack', {enabled: $('cpBasicAttack').checked});
if (!r.ok) $('cpStatus').textContent = r.error || '普攻开关保存失败';
else $('cpStatus').textContent = $('cpBasicAttack').checked
? '已允许支援兵普攻;重启客户端后生效。'
: '已关闭支援兵普攻,技能释放不受影响;重启客户端后生效。';
} catch (e) { $('cpStatus').textContent = '普攻开关保存失败:' + e.message; }
});
$('cpSave').addEventListener('click', async () => {
const owner = $('cpOwner').value;
if (!owner) { $('cpStatus').textContent = '请先选择出战角色'; return; }
if ($('cpEnabled').checked && cpSelected.size === 0) {
$('cpStatus').textContent = '启用支援兵时至少选择一个支援兵角色'; return;
}
const state = (cpState && cpState.state) || {};
const cfg = state.config || {};
const sources = [...cpSelected];
readCpAI();
const payload = {
characterId: String(owner),
expectedRevision: Number(cfg.revision || 0),
enabled: $('cpEnabled').checked,
balanceEnabled: $('cpBalance').checked,
progressionEnabled: $('cpProgression').checked,
randomEquipmentEnabled: $('cpRandom').checked,
sourceCharacterId: sources[0] || '',
sourceCharacterIds: sources,
ai: cfg.ai || {},
aiProfiles: cpAIProfiles
};
$('cpStatus').textContent = '保存中…';
try {
const r = await post('/api/companion', payload);
if (!r.ok) { $('cpStatus').textContent = r.error || '保存失败'; return; }
cpState = r.data || cpState;
$('cpStatus').textContent = '支援兵配置已保存,下一次进入副本生效。';
} catch (e) { $('cpStatus').textContent = '保存失败:' + e.message; }
});
```
同时,需要在 `shellGo` 函数中加回页面切换调用:
```javascript
if (page === 'companion') loadCompanion();
```
以及在 `pages` 数组加回 `'companion'`,在 `crumb` 加回 `companion:'工具 / 支援兵'`。
---
## 四、后端接口(server.py)
### 4.1 GET 路由注册(do_GET 内)
```python
elif path == "/api/companion":
self._api_companion(qs)
elif path == "/api/basic-attack":
self._json({"ok": True, "enabled": not os.path.exists(
os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled"))})
```
### 4.2 POST 路由注册(do_POST 内)
```python
elif path == "/api/companion":
self._api_save_companion(data)
elif path == "/api/basic-attack":
enabled = bool(data.get("enabled", True))
flag = os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled")
try:
if enabled:
if os.path.exists(flag):
os.remove(flag)
else:
os.makedirs(CLIENT_DIR, exist_ok=True)
with open(flag, "w", encoding="utf-8") as f:
f.write("disabled\n")
self._json({"ok": True, "enabled": enabled})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 500)
```
### 4.3 处理函数
```python
# ==================== 支援兵 ====================
def _api_companion(self, qs):
if NATIVE is None or not server_running():
self._json({"ok": False, "error": "服务端未运行,支援兵配置不可用"}, 400)
return
character = (qs.get("character") or qs.get("id") or [""])[0].strip()
if not character:
self._json({"ok": False, "error": "缺少角色 ID"}, 400)
return
try:
self._json({"ok": True, "data": NATIVE.companion_state(character)})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
def _api_save_companion(self, data):
if NATIVE is None or not server_running():
self._json({"ok": False, "error": "服务端未运行,无法保存支援兵配置"}, 400)
return
try:
self._json({"ok": True, "data": NATIVE.companion_save(data)})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
```
---
## 五、CSS 样式(shell.css 中的 cp- 相关)
支援兵页面复用了 `.st-form`、`.st-check`、`.st-json`、`.st-note` 等「服务端工具」样式(这些在服务端工具删除后需要一并保留或从模板恢复),以及以下独立的 cp- 样式(位于 shell.css 明暗主题区域):
```css
.cp-toolbar{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:16px}
.cp-toolbar label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:#9a9eb5}
.cp-toolbar select{min-width:220px}
.cp-flags{display:flex;flex-wrap:wrap;gap:28px;border:1px solid rgba(255,255,255,.1);border-radius:10px}
.cp-title{font-size:15px;font-weight:400;color:#c9beff;margin:0 0 10px}
.cp-grid{display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px}
.cp-card{border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:12px;background:rgba(20,20,36,.55);cursor:pointer}
.cp-card.selected{border-color:rgba(124,92,255,.7);background:rgba(124,92,255,.16)}
.cp-card b{font-size:14px;display:block;color:#eef0fb}
.cp-card small{font-size:12px;color:#9a9eb5}
.cp-card input{margin-right:7px}
.cp-ai{margin-top:20px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(20,20,36,.45);padding:14px}
.cp-ai-head{display:flex;align-items:center;gap:14px;margin-bottom:12px}
.cp-ai-head h3{margin:0;font-size:15px;font-weight:400;color:#c9beff}
.cp-ai-head select{width:240px}
.cp-ai .st-form{margin-bottom:10px}
```
> 注意:`cp-flags` 样式在原文件里是分成两行(续行),恢复时需合并为完整声明。
---
## 六、依赖说明
支援兵页面依赖以下「服务端工具」的通用样式(这些如果被一并删除,恢复支援兵时需一并恢复):
- `.st-form` / `.st-form label` / `.st-form input` / `.st-form select`
- `.st-check` / `.st-check input`
- `.st-json` / `.st-json summary` / `.st-json textarea`
- `.st-note`
依赖的后端接口:
- `/api/accounts`(账号列表)
- `/api/characters?account=`(角色列表)
- `/api/companion`(支援兵状态读取/保存)
- `/api/basic-attack`(普攻开关)
---
## 七、恢复步骤速查
1. `index.html`:粘贴 section + 侧边栏按钮
2. `shell.js`:粘贴 JS 模块,并在 `pages`/`crumb`/`shellGo` 三处加回 `companion`
3. `server.py`:粘贴路由注册 + 两个处理函数
4. `shell.css`:粘贴 cp- 样式(若 st- 样式已删,一并恢复 st- 通用样式)
5. 重新打包
---
(完)
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+1
View File
@@ -9,6 +9,7 @@
<AssemblyName>DNFLoadout</AssemblyName> <AssemblyName>DNFLoadout</AssemblyName>
<RootNamespace>DNFLoadout</RootNamespace> <RootNamespace>DNFLoadout</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\loadout.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+277
View File
@@ -0,0 +1,277 @@
// Command companionoverlay — 支援兵覆盖工具(单文件版)。
//
// 把「支援兵 AIC 覆盖」做成一个可独立发布的可执行程序:内置 companionpvf 与
// profiles.json,用户只需拖入 Script.pvf(或直接双击用默认路径),即可:
//
// 1) 生成覆盖版 PVF(默认输出到同目录 Script.companion.pvf);
// 2) 可选地安装到客户端/服务端目录(自动备份、打印哈希)。
//
// 用法:
//
// 支援兵覆盖工具.exe 交互模式(使用默认客户端路径)
// 支援兵覆盖工具.exe <Script.pvf> 拖拽/命令行:只生成覆盖版
// 支援兵覆盖工具.exe <Script.pvf> --client <客户端目录> --server <服务端数据目录>
package main
import (
"bufio"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
//go:embed companionpvf.exe profiles.json
var assets embed.FS
const (
appTitle = "DNF90 支援兵覆盖工具"
appVersion = "1.0.0"
defaultClient = `D:\90server\Client\Script.pvf`
minOutputBytes = 50 << 20
)
func main() {
fmt.Printf("%s v%s\n", appTitle, appVersion)
fmt.Println(strings.Repeat("=", 60))
args := os.Args[1:]
pvfPath, clientDir, serverDir, install, interactive := parseArgs(args)
if interactive {
pvfPath = prompt("要处理的 Script.pvf 路径", pvfPath)
if !ask("是否同时安装到客户端/服务端目录?(y/N): ") {
install = false
} else {
install = true
clientDir = prompt("客户端目录(含 Script.pvf)", clientDir)
serverDir = prompt("服务端数据目录(含 Script.pvf)", serverDir)
}
}
if !install {
clientDir, serverDir = "", ""
}
if strings.TrimSpace(pvfPath) == "" {
fail("没有指定 Script.pvf")
}
source, err := filepath.Abs(pvfPath)
if err != nil || !exists(source) {
fail("找不到 PVF:" + pvfPath)
}
outPath := strings.TrimSuffix(source, filepath.Ext(source)) + ".companion.pvf"
if dir := filepath.Dir(source); dir != "" {
outPath = filepath.Join(dir, "Script.companion.pvf")
}
fmt.Printf("输入 PVF:%s\n", source)
fmt.Printf("输出 PVF:%s\n", outPath)
if err := runOverlay(source, outPath); err != nil {
fail(err.Error())
}
size := fileSize(outPath)
fmt.Printf("覆盖版已生成:%d 字节(%.1f MB)\n", size, float64(size)/(1<<20))
if size < minOutputBytes {
fail("输出文件过小,疑似失败(原 PVF 未被修改)")
}
hash := fileHash(outPath)
fmt.Printf("覆盖版 SHA256:%s\n", hash)
if install {
if err := installTo(source, outPath, hash, clientDir, serverDir); err != nil {
fail(err.Error())
}
} else {
fmt.Println()
fmt.Println("未安装。手动替换方法:")
fmt.Println(" 客户端:把输出文件改名为 Script.pvf 覆盖到 客户端目录。")
fmt.Println(" 服务端:同样覆盖到 服务端 runtime\\data\\dnf\\Script.pvf。")
fmt.Println(" 完成后重启服务端,再启动游戏。")
}
fmt.Println()
fmt.Println("完成。")
if interactive {
fmt.Print("按回车退出...")
bufio.NewReader(os.Stdin).ReadString('\n')
}
}
func parseArgs(args []string) (pvf, clientDir, serverDir string, install, interactive bool) {
clientDir = `D:\90server\Client`
serverDir = `D:\90server\Server\runtime\data\dnf`
pvf = defaultClient
interactive = len(args) == 0
for i := 0; i < len(args); i++ {
switch args[i] {
case "--client":
if i+1 < len(args) {
clientDir = args[i+1]
install = true
i++
}
case "--server":
if i+1 < len(args) {
serverDir = args[i+1]
install = true
i++
}
case "--install":
install = true
default:
if !strings.HasPrefix(args[i], "--") {
pvf = args[i]
}
}
}
return pvf, clientDir, serverDir, install, interactive
}
func runOverlay(source, outPath string) error {
toolPath := filepath.Join(os.TempDir(), fmt.Sprintf("companionpvf-%d.exe", time.Now().UnixNano()))
toolBytes, err := assets.ReadFile("companionpvf.exe")
if err != nil {
return fmt.Errorf("读取内置工具失败:%w", err)
}
if err := os.WriteFile(toolPath, toolBytes, 0o700); err != nil {
return fmt.Errorf("释放内置工具失败:%w", err)
}
defer os.Remove(toolPath)
profilesPath := filepath.Join(os.TempDir(), fmt.Sprintf("companion-profiles-%d.json", time.Now().UnixNano()))
profilesBytes, err := assets.ReadFile("profiles.json")
if err != nil {
return fmt.Errorf("读取内置配置失败:%w", err)
}
if err := os.WriteFile(profilesPath, profilesBytes, 0o600); err != nil {
return fmt.Errorf("释放内置配置失败:%w", err)
}
defer os.Remove(profilesPath)
fmt.Println("生成覆盖版(约需 10–60 秒)...")
cmd := exec.Command(toolPath, "--profile-overlay", source, outPath, profilesPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("覆盖失败:%w(原 PVF 未被修改)", err)
}
return nil
}
func installTo(source, outPath, hash, clientDir, serverDir string) error {
if hasProcess("DNF") {
return fmt.Errorf("请先关闭 DNF 客户端:PVF 正被占用")
}
targets := []string{
filepath.Join(clientDir, "Script.pvf"),
filepath.Join(serverDir, "Script.pvf"),
}
stamp := time.Now().Format("20060102-150405")
for _, target := range targets {
if !exists(target) {
fmt.Printf("跳过(不存在):%s\n", target)
continue
}
if fileHash(target) == hash {
fmt.Printf("已是最新,跳过:%s\n", target)
continue
}
backup := target + ".bak_before_companion_" + stamp
if err := copyFile(target, backup); err != nil {
return fmt.Errorf("备份失败:%w", err)
}
fmt.Printf("已备份:%s\n", backup)
if err := copyFile(outPath, target); err != nil {
return fmt.Errorf("安装失败:%w", err)
}
fmt.Printf("已安装:%s\n", target)
}
fmt.Println("请重启 DNF90 服务端(服务端启动时加载 PVF),再启动游戏。")
return nil
}
func prompt(label, fallback string) string {
fmt.Printf("%s [%s]: ", label, fallback)
reader := bufio.NewReader(os.Stdin)
text, _ := reader.ReadString('\n')
text = strings.TrimSpace(text)
if text == "" {
return fallback
}
return strings.Trim(text, `"`)
}
func ask(label string) bool {
fmt.Print(label)
reader := bufio.NewReader(os.Stdin)
text, _ := reader.ReadString('\n')
text = strings.ToLower(strings.TrimSpace(text))
return text == "y" || text == "yes"
}
func exists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func fileSize(path string) int64 {
info, err := os.Stat(path)
if err != nil {
return 0
}
return info.Size()
}
func fileHash(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return ""
}
return strings.ToUpper(hex.EncodeToString(h.Sum(nil)))
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
func hasProcess(name string) bool {
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq "+name+".exe", "/NH")
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(strings.ToLower(string(output)), strings.ToLower(name+".exe"))
}
func fail(message string) {
fmt.Fprintf(os.Stderr, "\n[错误] %s\n", message)
fmt.Print("按回车退出...")
bufio.NewReader(os.Stdin).ReadString('\n')
os.Exit(1)
}
@@ -0,0 +1,126 @@
package dnf90
import (
"encoding/json"
"io"
"net/http"
"strings"
appkit "longheng.io/server/internal/platform/app"
httpx "longheng.io/server/internal/platform/httpx"
"longheng.io/server/internal/services/dnfbridge"
)
// companionPanelAdminPath is the local-only endpoint used by the GM tool's
// "支援兵属性" page. It is intentionally unauthenticated: the admin mux binds
// to loopback, like the existing /local endpoints, and the page is served by
// the local GM tool.
const companionPanelAdminPath = "/local/companion-panel"
func registerCompanionPanelAdmin(env *appkit.Env, bridge *dnfbridge.Service) {
if env == nil || env.AdminMux == nil || bridge == nil {
return
}
env.AdminMux.HandleFunc(companionPanelAdminPath, func(w http.ResponseWriter, r *http.Request) {
applyCompanionPanelCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
switch r.Method {
case http.MethodGet:
characterID := strings.TrimSpace(r.URL.Query().Get("character_id"))
if characterID == "" {
characterID = strings.TrimSpace(r.URL.Query().Get("characterId"))
}
values, found, err := bridge.CompanionPanelValues(r.Context(), characterID)
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"character_id": characterID,
"found": found,
"values": values,
})
case http.MethodPost:
var body struct {
CharacterID string `json:"character_id"`
Values map[string]string `json:"values"`
}
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
if err := decoder.Decode(&body); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if strings.TrimSpace(body.CharacterID) == "" {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "character_id is required",
})
return
}
if err := bridge.SaveCompanionPanelValues(r.Context(), body.CharacterID, body.Values); err != nil {
httpx.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
values, found, _ := bridge.CompanionPanelValues(r.Context(), body.CharacterID)
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"character_id": body.CharacterID,
"found": found,
"values": values,
})
default:
w.Header().Set("Allow", "GET, POST, OPTIONS")
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{
"error": "method not allowed",
})
}
})
// Base-estimate generation: PVF growth + worn equipment + weapon forging.
// Enchant cards, amplification, reinforcement, quality, sets, titles, auras
// and pets are intentionally not modelled; the GM page marks the result as
// an estimate and lets the operator correct it.
env.AdminMux.HandleFunc(companionPanelGeneratePath, func(w http.ResponseWriter, r *http.Request) {
applyCompanionPanelCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST, OPTIONS")
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{
"error": "method not allowed",
})
return
}
var body struct {
CharacterID string `json:"character_id"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
values, err := bridge.GenerateCompanionPanelSnapshot(r.Context(), body.CharacterID)
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"character_id": body.CharacterID,
"estimate": true,
"values": values,
})
})
}
const companionPanelGeneratePath = "/local/companion-panel/generate"
func applyCompanionPanelCORS(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Admin-Token")
w.Header().Set("Access-Control-Allow-Private-Network", "true")
}
@@ -52,6 +52,10 @@ const (
// gmChannelReloadOperation 频道热重载:重读 channel_info.etc 并热替换频道表 + // gmChannelReloadOperation 频道热重载:重读 channel_info.etc 并热替换频道表 +
// 对账游戏端口监听,不需要重启进程。 // 对账游戏端口监听,不需要重启进程。
gmChannelReloadOperation = "POST /dnf/gm/channels/reload" gmChannelReloadOperation = "POST /dnf/gm/channels/reload"
// gmActivitySwitchesOperation 活动开关设置:切换某活动是否下发。
gmActivitySwitchesOperation = "POST /dnf/gm/activity-switches"
// gmChannelSwitchOperation 频道开关设置:切换某频道是否启用。
gmChannelSwitchOperation = "POST /dnf/gm/channel-switch"
// gmRecordQueryOperation 记录查询。只读操作,但沿用统一的操作串便于审计日志对齐。 // gmRecordQueryOperation 记录查询。只读操作,但沿用统一的操作串便于审计日志对齐。
gmRecordQueryOperation = "POST /dnf/gm/records/query" gmRecordQueryOperation = "POST /dnf/gm/records/query"
) )
@@ -125,6 +129,58 @@ func registerGMAdminRoutes(env *appkit.Env, service *dnfbridge.Service) {
} }
writeGMData(w, http.StatusOK, result) writeGMData(w, http.StatusOK, result)
})) }))
// 活动开关:读取当前各活动的开关状态(供启动器「游戏设置 → 活动开关」展示)。
env.AdminMux.HandleFunc("GET /dnf/gm/activity-switches", read(func(w http.ResponseWriter, r *http.Request) {
switches, err := service.GMActivitySwitches(r.Context())
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, switches)
}))
// 活动开关:切换某活动的开/关。
env.AdminMux.HandleFunc("POST /dnf/gm/activity-switches", write(gmActivitySwitchesOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Key string `json:"key"`
Enabled bool `json:"enabled"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
switches, err := service.GMSetActivitySwitch(r.Context(), request.Key, request.Enabled)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, switches)
}))
// 频道开关:读取当前各频道的启用状态(含被手动关闭的频道)。
env.AdminMux.HandleFunc("GET /dnf/gm/channel-switches", read(func(w http.ResponseWriter, r *http.Request) {
switches, err := service.GMChannelSwitches(r.Context())
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, switches)
}))
// 频道开关:切换某频道的启用/停用(改运行时目录并热对账端口,无需重启)。
env.AdminMux.HandleFunc("POST /dnf/gm/channel-switch", write(gmChannelSwitchOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
ChannelID int `json:"channel_id"`
Enabled bool `json:"enabled"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
switches, err := service.GMSetChannelSwitch(r.Context(), request.ChannelID, request.Enabled)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, switches)
}))
// 记录查询(点券消费/对话/副本)。三类记录都落在服务端的 JSONL 文件里, // 记录查询(点券消费/对话/副本)。三类记录都落在服务端的 JSONL 文件里,
// 这里只做检索,不改任何运行态,所以走 read()。 // 这里只做检索,不改任何运行态,所以走 read()。
env.AdminMux.HandleFunc("GET /dnf/gm/records/meta", read(func(w http.ResponseWriter, r *http.Request) { env.AdminMux.HandleFunc("GET /dnf/gm/records/meta", read(func(w http.ResponseWriter, r *http.Request) {
@@ -769,6 +825,7 @@ func registerGMAdminRoutes(env *appkit.Env, service *dnfbridge.Service) {
ExpireDays int `json:"expire_days"` ExpireDays int `json:"expire_days"`
Reinforce int `json:"reinforce"` Reinforce int `json:"reinforce"`
AmplifyType int `json:"amplify_type"` AmplifyType int `json:"amplify_type"`
AmplifyLevel int `json:"amplify_level"`
EquipmentQuality string `json:"equipment_quality"` EquipmentQuality string `json:"equipment_quality"`
Forging int `json:"forging"` Forging int `json:"forging"`
EmblemItemIDs []uint32 `json:"emblem_item_ids"` EmblemItemIDs []uint32 `json:"emblem_item_ids"`
@@ -789,6 +846,7 @@ func registerGMAdminRoutes(env *appkit.Env, service *dnfbridge.Service) {
ExpireDays: request.ExpireDays, ExpireDays: request.ExpireDays,
Reinforce: request.Reinforce, Reinforce: request.Reinforce,
AmplifyType: request.AmplifyType, AmplifyType: request.AmplifyType,
AmplifyLevel: request.AmplifyLevel,
EquipmentQuality: request.EquipmentQuality, EquipmentQuality: request.EquipmentQuality,
Forging: request.Forging, Forging: request.Forging,
EmblemItemIDs: request.EmblemItemIDs, EmblemItemIDs: request.EmblemItemIDs,
@@ -34,6 +34,9 @@ type Options struct {
GamePortBase int GamePortBase int
MaxUsers int MaxUsers int
CurrentUsers int CurrentUsers int
// DisabledIDs 是运行时被关闭的频道 ID 集合(来自启动器「频道开关」)。
// 这些频道不进入目录,也不会开游戏端口监听。
DisabledIDs map[int]bool
} }
// Catalog 是从 channel_info.etc 派生的只读频道目录。 // Catalog 是从 channel_info.etc 派生的只读频道目录。
@@ -56,6 +59,9 @@ func New(index *channelinfo.Index, options Options) (*Catalog, error) {
if item.ID <= 0 { if item.ID <= 0 {
continue continue
} }
if options.DisabledIDs != nil && options.DisabledIDs[item.ID] {
continue
}
if _, ok := seen[item.ID]; ok { if _, ok := seen[item.ID]; ok {
continue continue
} }
@@ -17,7 +17,13 @@ import (
// 严格版:源表缺少引导频道时直接失败。只开少数频道(甚至只开特殊区域频道)的运维配置 // 严格版:源表缺少引导频道时直接失败。只开少数频道(甚至只开特殊区域频道)的运维配置
// 请走 Build90CNOnlineScriptAllowingMissingBootstrap。 // 请走 Build90CNOnlineScriptAllowingMissingBootstrap。
func Build90CNOnlineScript(data []byte, sourceServerID, advertiseServerID, bootstrapChannelID int) ([]byte, error) { func Build90CNOnlineScript(data []byte, sourceServerID, advertiseServerID, bootstrapChannelID int) ([]byte, error) {
script, bootstrapFound, err := build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID) return Build90CNOnlineScriptFiltered(data, sourceServerID, advertiseServerID, bootstrapChannelID, nil)
}
// Build90CNOnlineScriptFiltered 是 Build90CNOnlineScript 的带禁用频道过滤版。
// disabledIDs 里的频道不会进入下发给客户端的频道表(与 channelcatalog 的过滤保持一致)。
func Build90CNOnlineScriptFiltered(data []byte, sourceServerID, advertiseServerID, bootstrapChannelID int, disabledIDs map[int]bool) ([]byte, error) {
script, bootstrapFound, err := build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID, disabledIDs)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -45,12 +51,22 @@ func Build90CNOnlineScriptAllowingMissingBootstrap(
data []byte, data []byte,
sourceServerID, advertiseServerID, bootstrapChannelID int, sourceServerID, advertiseServerID, bootstrapChannelID int,
) ([]byte, bool, error) { ) ([]byte, bool, error) {
return build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID) return build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID, nil)
}
// Build90CNOnlineScriptAllowingMissingBootstrapFiltered 是带禁用频道过滤的宽松版。
func Build90CNOnlineScriptAllowingMissingBootstrapFiltered(
data []byte,
sourceServerID, advertiseServerID, bootstrapChannelID int,
disabledIDs map[int]bool,
) ([]byte, bool, error) {
return build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID, disabledIDs)
} }
func build90CNOnlineScript( func build90CNOnlineScript(
data []byte, data []byte,
sourceServerID, advertiseServerID, bootstrapChannelID int, sourceServerID, advertiseServerID, bootstrapChannelID int,
disabledIDs map[int]bool,
) ([]byte, bool, error) { ) ([]byte, bool, error) {
if sourceServerID <= 0 || advertiseServerID < 0 || bootstrapChannelID <= 0 { if sourceServerID <= 0 || advertiseServerID < 0 || bootstrapChannelID <= 0 {
return nil, false, errors.New("channel script source, advertisement, and bootstrap IDs are invalid") return nil, false, errors.New("channel script source, advertisement, and bootstrap IDs are invalid")
@@ -124,6 +140,10 @@ func build90CNOnlineScript(
if activeServer == sourceServerID { if activeServer == sourceServerID {
record := append([]string(nil), fields[start:end]...) record := append([]string(nil), fields[start:end]...)
id, _ := strconv.Atoi(record[0]) id, _ := strconv.Atoi(record[0])
if disabledIDs != nil && disabledIDs[id] {
start = end
continue
}
channelType, _ := strconv.Atoi(record[2]) channelType, _ := strconv.Atoi(record[2])
record[2] = strconv.Itoa(OnlineTypeFor90CN(id, channelType)) record[2] = strconv.Itoa(OnlineTypeFor90CN(id, channelType))
if id == bootstrapChannelID { if id == bootstrapChannelID {
@@ -0,0 +1,234 @@
package companion
import (
"sort"
"strconv"
"strings"
)
// TeamMaxCompanions is the number of support soldiers that may enter with the
// owner. The current client's ordinary party has four slots: the owner plus
// three companions. Keeping the limit here (rather than in the bridge) makes
// every matcher, GM preflight and status projection agree.
const TeamMaxCompanions = 3
// DefaultTeamAIProfile is the fallback policy for an auto-matched teammate
// that has no per-character GM profile. Team members must actually join the
// fight, so the default is the aggressive AIC variant; operators can still
// override any character through ai_profiles (assist/aggressive).
func DefaultTeamAIProfile(classID uint16) AIProfile {
profile := DefaultAIProfile(classID)
profile.Behavior = "aggressive"
return profile
}
// TeamCandidate is the detached roster row used by the automatic matcher. It
// deliberately contains no session, inventory or mutable character pointers:
// the bridge reads one consistent repository list and passes only identity,
// level, job and online state here.
type TeamCandidate struct {
CharacterID string
Level int
Job string
Slot int
Online bool
Deleted bool
}
// TeamSkip explains why a candidate did not enter the frozen run. It is
// evidence for the GM status and packet log, not a client-facing error.
type TeamSkip struct {
CharacterID string
Reason string
}
// TeamMatch is the deterministic result of one dungeon-entry match.
type TeamMatch struct {
Selected []string
Skipped []TeamSkip
}
const (
TeamSkipOwner = "source_is_owner"
TeamSkipOnline = "source_online"
TeamSkipUnavailable = "source_unavailable"
TeamSkipLevel = "level_unsupported"
TeamSkipDeleted = "source_deleted"
TeamSkipInvalid = "source_invalid"
TeamSkipJob = "job_unsupported"
TeamSkipPartyFull = "party_full"
)
// MatchTeam forms the teammate roster for one dungeon.
//
// Rules, in order:
// 1. The GM's explicit selection is authoritative and keeps its order.
// 2. Remaining slots are filled automatically from the same account's offline,
// non-deleted characters.
// 3. Automatic picks prefer higher level first, then one character per job
// before duplicating a job, then the lower slot/id for stable output.
//
// The result is frozen by the caller. This function is pure so the selection
// cannot depend on map iteration order or on repository timing.
func MatchTeam(ownerID string, configured []string, candidates []TeamCandidate, limit int) TeamMatch {
ownerID = strings.TrimSpace(ownerID)
if limit <= 0 || limit > TeamMaxCompanions {
limit = TeamMaxCompanions
}
byID := make(map[string]TeamCandidate, len(candidates))
for _, candidate := range candidates {
id := strings.TrimSpace(candidate.CharacterID)
if !validCharacterID(id) {
continue
}
if _, exists := byID[id]; exists {
continue
}
candidate.CharacterID = id
candidate.Job = strings.TrimSpace(candidate.Job)
byID[id] = candidate
}
result := TeamMatch{
Selected: make([]string, 0, limit),
Skipped: make([]TeamSkip, 0, 4),
}
selected := make(map[string]struct{}, limit)
skip := func(id, reason string) {
if len(result.Skipped) >= 16 {
return
}
result.Skipped = append(result.Skipped, TeamSkip{CharacterID: id, Reason: reason})
}
selectID := func(id string) bool {
if _, exists := selected[id]; exists {
return true
}
if len(result.Selected) >= limit {
return false
}
selected[id] = struct{}{}
result.Selected = append(result.Selected, id)
return true
}
// Phase 1: preserve the operator's manual choice.
for _, raw := range configured {
id := strings.TrimSpace(raw)
if !validCharacterID(id) {
skip(id, TeamSkipInvalid)
continue
}
if _, exists := selected[id]; exists {
continue
}
if id == ownerID {
skip(id, TeamSkipOwner)
continue
}
candidate, found := byID[id]
if !found {
skip(id, TeamSkipUnavailable)
continue
}
if reason := teamCandidateReason(candidate); reason != "" {
skip(id, reason)
continue
}
if !selectID(id) {
skip(id, TeamSkipPartyFull)
}
}
// Phase 2: auto-fill. Sorting is made total (level, slot, numeric id) so a
// database returning rows in a different order cannot change the roster.
available := make([]TeamCandidate, 0, len(byID))
for id, candidate := range byID {
if id == ownerID {
continue
}
if _, exists := selected[id]; exists {
continue
}
if teamCandidateReason(candidate) != "" {
continue
}
available = append(available, candidate)
}
sort.Slice(available, func(i, j int) bool {
if available[i].Level != available[j].Level {
return available[i].Level > available[j].Level
}
if available[i].Slot != available[j].Slot {
return available[i].Slot < available[j].Slot
}
return teamNumericID(available[i].CharacterID) < teamNumericID(available[j].CharacterID)
})
usedJobs := make(map[string]struct{}, limit)
for id := range selected {
if candidate, ok := byID[id]; ok {
usedJobs[teamJobKey(candidate.Job)] = struct{}{}
}
}
// First pass: one character per job keeps the team varied.
for _, candidate := range available {
if len(result.Selected) >= limit {
break
}
job := teamJobKey(candidate.Job)
if job == "" {
continue
}
if _, exists := usedJobs[job]; exists {
continue
}
if selectID(candidate.CharacterID) {
usedJobs[job] = struct{}{}
}
}
// Second pass: fill the remaining slots by level/slot/id.
for _, candidate := range available {
if len(result.Selected) >= limit {
break
}
if _, exists := selected[candidate.CharacterID]; exists {
continue
}
selectID(candidate.CharacterID)
}
return result
}
func teamCandidateReason(candidate TeamCandidate) string {
if candidate.Deleted {
return TeamSkipDeleted
}
if candidate.Level < 1 || candidate.Level > 90 {
return TeamSkipLevel
}
if candidate.Online {
return TeamSkipOnline
}
if _, err := strconv.ParseUint(strings.TrimSpace(candidate.Job), 10, 8); err != nil {
return TeamSkipJob
}
return ""
}
func teamJobKey(job string) string {
parsed, err := strconv.ParseUint(strings.TrimSpace(job), 10, 8)
if err != nil {
return ""
}
return strconv.FormatUint(parsed, 10)
}
func teamNumericID(id string) int {
parsed, err := strconv.Atoi(strings.TrimSpace(id))
if err != nil {
return int(^uint(0) >> 1)
}
return parsed
}
@@ -0,0 +1,95 @@
package companion
import (
"reflect"
"testing"
)
func TestMatchTeamKeepsManualSelectionAndFillsByLevelAndJob(t *testing.T) {
candidates := []TeamCandidate{
{CharacterID: "1", Level: 90, Job: "0", Slot: 0},
{CharacterID: "2", Level: 90, Job: "12", Slot: 1},
{CharacterID: "3", Level: 90, Job: "3", Slot: 2},
{CharacterID: "4", Level: 90, Job: "0", Slot: 3},
{CharacterID: "5", Level: 21, Job: "2", Slot: 4},
}
match := MatchTeam("1", []string{"4"}, candidates, 3)
if want := []string{"4", "2", "3"}; !reflect.DeepEqual(match.Selected, want) {
t.Fatalf("selected=%v want=%v", match.Selected, want)
}
}
func TestMatchTeamSkipsOwnerOnlineDeletedLowLevelAndInvalid(t *testing.T) {
candidates := []TeamCandidate{
{CharacterID: "1", Level: 90, Job: "0", Slot: 0}, // owner
{CharacterID: "2", Level: 90, Job: "0", Slot: 1, Online: true}, // online
{CharacterID: "3", Level: 90, Job: "0", Slot: 2, Deleted: true},
{CharacterID: "4", Level: 0, Job: "0", Slot: 3},
{CharacterID: "5", Level: 90, Job: "x", Slot: 4},
{CharacterID: "0", Level: 90, Job: "0", Slot: 5},
{CharacterID: "6", Level: 90, Job: "1", Slot: 6},
}
match := MatchTeam("1", []string{"2", "3", "4", "5", "0", "99"}, candidates, 3)
if want := []string{"6"}; !reflect.DeepEqual(match.Selected, want) {
t.Fatalf("selected=%v want=%v", match.Selected, want)
}
assertSkip(t, match, "2") // online
assertSkip(t, match, "3") // deleted
assertSkip(t, match, "4") // low level
assertSkip(t, match, "5") // unsupported job
assertSkip(t, match, "99") // missing
}
func TestMatchTeamCapsAtThreeAndIsDeterministic(t *testing.T) {
candidates := []TeamCandidate{
{CharacterID: "9", Level: 90, Job: "0", Slot: 9},
{CharacterID: "7", Level: 90, Job: "2", Slot: 7},
{CharacterID: "8", Level: 90, Job: "4", Slot: 8},
{CharacterID: "6", Level: 90, Job: "1", Slot: 6},
}
first := MatchTeam("1", nil, candidates, 99)
if len(first.Selected) != TeamMaxCompanions {
t.Fatalf("selected=%v want %d entries", first.Selected, TeamMaxCompanions)
}
// Slot order wins inside the same-levelled, different-job set.
if want := []string{"6", "7", "8"}; !reflect.DeepEqual(first.Selected, want) {
t.Fatalf("selected=%v want=%v", first.Selected, want)
}
for i := 0; i < 20; i++ {
again := MatchTeam("1", nil, candidates, 99)
if !reflect.DeepEqual(again.Selected, first.Selected) {
t.Fatalf("iteration %d selected=%v want=%v", i, again.Selected, first.Selected)
}
}
}
func TestMatchTeamEmptyRosterReturnsNoSelection(t *testing.T) {
match := MatchTeam("1", []string{"2"}, nil, 3)
if len(match.Selected) != 0 {
t.Fatalf("selected=%v want empty", match.Selected)
}
assertSkip(t, match, "2")
}
func TestDefaultTeamAIProfileIsAggressiveAndJobScoped(t *testing.T) {
profile := DefaultTeamAIProfile(12)
if profile.Behavior != "aggressive" {
t.Fatalf("behavior=%q want aggressive", profile.Behavior)
}
if profile.ClassID != 12 {
t.Fatalf("class id=%d want 12", profile.ClassID)
}
if err := profile.Validate(); err != nil {
t.Fatalf("default team profile is invalid: %v", err)
}
}
func assertSkip(t *testing.T, match TeamMatch, id string) {
t.Helper()
for _, skipped := range match.Skipped {
if skipped.CharacterID == id {
return
}
}
t.Fatalf("candidate %s was not recorded as skipped: %+v", id, match.Skipped)
}
@@ -373,7 +373,7 @@ func (s *Service) buildScriptBody() ([]byte, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
script, err = s.buildChannelScript(raw) script, err = s.buildChannelScriptFiltered(raw, s.loadDisabledChannelIDs())
if err != nil { if err != nil {
return nil, fmt.Errorf("build 90CN online channel script: %w", err) return nil, fmt.Errorf("build 90CN online channel script: %w", err)
} }
@@ -10,8 +10,8 @@ const (
boundMovieSHA256 = "D62E3623C39CD9FCED5F6C4C7F5E6E18BEB69EB269ABF5D713EDE08EE8C926A1" boundMovieSHA256 = "D62E3623C39CD9FCED5F6C4C7F5E6E18BEB69EB269ABF5D713EDE08EE8C926A1"
boundMovieBytes = int64(22846532) boundMovieBytes = int64(22846532)
boundDllSHA256 = "CF2A0C4626DF2A5D056DC87CFB3D6AA07BC7A4EF7888B5B3B80A006BAD2204AF" boundDllSHA256 = "186A2C95D04B5E0D574D1A66EE097D2B83E41995E50A7C60F7F8620D93C89F23"
boundDllBytes = int64(1229824) boundDllBytes = int64(1260032)
) )
@@ -63,6 +63,16 @@ type currentDungeonCompanionRun struct {
Enabled bool Enabled bool
BalanceEnabled bool BalanceEnabled bool
RandomEquipmentEnabled bool RandomEquipmentEnabled bool
TeamMode bool
TeamLeaderID uint16
TeamSize int
Online bool
OnlineAt time.Time
OfflineAt time.Time
OfflineReason string
LogoutSent bool
NativePartyID uint16
NativePartySent bool
Ready bool Ready bool
Reason string Reason string
ReasonMessage string ReasonMessage string
@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
"fmt"
"os" "os"
"sort" "sort"
"strconv" "strconv"
@@ -369,6 +370,22 @@ func mustProjectionCharacterID(value string) uint64 {
return parsed return parsed
} }
// buildCurrentDungeonCompanionOfflineBody is the exact present=0 projection
// body. The client's decoder requires all identity/count fields to be zero,
// the body length to be exactly the 72-byte header, and a non-zero revision.
// One body clears the whole squad, so the caller sends it once per run.
func buildCurrentDungeonCompanionOfflineBody(revision uint64) []byte {
if revision == 0 {
revision = 1
}
body := make([]byte, currentDungeonCompanionProjectionHeaderSize)
binary.LittleEndian.PutUint32(body[0:4], currentDungeonCompanionProjectionMagic)
body[4] = currentDungeonCompanionProjectionVersion
body[5] = 0
binary.LittleEndian.PutUint64(body[8:16], revision)
return body
}
func (s *Service) currentDungeonCompanionProjectionSourceID( func (s *Service) currentDungeonCompanionProjectionSourceID(
ctx context.Context, ctx context.Context,
session *gameSession, session *gameSession,
@@ -531,6 +548,22 @@ func (s *Service) sendCurrentDungeonCompanionProjection(
"private_opcode", currentDungeonCompanionProjectionOpcode, "private_opcode", currentDungeonCompanionProjectionOpcode,
"body_len", len(body), "body_len", len(body),
"template_mutation", false) "template_mutation", false)
return s.sendGameUpperRawClass( if err := s.sendGameUpperRawClass(
session, currentDungeonCompanionProjectionOpcode, body, 0) session, currentDungeonCompanionProjectionOpcode, body, 0); err != nil {
return err
}
// The dungeon is now loaded on the client (projection is sent after op29 and
// player placement), so a notice here is visible instead of being swallowed
// by the loading screen.
if repos.Settings != nil {
if snapshot, found := loadCompanionPanelSnapshot(ctx, repos,
strconv.FormatUint(uint64(sourceID), 10)); found {
_ = s.sendCurrentClientNotice(session,
fmt.Sprintf("支援兵面板 HP=%d MP=%d 力量=%d 物攻=%d 物防=%d",
snapshot.HPMax, snapshot.MPMax, snapshot.Strength,
snapshot.PhysicalAttack, snapshot.PhysicalDefense),
currentClientNoticeDefaultColor, "companion-panel-summary")
}
}
return nil
} }
@@ -0,0 +1,544 @@
package dnfbridge
import (
"context"
"encoding/binary"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
)
// Headless volunteer sessions.
//
// These are real game sessions from the server's point of view: they are in
// Service.gameSessions, have a character generation, a native party peer
// endpoint, and can be driven through the ordinary party/dungeon owners. Their
// transport is a sink: outbound packets are accepted and discarded because no
// client is attached. The owner's real client receives the normal party
// prelude (op11/op9/op153 + per-member mode0/mode1) and therefore sees real
// party actors, not the APC projection.
//
// This path is gated and OFF by default while the AIC action driver is not yet
// attached.
const (
currentCompanionHeadlessEnv = "DNF_COMPANION_HEADLESS"
currentCompanionHeadlessFlagFile = "companion_headless.enabled"
currentCompanionHeadlessPortBase = 45000
)
func currentCompanionHeadlessEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentCompanionHeadlessEnv))) {
case "0", "off", "false", "no":
return false
}
if _, err := os.Stat(currentCompanionHeadlessFlagFile); err == nil {
return true
}
return false
}
func headlessDebug(message string) {
file, err := os.OpenFile("companion_headless_debug.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return
}
defer file.Close()
_, _ = file.WriteString(time.Now().Format("15:04:05.000") + " " + message + "\n")
}
type companionHeadlessConn struct {
mu sync.Mutex
closed bool
port int
}
func (c *companionHeadlessConn) Read([]byte) (int, error) { return 0, io.EOF }
func (c *companionHeadlessConn) Write(data []byte) (int, error) {
return len(data), nil
}
func (c *companionHeadlessConn) Close() error {
c.mu.Lock()
c.closed = true
c.mu.Unlock()
return nil
}
func (c *companionHeadlessConn) LocalAddr() net.Addr {
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: c.port}
}
func (c *companionHeadlessConn) RemoteAddr() net.Addr {
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: c.port}
}
func (c *companionHeadlessConn) SetDeadline(time.Time) error { return nil }
func (c *companionHeadlessConn) SetReadDeadline(time.Time) error { return nil }
func (c *companionHeadlessConn) SetWriteDeadline(time.Time) error { return nil }
func (s *Service) ensureHeadlessCompanionSessions(
ctx context.Context,
ownerSession *gameSession,
sources []string,
accountID string,
) []uint16 {
if s == nil || ownerSession == nil || len(sources) == 0 {
return nil
}
headlessDebug(fmt.Sprintf("ensure_sessions owner=%d sources=%v", ownerSession.selectedCharacterID, sources))
created := make([]uint16, 0, len(sources))
for _, raw := range sources {
id, ok := currentDungeonCompanionProjectionSourceIDValue(raw)
if !ok || id == ownerSession.selectedCharacterID {
continue
}
if real, online := s.onlineGameSession(id); online && real != nil {
if !real.companionHeadless {
continue
}
created = append(created, id)
continue
}
session := &gameSession{
conn: &companionHeadlessConn{port: currentCompanionHeadlessPortBase + int(id)},
connID: "companion-headless-" + strconv.FormatUint(uint64(id), 10),
accountID: accountID,
selectedCharacterID: id,
characterGeneration: s.nextCompanionGeneration(),
channel: ownerSession.channel,
companionHeadless: true,
companionVirtual: false,
companionOwnerID: ownerSession.selectedCharacterID,
}
session.partyPeerMu.Lock()
session.partyPeer = currentPartyPeerEndpointRegistration{
InnerIPv4: [4]byte{127, 0, 0, 1},
OuterIPv4: [4]byte{127, 0, 0, 1},
Port: uint16(currentCompanionHeadlessPortBase + int(id)),
MTU: currentPartyPeerDefaultPathMTU,
}
session.partyRoutingKey = s.nextPartyRoutingKey.Add(1)
session.partyPathMTU = currentPartyPeerDefaultPathMTU
session.partyPeerMu.Unlock()
session.townMu.Lock()
session.townPositionSnapshot = ownerSession.townPositionSnapshot
session.townMu.Unlock()
s.mu.Lock()
if s.gameSessions == nil {
s.gameSessions = make(map[uint16]*gameSession)
}
s.gameSessions[id] = session
s.mu.Unlock()
go s.runHeadlessCompanionAIC(session)
created = append(created, id)
}
if len(created) != 0 {
headlessDebug(fmt.Sprintf("sessions_created ids=%v", created))
s.logGameEvent(ownerSession, "game-companion-headless-sessions-online",
"owner_character_id", ownerSession.selectedCharacterID,
"character_ids", uint16ListLog(created))
}
return created
}
// dropHeadlessCompanionSessionsForOwner removes the previous login's headless
// sessions when the feature is switched off. Without this, a relog after
// deleting the flag would leave the synthetic party members in the manager and
// the client would stay at "connecting".
func (s *Service) dropHeadlessCompanionSessionsForOwner(ownerID uint16) {
if s == nil || ownerID == 0 {
return
}
manager := s.runtimePartyManagerForService()
removed := make([]uint16, 0, 4)
type removedMember struct {
id uint16
generation uint64
}
members := make([]removedMember, 0, 4)
s.mu.Lock()
for id, session := range s.gameSessions {
if session == nil || !session.companionHeadless || session.companionOwnerID != ownerID {
continue
}
delete(s.gameSessions, id)
removed = append(removed, id)
members = append(members, removedMember{id: id, generation: session.characterGeneration})
}
s.mu.Unlock()
if manager != nil {
for _, member := range members {
manager.Leave(member.id, member.generation)
}
}
if len(removed) != 0 {
s.logPacketEvent("game-companion-headless-sessions-dropped",
"owner_character_id", ownerID,
"character_ids", uint16ListLog(removed))
}
}
// CompanionForceEnter drives the ordinary op16 dungeon entry for an online
// character without requiring the client to send op16. It exists for the
// headless-party PoC: the client's party window can be stuck on its P2P gate
// even though the server party is complete, which blocks the only packet that
// would start the entry.
func (s *Service) CompanionForceEnter(characterID uint16, dungeonID uint32) error {
if s == nil || characterID == 0 || dungeonID == 0 {
return fmt.Errorf("companion force enter needs character and dungeon")
}
session, online := s.onlineGameSession(characterID)
if !online || session == nil {
return fmt.Errorf("character %d is not online", characterID)
}
body := make([]byte, dungeoncmd.SelectDungeonRequestSize)
binary.LittleEndian.PutUint32(body[0:4], dungeonID)
body[4] = 0 // difficulty
binary.LittleEndian.PutUint16(body[5:7], 0)
body[7] = 0 // selection mode
body[8] = 0 // runtime state
binary.LittleEndian.PutUint16(body[9:11], 0)
binary.LittleEndian.PutUint32(body[11:15], 0)
body[15] = 0 // the current 21-byte writer rejects a non-zero value here
binary.LittleEndian.PutUint32(body[16:20],
uint32(currentSceneActorObjectKey(characterID)))
body[20] = 0
session.companionForceEnter = true
defer func() { session.companionForceEnter = false }()
s.ensureForceSelectorOrigin(session)
headlessDebug(fmt.Sprintf("force_enter character=%d dungeon=%d", characterID, dungeonID))
return s.handleDungeonSelectUpper(session, body)
}
func (s *Service) ensureForceSelectorOrigin(session *gameSession) bool {
if s == nil || session == nil {
return false
}
session.townMu.Lock()
if session.townSelectorOriginBound {
session.townMu.Unlock()
return true
}
origin := session.townPositionSnapshot
session.townMu.Unlock()
// The live town snapshot carries the current area (for example 38/2), while
// the dungeon return origin is validated against the durable character
// location (38/1). Use the durable ids and only borrow x/y from the live
// position.
if repos, ok := s.repositoryGroup(); ok && repos.Character != nil {
if record, found, err := repos.Character.Load(context.Background(),
strconv.FormatUint(uint64(session.selectedCharacterID), 10)); err == nil && found {
townID := byte(numericCharacterStatValue(record, "town_id"))
areaID := byte(numericCharacterStatValue(record, "area_id"))
if townID == 0 {
townID = origin.TownID
}
if areaID == 0 {
areaID = origin.AreaID
}
origin.TownID = townID
origin.AreaID = areaID
if !origin.PositionValid {
origin.PositionX = uint16(numericCharacterStatValue(record, "pos_x"))
origin.PositionY = uint16(numericCharacterStatValue(record, "pos_y"))
origin.MovementCode = byte(numericCharacterStatValue(record, "direction"))
origin.PositionValid = true
}
}
}
if !origin.PositionValid {
headlessDebug(fmt.Sprintf("force_origin_unavailable char=%d", session.selectedCharacterID))
return false
}
origin.CharacterID = session.selectedCharacterID
session.townMu.Lock()
session.townSelectorOriginSnapshot = origin
session.townSelectorOriginBound = true
session.townMu.Unlock()
headlessDebug(fmt.Sprintf("force_origin_bound char=%d town=%d area=%d x=%d y=%d",
session.selectedCharacterID, origin.TownID, origin.AreaID, origin.PositionX, origin.PositionY))
return true
}
func (s *Service) bindHeadlessSelectorOrigin(leader, follower *gameSession) bool {
if s == nil || leader == nil || follower == nil {
return false
}
leader.townMu.Lock()
origin := leader.townSelectorOriginSnapshot
bound := leader.townSelectorOriginBound
leader.townMu.Unlock()
if !bound || !origin.PositionValid {
headlessDebug(fmt.Sprintf("selector_origin_missing char=%d bound=%v valid=%v",
follower.selectedCharacterID, bound, origin.PositionValid))
return false
}
origin.CharacterID = follower.selectedCharacterID
follower.townMu.Lock()
follower.townSelectorOriginSnapshot = origin
follower.townSelectorOriginBound = true
follower.townMu.Unlock()
headlessDebug(fmt.Sprintf("selector_origin_bound char=%d town=%d area=%d x=%d y=%d",
follower.selectedCharacterID, origin.TownID, origin.AreaID, origin.PositionX, origin.PositionY))
return true
}
func (s *Service) ensureHeadlessPartyActorProjections(ctx context.Context, leader *gameSession, runtime *runtimeDungeonState) {
if s == nil || runtime == nil || runtime.Companion == nil {
return
}
for _, entry := range runtime.Companion.Sources {
if !entry.Ready || entry.SourceCharacterID == 0 {
continue
}
identity, online := s.onlineGameSessionCharacterSnapshot(entry.SourceCharacterID)
if !online || identity.session == nil || !identity.session.companionHeadless {
continue
}
if _, found := currentPartyActorProjectionSnapshot(
identity.session, entry.SourceCharacterID, identity.generation); found {
continue
}
state := entry.ProgressionSnapshot
if state.Source.CharacterID == "" {
headlessDebug(fmt.Sprintf("actor_projection_missing_state char=%d", entry.SourceCharacterID))
continue
}
name := strings.TrimSpace(state.Source.Name)
if name == "" {
name = entry.SourceName
}
ownerChannel := currentPartySceneOwnerContext(identity.session)
mode0, err := s.buildCurrentSceneObjectListBodyForSessionInContextStrict(
ctx, identity.session, entry.SourceCharacterID, name, state.Source, true, ownerChannel)
if err != nil || len(mode0) == 0 {
headlessDebug(fmt.Sprintf("actor_projection_mode0_failed char=%d err=%v", entry.SourceCharacterID, err))
continue
}
reader := csharpLegacyUserInfoReader{
ctx: ctx,
characterID: state.Source.CharacterID,
service: s,
session: identity.session,
suppressActorSkillResources: true,
equipmentSnapshot: &state.Equipment,
}
mode1 := reader.buildCurrentUserInfoMode1WithEquipmentForOwner(
state.Source, true, entry.SourceCharacterID, true, 0,
currentSceneObjectRoute, ownerChannel)
if len(mode1) == 0 {
headlessDebug(fmt.Sprintf("actor_projection_mode1_failed char=%d", entry.SourceCharacterID))
continue
}
if err := captureCurrentPartyActorProjection(
identity.session, entry.SourceCharacterID, mode0, mode1); err != nil {
headlessDebug(fmt.Sprintf("actor_projection_capture_failed char=%d err=%v", entry.SourceCharacterID, err))
continue
}
headlessDebug(fmt.Sprintf("actor_projection_captured char=%d owner=%d mode0=%d mode1=%d",
entry.SourceCharacterID, ownerChannel, len(mode0), len(mode1)))
}
}
func (s *Service) publishHeadlessActorToLeader(headless *gameSession, runtime *runtimeDungeonState) bool {
if s == nil || headless == nil || runtime == nil || runtime.partyDungeonOwner == nil {
return false
}
leader, online := s.onlineGameSession(runtime.partyDungeonOwner.leaderID)
if !online || leader == nil {
return false
}
projection, found := currentPartyActorProjectionSnapshot(
headless, headless.selectedCharacterID, headless.characterGeneration)
if !found {
return false
}
for _, body := range [][]byte{projection.mode0, projection.mode1} {
if len(body) == 0 {
continue
}
if err := s.sendGameUpperRawClass(
leader, uint16(dnfenum.CmdPacketSetUDPIPPort), body, 0); err != nil {
return false
}
}
leader.townMu.Lock()
position := leader.townPositionSnapshot
leader.townMu.Unlock()
x := int(position.PositionX) + int(headless.selectedCharacterID%3)*40
y := int(position.PositionY)
area := buildCurrentTownUserAreaNotificationBody(
currentSceneActorObjectKey(headless.selectedCharacterID),
position.TownID, 0xff, uint16(x), uint16(y), position.MovementCode,
currentActorDefaultUserStateBits)
if err := s.sendGameUpperRawClass(leader, currentTownUserAreaNotificationMsgID, area, 0); err != nil {
return false
}
move := buildCurrentTownUserPositionNotificationBody(
currentSceneActorObjectKey(headless.selectedCharacterID),
uint16(x), uint16(y), position.MovementCode, 100)
if err := s.sendGameUpperRawClass(leader, currentTownUserPositionNotificationMsgID, move, 0); err != nil {
return false
}
headlessDebug(fmt.Sprintf("headless_actor_projected char=%d leader=%d mode0=%d mode1=%d x=%d y=%d",
headless.selectedCharacterID, leader.selectedCharacterID, len(projection.mode0), len(projection.mode1), x, y))
return true
}
func (s *Service) publishHeadlessActorsBeforeStart(leader *gameSession, runtime *runtimeDungeonState) {
if s == nil || leader == nil || runtime == nil || runtime.partyDungeonOwner == nil {
return
}
for _, member := range runtime.partyDungeonOwner.members {
identity, online := s.onlineGameSessionCharacterSnapshot(member.userID)
if !online || identity.session == nil || !identity.session.companionHeadless ||
identity.session.headlessActorPublished {
continue
}
if s.publishHeadlessActorToLeader(identity.session, runtime) {
identity.session.headlessActorPublished = true
}
}
}
func (s *Service) ensureHeadlessCompanionParty(ownerSession *gameSession, ids []uint16) {
if s == nil || ownerSession == nil || len(ids) == 0 {
return
}
manager := s.runtimePartyManagerForService()
if manager == nil {
return
}
ownerIdentity, ok := s.boundGameSessionCharacterSnapshot(ownerSession)
if !ok {
headlessDebug("party owner_identity_missing")
return
}
ownerMember, ok := s.runtimePartyMemberForIdentity(ownerIdentity)
if !ok {
headlessDebug(fmt.Sprintf("party owner_member_invalid owner=%d gen=%d", ownerIdentity.character, ownerIdentity.generation))
return
}
snapshot, found := manager.SnapshotByUser(ownerIdentity.character, ownerIdentity.generation)
headlessDebug(fmt.Sprintf("party snapshot_before found=%v id=%d members=%d", found, snapshot.ID, len(snapshot.Members)))
if !found {
created := manager.EnsureLeader(ownerMember, runtimePartyStateSnapshot(ownerSession))
if !created.OK {
headlessDebug("party ensure_leader_failed reason=" + created.Reason)
s.logGameEvent(ownerSession, "game-companion-headless-party-failed",
"stage", "leader", "reason", created.Reason)
return
}
snapshot = created.Party
} else if len(snapshot.Members) > 1 {
// A real party already owns this roster; never mix headless volunteers in.
headlessDebug(fmt.Sprintf("party real_party_present members=%d", len(snapshot.Members)))
return
}
for _, id := range ids {
identity, online := s.onlineGameSessionCharacterSnapshot(id)
if !online || identity.session == nil || !identity.session.companionHeadless {
headlessDebug(fmt.Sprintf("party join_skip id=%d online=%v", id, online))
continue
}
member, memberOK := s.runtimePartyMemberForIdentity(identity)
if !memberOK {
headlessDebug(fmt.Sprintf("party join_member_invalid id=%d", id))
continue
}
joined := manager.Join(snapshot.ID, member)
if !joined.OK {
headlessDebug(fmt.Sprintf("party join_failed id=%d reason=%s", id, joined.Reason))
s.logGameEvent(ownerSession, "game-companion-headless-party-failed",
"stage", "join", "member_id", id, "reason", joined.Reason)
continue
}
snapshot = joined.Party
headlessDebug(fmt.Sprintf("party joined id=%d members=%d", id, len(snapshot.Members)))
}
if len(snapshot.Members) <= 1 {
headlessDebug("party no_members_after_join")
return
}
state := s.publishRuntimePartySnapshot(snapshot)
headlessDebug(fmt.Sprintf("party published party_id=%d members=%d", state.PartyID, len(snapshot.Members)))
if state.PartyID <= 0 {
s.logGameEvent(ownerSession, "game-companion-headless-party-failed",
"stage", "publish")
return
}
s.logGameEvent(ownerSession, "game-companion-headless-party-online",
"owner_character_id", ownerSession.selectedCharacterID,
"party_id", state.PartyID,
"member_count", len(snapshot.Members))
}
// runHeadlessCompanionAIC is the server-side action driver for one headless
// volunteer. The first milestone commits real monster deaths as that character:
// the existing op39 owner validates the room actor, applies reward/clear state,
// and broadcasts the death to every real party member, so the player sees the
// volunteer actually clearing the room. Movement/skill choreography is a later
// layer on top of the same driver.
func (s *Service) runHeadlessCompanionAIC(session *gameSession) {
if s == nil || session == nil {
return
}
ticker := time.NewTicker(1500 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
if session.selectedCharacterID == 0 {
return
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
if runtime == nil || runtime.Room == nil || runtime.partyDungeonOwner == nil {
session.dungeon.mu.Unlock()
continue
}
snapshot := runtime.Room.Snapshot()
session.dungeon.mu.Unlock()
// A real follower sends op37 FINISH_LOADING after every room load; the
// party owner's loading gate waits for every member before it releases
// the leader. The headless session has no client, so issue the same
// request itself. The handler is idempotent per loading cycle.
if !session.headlessActorPublished {
if s.publishHeadlessActorToLeader(session, runtime) {
session.headlessActorPublished = true
}
}
_ = s.sendFinishLoadingStatus(session, make([]byte, currentFinishLoadingLegacyRequestBodySize))
var target uint32
for _, monster := range snapshot.Monsters {
if monster.State == runtimeDungeonMonsterAnnounced {
target = monster.ObjectKey
break
}
}
if target == 0 {
continue
}
body := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(body[0:4], target)
binary.LittleEndian.PutUint16(body[4:6],
currentSceneActorObjectKey(session.selectedCharacterID))
if err := s.handleDungeonMonsterDeath(session, body); err != nil {
s.logGameEvent(session, "game-companion-headless-ai-death-failed",
"character_id", session.selectedCharacterID,
"object_key", target,
"error", err)
continue
}
s.logGameEvent(session, "game-companion-headless-ai-action",
"character_id", session.selectedCharacterID,
"action", "kill_monster",
"object_key", target,
"room", snapshot.Coordinate.String(),
"map_id", snapshot.MapID)
}
}
@@ -0,0 +1,430 @@
package dnfbridge
import (
"context"
"os"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/party"
dnffriend "longheng.io/server/internal/modules/dnf/unitedfriend"
)
// Volunteers are persistent social characters: the native friend list and the
// native party frame both resolve them through a synthetic presence session.
// The session has no socket; it exists only while the server process is up so
// the operator's friend window keeps seeing the volunteers as online, including
// after a dungeon ends.
const (
currentCompanionNativePartyEnv = "DNF_COMPANION_NATIVE_PARTY"
// Presence of this file disables the native op9/op153 frame without a
// restart. The friend presence stays online; only the party window is
// suppressed, which is the fast rollback if a client build rejects the
// synthetic roster.
currentCompanionNativePartyDisableFile = "companion_native_party.disabled"
currentCompanionVirtualPrefix = "companion-presence-"
)
func currentCompanionNativePartyEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentCompanionNativePartyEnv))) {
case "0", "off", "false", "no":
return false
}
if _, err := os.Stat(currentCompanionNativePartyDisableFile); err == nil {
return false
}
return true
}
func (s *Service) companionPresenceSession(characterID uint16) (*gameSession, bool) {
if s == nil || characterID == 0 {
return nil, false
}
// A real client always wins over a synthetic presence row.
if real, online := s.onlineGameSession(characterID); online && real != nil {
return real, false
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
session := s.companionSessions[characterID]
return session, session != nil
}
// companionPresenceFriendChannel returns the friend-row channel for a synthetic
// volunteer. It copies the field under companionPresenceMu because ensure may
// update the channel when the owner moves.
func (s *Service) companionPresenceFriendChannel(characterID uint16) (uint16, bool) {
if s == nil || characterID == 0 {
return 0, false
}
if real, online := s.onlineGameSession(characterID); online && real != nil {
return 0, false
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
session := s.companionSessions[characterID]
if session == nil {
return 0, false
}
return uint16(session.channel.ID), true
}
func (s *Service) companionSessionSnapshot(characterID uint16) (boundGameSessionCharacter, bool) {
session, ok := s.companionPresenceSession(characterID)
if !ok || session == nil || session.companionVirtual == false {
return boundGameSessionCharacter{}, false
}
return boundGameSessionCharacter{
session: session,
character: characterID,
generation: session.characterGeneration,
virtual: true,
}, true
}
func (s *Service) companionSessionCurrent(identity boundGameSessionCharacter) bool {
if s == nil || identity.session == nil || identity.character == 0 {
return false
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
return s.companionSessions[identity.character] == identity.session &&
identity.session.selectedCharacterID == identity.character &&
identity.session.characterGeneration == identity.generation
}
func (s *Service) nextCompanionGeneration() uint64 {
s.mu.Lock()
defer s.mu.Unlock()
s.nextGameSessionGeneration++
if s.nextGameSessionGeneration == 0 {
s.nextGameSessionGeneration++
}
return s.nextGameSessionGeneration
}
// ensureCompanionPresenceForSession makes the currently configured volunteers
// visible in the native friend window for this owner. It is called at the login
// scene tail (before op273) and again at dungeon entry.
func (s *Service) ensureCompanionPresenceForSession(ctx context.Context, session *gameSession) {
if s == nil || session == nil || session.selectedCharacterID == 0 ||
!currentDungeonCompanionTeamModeEnabled() {
return
}
if ctx == nil {
ctx = context.Background()
}
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil || repos.Settings == nil {
return
}
ownerID := session.selectedCharacterID
ownerKey := strconv.FormatUint(uint64(ownerID), 10)
cfg, _, _, err := s.loadCurrentDungeonCompanionConfig(
ctx, ownerKey, strings.TrimSpace(s.accountIDForSession(session)), repos)
if err != nil {
s.logGameEvent(session, "game-companion-presence-config-failed",
"owner_character_id", ownerID, "error", err)
return
}
if !cfg.Enabled {
s.dropCompanionPresenceForOwner(ownerID)
return
}
sources := cfg.Sources()
accountID := strings.TrimSpace(s.accountIDForSession(session))
if accountID == "" {
owner, found, loadErr := repos.Character.Load(ctx, ownerKey)
if loadErr != nil || !found {
return
}
accountID = strings.TrimSpace(owner.AccountID)
}
matched, _, applied := s.autoMatchCurrentDungeonCompanionSources(
ctx, repos, accountID, ownerID, sources)
if applied {
sources = matched
}
if len(sources) == 0 {
return
}
ownerName := ""
if _, name, _, found := s.selectedCharacterForEnter(ctx, session); found {
ownerName = name
}
s.ensureCompanionFriendRelations(ctx, session, ownerName, sources)
if currentCompanionHeadlessEnabled() {
ids := s.ensureHeadlessCompanionSessions(ctx, session, sources, accountID)
s.ensureHeadlessCompanionParty(session, ids)
} else {
s.dropHeadlessCompanionSessionsForOwner(ownerID)
s.ensureCompanionVirtualSessions(ctx, session, sources, accountID)
}
s.logGameEvent(session, "game-companion-presence-online",
"owner_character_id", ownerID,
"source_character_ids", strings.Join(sources, ","),
"count", len(sources))
}
func (s *Service) ensureCompanionVirtualSessions(
ctx context.Context,
ownerSession *gameSession,
sources []string,
accountID string,
) {
if s == nil || ownerSession == nil || len(sources) == 0 {
return
}
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil {
return
}
for _, raw := range sources {
id, ok := currentDungeonCompanionProjectionSourceIDValue(raw)
if !ok || id == ownerSession.selectedCharacterID {
continue
}
if _, realOnline := s.onlineGameSession(id); realOnline {
continue
}
character, found, err := repos.Character.Load(ctx, strconv.FormatUint(uint64(id), 10))
if err != nil || !found || character.Stats["delete_flag"] != 0 ||
strings.TrimSpace(character.Name) == "" {
continue
}
// Allocate the generation before taking companionPresenceMu: the
// generation owner lives under Service.mu, and bindGameSessionCharacter
// holds Service.mu while dropping a virtual session.
generation := s.nextCompanionGeneration()
s.companionPresenceMu.Lock()
if s.companionSessions == nil {
s.companionSessions = make(map[uint16]*gameSession)
}
session := s.companionSessions[id]
if session == nil {
session = &gameSession{
connID: currentCompanionVirtualPrefix + strconv.FormatUint(uint64(id), 10),
accountID: accountID,
selectedCharacterID: id,
characterGeneration: generation,
channel: ownerSession.channel,
companionVirtual: true,
companionOwnerID: ownerSession.selectedCharacterID,
}
s.companionSessions[id] = session
} else {
session.channel = ownerSession.channel
session.companionOwnerID = ownerSession.selectedCharacterID
session.accountID = accountID
}
s.companionPresenceMu.Unlock()
}
}
func (s *Service) ensureCompanionFriendRelations(
ctx context.Context,
session *gameSession,
ownerName string,
sources []string,
) {
if s == nil || session == nil || strings.TrimSpace(ownerName) == "" {
return
}
repos, ok := s.repositoryGroup()
if !ok || repos.UnitedFriend == nil || repos.Character == nil {
return
}
owner, err := dnffriend.NewOwner(repos, nil)
if err != nil {
return
}
ownerID := session.selectedCharacterID
s.friendMu.Lock()
defer s.friendMu.Unlock()
for _, raw := range sources {
id, ok := currentDungeonCompanionProjectionSourceIDValue(raw)
if !ok || id == ownerID {
continue
}
character, found, loadErr := repos.Character.Load(ctx, strconv.FormatUint(uint64(id), 10))
if loadErr != nil || !found || strings.TrimSpace(character.Name) == "" {
continue
}
if _, addErr := owner.Add(ctx, ownerName, ownerID, character.Name, id); addErr != nil {
if addErr != dnffriend.ErrFull {
s.logGameEvent(session, "game-companion-friend-add-failed",
"owner_character_id", ownerID,
"source_character_id", id,
"error", addErr)
}
continue
}
s.logGameEvent(session, "game-companion-friend-ensured",
"owner_character_id", ownerID,
"source_character_id", id,
"name", character.Name)
}
}
func (s *Service) dropCompanionPresenceCharacter(characterID uint16) {
if s == nil || characterID == 0 {
return
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
delete(s.companionSessions, characterID)
}
func (s *Service) dropCompanionPresenceForOwner(ownerID uint16) {
if s == nil || ownerID == 0 {
return
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
for id, session := range s.companionSessions {
if session != nil && session.companionOwnerID == ownerID {
delete(s.companionSessions, id)
}
}
}
func companionPartyID(ownerID uint16) uint16 {
id := uint16(0xE000) | (ownerID & 0x0FFF)
if id == 0 || id == 0xFFFF {
id = 0xE001
}
return id
}
func (s *Service) buildCompanionPartyState(session *gameSession, run *currentDungeonCompanionRun) alignedcmd.PartyState {
if s == nil || session == nil || run == nil {
return alignedcmd.PartyState{}
}
state := alignedcmd.PartyState{
PartyID: int(companionPartyID(session.selectedCharacterID)),
IsLeader: true,
UserID: session.selectedCharacterID,
UserState: 100,
MaxMembers: 4,
}
state.Members = append(state.Members, alignedcmd.PartyMemberState{
UserID: session.selectedCharacterID, Slot: 0, UserState: 100, HPPercent: 100, MPPercent: 100,
})
slot := byte(1)
for _, entry := range run.Sources {
if !entry.Ready || entry.SourceCharacterID == 0 {
continue
}
if slot >= state.MaxMembers {
break
}
state.Members = append(state.Members, alignedcmd.PartyMemberState{
UserID: entry.SourceCharacterID, Slot: slot, UserState: 100, HPPercent: 100, MPPercent: 100,
})
slot++
}
return state
}
// publishCurrentDungeonCompanionNativeParty builds the native op9 party frame
// (kind-0 complete roster) followed by op153 HP/MP rows. It intentionally does
// not send op11: the volunteers have no P2P socket, and a made-up endpoint
// would leave the client stuck on "connecting". The scene actors themselves
// still come from the support-soldier projection.
func (s *Service) publishCurrentDungeonCompanionNativeParty(session *gameSession, runtime *runtimeDungeonState) {
if s == nil || session == nil || runtime == nil || runtime.Companion == nil ||
currentCompanionHeadlessEnabled() ||
!currentCompanionNativePartyEnabled() {
return
}
run := runtime.Companion
if !run.Ready || len(run.Sources) == 0 {
return
}
currentParty := runtimePartyStateSnapshot(session)
if currentParty.PartyID > 0 && len(runtimePartyMembers(currentParty)) > 1 {
s.logGameEvent(session, "game-companion-native-party-skipped",
"owner_character_id", session.selectedCharacterID,
"reason", "owner_already_in_native_party")
return
}
if runtime.partyDungeonOwner != nil || isCurrentImplementedRaidChannel(session) {
s.logGameEvent(session, "game-companion-native-party-skipped",
"owner_character_id", session.selectedCharacterID,
"reason", "party_or_raid_owner_has_priority")
return
}
sources := make([]string, 0, len(run.Sources))
for _, entry := range run.Sources {
if entry.Ready && entry.SourceCharacterID != 0 {
sources = append(sources, strconv.FormatUint(uint64(entry.SourceCharacterID), 10))
}
}
s.ensureCompanionVirtualSessions(
context.Background(), session, sources, strings.TrimSpace(s.accountIDForSession(session)))
state := s.buildCompanionPartyState(session, run)
if len(state.Members) <= 1 {
return
}
sent, err := s.sendCurrentPartyActorFrameProjection(
session, session, state, "companion_native_party")
if err != nil {
s.logGameEvent(session, "game-companion-native-party-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"error", err)
return
}
if !sent {
s.logGameEvent(session, "game-companion-native-party-deferred",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"reason", "op9_projection_not_delivered")
return
}
if err := s.sendRuntimePartyRealtimeInfoLocal(session, state); err != nil {
s.logGameEvent(session, "game-companion-native-party-rows-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"error", err)
return
}
run.NativePartyID = uint16(state.PartyID)
run.NativePartySent = true
s.logGameEvent(session, "game-companion-native-party-sent",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"member_count", len(state.Members),
"source_character_ids", uint16ListLog(currentDungeonCompanionTeamSourceIDs(run)))
}
// clearCurrentDungeonCompanionNativeParty empties only the party window; the
// virtual sessions and friend rows stay online, which is the requested
// post-dungeon state.
func (s *Service) clearCurrentDungeonCompanionNativeParty(session *gameSession, run *currentDungeonCompanionRun) {
if s == nil || session == nil || run == nil || !run.NativePartySent || run.NativePartyID == 0 {
return
}
body := buildCurrentSceneOp9ActorRemovalBodyInContext(
run.NativePartyID, currentPartySceneOwnerContext(session))
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketRecoverStamina), body, 0); err != nil {
s.logGameEvent(session, "game-companion-native-party-clear-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", run.NativePartyID,
"error", err)
}
if err := s.sendGameUpperRawClassCodec(
session, 0x0099, party.BuildEmptyRealtimeInfo(), 0, true); err != nil {
s.logGameEvent(session, "game-companion-native-party-clear-rows-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", run.NativePartyID,
"error", err)
}
run.NativePartySent = false
s.logGameEvent(session, "game-companion-native-party-cleared",
"owner_character_id", session.selectedCharacterID,
"party_id", run.NativePartyID,
"friends_online", true)
}
@@ -0,0 +1,129 @@
package dnfbridge
import (
"bytes"
"encoding/binary"
"net"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
)
type companionPartyTestConn struct {
net.Conn
write bytes.Buffer
}
func (c *companionPartyTestConn) Write(data []byte) (int, error) { return c.write.Write(data) }
func (c *companionPartyTestConn) SetWriteDeadline(time.Time) error {
return nil
}
func TestCompanionPresenceFriendRowRendersOnline(t *testing.T) {
service := &Service{}
virtual := &gameSession{
selectedCharacterID: 6,
characterGeneration: 7,
channel: channelcatalog.Channel{ID: 11},
companionVirtual: true,
companionOwnerID: 2,
}
service.companionSessions = map[uint16]*gameSession{6: virtual}
presence := service.currentUnitedFriendPresence(6)
if presence.Busy {
t.Fatal("volunteer friend row must not render busy/登录中")
}
if presence.ChannelID != 11 {
t.Fatalf("friend row channel=%d want 11", presence.ChannelID)
}
}
func TestBuildCompanionPartyStateUsesNativeFourSlotRoster(t *testing.T) {
service := &Service{}
owner := &gameSession{selectedCharacterID: 2}
run := &currentDungeonCompanionRun{
Ready: true,
Sources: []currentDungeonCompanionSourceRun{
{Ready: true, SourceCharacterID: 6},
{Ready: false, SourceCharacterID: 7},
{Ready: true, SourceCharacterID: 3},
},
}
state := service.buildCompanionPartyState(owner, run)
if state.PartyID <= 0 || state.PartyID >= 0xFFFF {
t.Fatalf("party id=%d is not a printable native party", state.PartyID)
}
if state.UserID != 2 || !state.IsLeader || state.MaxMembers != 4 {
t.Fatalf("party identity=%+v", state)
}
if len(state.Members) != 3 {
t.Fatalf("members=%v want owner + 2 ready volunteers", state.Members)
}
want := []struct {
id uint16
slot byte
}{{2, 0}, {6, 1}, {3, 2}}
for index, expected := range want {
member := state.Members[index]
if member.UserID != expected.id || member.Slot != expected.slot {
t.Fatalf("member %d=%+v want id=%d slot=%d", index, member, expected.id, expected.slot)
}
if member.HPPercent != 100 || member.MPPercent != 100 {
t.Fatalf("member %d hp/mp=%d/%d want 100/100", index, member.HPPercent, member.MPPercent)
}
}
}
func TestClearCompanionNativePartySendsOp9ThenOp153(t *testing.T) {
connection := &companionPartyTestConn{}
session := &gameSession{
conn: connection,
connID: "companion-party-clear",
selectedCharacterID: 2,
characterGeneration: 3,
channel: channelcatalog.Channel{ID: 11},
}
run := &currentDungeonCompanionRun{
Ready: true,
NativePartyID: 0xE002,
NativePartySent: true,
}
service := &Service{}
service.clearCurrentDungeonCompanionNativeParty(session, run)
if run.NativePartySent {
t.Fatal("native party flag was not cleared")
}
stream := connection.write.Bytes()
headerSize := service.gameUpperHeaderSize()
msgIDs := make([]uint16, 0, 2)
for len(stream) > 0 {
if len(stream) < headerSize {
t.Fatalf("truncated packet header: %d", len(stream))
}
total := int(binary.LittleEndian.Uint32(stream[3:7]))
if total < headerSize || total > len(stream) {
t.Fatalf("packet total=%d stream=%d", total, len(stream))
}
msgIDs = append(msgIDs, binary.LittleEndian.Uint16(stream[1:3]))
stream = stream[total:]
}
if len(msgIDs) != 2 || msgIDs[0] != 9 || msgIDs[1] != 0x0099 {
t.Fatalf("clear packets=%v want [9 153]", msgIDs)
}
}
func TestCompanionSessionIdentityIsCurrentOnlyWhileRegistered(t *testing.T) {
service := &Service{}
virtual := &gameSession{selectedCharacterID: 6, characterGeneration: 9, companionVirtual: true}
service.companionSessions = map[uint16]*gameSession{6: virtual}
identity, ok := service.companionSessionSnapshot(6)
if !ok || !identity.virtual || !service.boundGameSessionCharacterCurrent(identity) {
t.Fatalf("virtual identity=%+v ok=%v current=%v", identity, ok, service.boundGameSessionCharacterCurrent(identity))
}
delete(service.companionSessions, 6)
if service.boundGameSessionCharacterCurrent(identity) {
t.Fatal("removed volunteer session must fail the liveness check")
}
}
@@ -0,0 +1,306 @@
package dnfbridge
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
dnfcharstat "longheng.io/server/internal/modules/dnf/charstat"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// Companion panel snapshot (Option B): the client captures the source
// character's final character-info panel values and stores them in a settings
// record; the support-soldier projection prefers this snapshot over the
// PVF-derived base vector so the companion can mirror the source panel.
const (
companionPanelSnapshotKeyPrefix = "character:"
companionPanelSnapshotKeySuffix = ":companion-panel-v1"
)
var errCompanionPanelCharacterID = errors.New("companion panel: character_id is required")
type companionPanelSnapshot struct {
HPMax int64
MPMax int64
Strength int64
Intelligence int64
Vitality int64
Spirit int64
PhysicalAttack int64
MagicalAttack int64
IndependentAttack int64
PhysicalDefense int64
MagicalDefense int64
FireResistance int64
WaterResistance int64
DarkResistance int64
LightResistance int64
MoveSpeed int64
AttackSpeed int64
CastSpeed int64
HitRecovery int64
JumpPower int64
Weight int64
InventoryLimit int64
HPRegenSpeed int64
MPRegenSpeed int64
SourceClient string
// The following values are shown on the character-info panel but are not
// part of the 92-byte companion mode1 blob; they are stored so the GM panel
// can mirror the full panel and future projections can consume them.
// Percentages are stored scaled by 10 (70.0% -> 700); the three speed fields
// above use the blob's scale of 100 (60.50% -> 6050).
PhysicalCritical int64
MagicalCritical int64
MagicResistance int64
HitRate int64
DodgeRate int64
Stiffness int64
FireDamage int64
WaterDamage int64
LightDamage int64
DarkDamage int64
}
func companionPanelSnapshotScope(characterID string) string {
return companionPanelSnapshotKeyPrefix + strings.TrimSpace(characterID) +
companionPanelSnapshotKeySuffix
}
func companionPanelSnapshotInt(values map[string]string, names ...string) int64 {
for _, name := range names {
raw, ok := values[name]
if !ok {
continue
}
parsed, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err == nil {
return parsed
}
}
return 0
}
func loadCompanionPanelSnapshot(
ctx context.Context,
repos dnfrepo.Group,
characterID string,
) (companionPanelSnapshot, bool) {
if repos.Settings == nil || strings.TrimSpace(characterID) == "" {
return companionPanelSnapshot{}, false
}
record, found, err := repos.Settings.Load(
ctx, companionPanelSnapshotScope(characterID))
if err != nil || !found {
return companionPanelSnapshot{}, false
}
values := record.Values
snapshot := companionPanelSnapshot{
HPMax: companionPanelSnapshotInt(values, "hp_max"),
MPMax: companionPanelSnapshotInt(values, "mp_max"),
Strength: companionPanelSnapshotInt(values, "strength"),
Intelligence: companionPanelSnapshotInt(values, "intelligence"),
Vitality: companionPanelSnapshotInt(values, "vitality"),
Spirit: companionPanelSnapshotInt(values, "spirit"),
PhysicalAttack: companionPanelSnapshotInt(values, "physical_attack"),
MagicalAttack: companionPanelSnapshotInt(values, "magical_attack"),
IndependentAttack: companionPanelSnapshotInt(values, "independent_attack"),
PhysicalDefense: companionPanelSnapshotInt(values, "physical_defense"),
MagicalDefense: companionPanelSnapshotInt(values, "magical_defense"),
FireResistance: companionPanelSnapshotInt(values, "fire_resistance"),
WaterResistance: companionPanelSnapshotInt(values, "water_resistance"),
DarkResistance: companionPanelSnapshotInt(values, "dark_resistance"),
LightResistance: companionPanelSnapshotInt(values, "light_resistance"),
MoveSpeed: companionPanelSnapshotInt(values, "move_speed"),
AttackSpeed: companionPanelSnapshotInt(values, "attack_speed"),
CastSpeed: companionPanelSnapshotInt(values, "cast_speed"),
HitRecovery: companionPanelSnapshotInt(values, "hit_recovery"),
JumpPower: companionPanelSnapshotInt(values, "jump_power"),
Weight: companionPanelSnapshotInt(values, "weight"),
InventoryLimit: companionPanelSnapshotInt(values, "inventory_limit"),
HPRegenSpeed: companionPanelSnapshotInt(values, "hp_regen"),
MPRegenSpeed: companionPanelSnapshotInt(values, "mp_regen"),
SourceClient: strings.TrimSpace(values["source_client"]),
PhysicalCritical: companionPanelSnapshotInt(values, "physical_critical"),
MagicalCritical: companionPanelSnapshotInt(values, "magical_critical"),
MagicResistance: companionPanelSnapshotInt(values, "magic_resistance"),
HitRate: companionPanelSnapshotInt(values, "hit_rate"),
DodgeRate: companionPanelSnapshotInt(values, "dodge_rate"),
Stiffness: companionPanelSnapshotInt(values, "stiffness"),
FireDamage: companionPanelSnapshotInt(values, "fire_damage"),
WaterDamage: companionPanelSnapshotInt(values, "water_damage"),
LightDamage: companionPanelSnapshotInt(values, "light_damage"),
DarkDamage: companionPanelSnapshotInt(values, "dark_damage"),
}
if snapshot.HPMax <= 0 || snapshot.MPMax <= 0 || snapshot.Strength <= 0 {
return companionPanelSnapshot{}, false
}
return snapshot, true
}
func (s companionPanelSnapshot) Vector() dnfcharstat.Vector {
return dnfcharstat.Vector{
HPMax: s.HPMax,
MPMax: s.MPMax,
Strength: s.Strength,
Intelligence: s.Intelligence,
Vitality: s.Vitality,
Spirit: s.Spirit,
PhysicalAttack: s.PhysicalAttack,
PhysicalDefense: s.PhysicalDefense,
MagicalAttack: s.MagicalAttack,
MagicalDefense: s.MagicalDefense,
IndependentAttack: s.IndependentAttack,
FireResistance: s.FireResistance,
WaterResistance: s.WaterResistance,
DarkResistance: s.DarkResistance,
LightResistance: s.LightResistance,
InventoryLimit: s.InventoryLimit,
HPRegenSpeed: s.HPRegenSpeed,
MPRegenSpeed: s.MPRegenSpeed,
MoveSpeed: s.MoveSpeed,
AttackSpeed: s.AttackSpeed,
CastSpeed: s.CastSpeed,
HitRecovery: s.HitRecovery,
JumpPower: s.JumpPower,
Weight: s.Weight,
}
}
// CompanionPanelValues returns the raw stored panel snapshot values for the GM
// editor. found is false when no snapshot has been saved yet.
func (s *Service) CompanionPanelValues(ctx context.Context, characterID string) (map[string]string, bool, error) {
repos, ok := s.repositoryGroup()
if !ok {
return nil, false, ErrGMRepositoryUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return nil, false, errCompanionPanelCharacterID
}
record, found, err := repos.Settings.Load(ctx, companionPanelSnapshotScope(characterID))
if err != nil {
return nil, false, err
}
if !found {
return map[string]string{}, false, nil
}
values := make(map[string]string, len(record.Values))
nonEmpty := false
for k, v := range record.Values {
values[k] = v
if strings.TrimSpace(v) != "" {
nonEmpty = true
}
}
// A record whose values are all empty is a leftover of a blank GM save and
// carries no usable panel; report it as not configured so the editor does
// not show a misleading "已读取".
if !nonEmpty {
return map[string]string{}, false, nil
}
return values, true, nil
}
// SaveCompanionPanelValues replaces the stored panel snapshot values.
func (s *Service) SaveCompanionPanelValues(ctx context.Context, characterID string, values map[string]string) error {
repos, ok := s.repositoryGroup()
if !ok {
return ErrGMRepositoryUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return errCompanionPanelCharacterID
}
cleaned := make(map[string]string, len(values))
for k, v := range values {
key := strings.TrimSpace(k)
if key == "" {
continue
}
cleaned[key] = strings.TrimSpace(v)
}
return repos.Settings.Save(ctx, dnfrepo.SettingsRecord{
Scope: companionPanelSnapshotScope(characterID),
Values: cleaned,
})
}
// GenerateCompanionPanelSnapshot builds a best-effort panel snapshot from the
// server's PVF character growth plus the character's worn equipment PVF stats
// (and weapon forging). It deliberately does not model enchant cards,
// amplification, reinforcement, quality, set effects, titles, auras or pets:
// the saved values are a BASE ESTIMATE that the GM page can correct.
func (s *Service) GenerateCompanionPanelSnapshot(ctx context.Context, characterID string) (map[string]string, error) {
repos, ok := s.repositoryGroup()
if !ok {
return nil, ErrGMRepositoryUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return nil, errCompanionPanelCharacterID
}
character, found, err := repos.Character.Load(ctx, characterID)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("character %s not found", characterID)
}
base, ok := s.characterPVFStatsForUserInfo(ctx, nil, character, true)
if !ok {
return nil, fmt.Errorf("character %s PVF stats unavailable", characterID)
}
stats := s.addEquipmentPVFStatsForUserInfo(ctx, nil, character, base)
values := companionPanelValuesFromVector(stats, character)
return values, nil
}
func companionPanelValuesFromVector(stats dnfcharstat.Vector, character dnfrepo.CharacterRecord) map[string]string {
values := make(map[string]string, 24)
put := func(key string, value int64) {
values[key] = strconv.FormatInt(value, 10)
}
put("hp_max", stats.HPMax)
put("mp_max", stats.MPMax)
put("strength", stats.Strength)
put("intelligence", stats.Intelligence)
put("vitality", stats.Vitality)
put("spirit", stats.Spirit)
put("physical_attack", stats.PhysicalAttack)
put("magical_attack", stats.MagicalAttack)
put("independent_attack", stats.IndependentAttack)
put("physical_defense", stats.PhysicalDefense)
put("magical_defense", stats.MagicalDefense)
put("fire_resistance", stats.FireResistance)
put("water_resistance", stats.WaterResistance)
put("dark_resistance", stats.DarkResistance)
put("light_resistance", stats.LightResistance)
put("move_speed", stats.MoveSpeed)
put("attack_speed", stats.AttackSpeed)
put("cast_speed", stats.CastSpeed)
put("hit_recovery", stats.HitRecovery)
jump := stats.JumpPower
if jump == 0 {
jump = character.Stats["stat_jump_power"]
}
put("jump_power", jump)
weight := stats.Weight
if weight == 0 {
weight = character.Stats["stat_weight"]
}
put("weight", weight)
inventory := stats.InventoryLimit
if inventory == 0 {
inventory = character.Stats["stat_inventory_limit"]
}
put("inventory_limit", inventory)
put("hp_regen", stats.HPRegenSpeed)
put("mp_regen", stats.MPRegenSpeed)
values["source_client"] = "server-estimate"
return values
}
@@ -0,0 +1,192 @@
package dnfbridge
import (
"context"
"os"
"strings"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
// Real-actor PoC for the volunteer squad.
//
// This path is deliberately switch-gated and OFF by default. It publishes each
// volunteer as a real character actor (mode0 + mode1 + mode3, objectKey =
// character id, non-zero party owner context) instead of the support-soldier
// APC projection. It is the first milestone of the "real character enters the
// dungeon" route; attaching the client AIC to these actors is the next client
// patch step, so this path currently ships appearance/roster only.
const (
currentCompanionRealActorsEnv = "DNF_COMPANION_REAL_ACTORS"
currentCompanionRealActorsFlagFile = "companion_real_actors.enabled"
currentCompanionRealActorsMode0Kind = "real_party_actor"
)
func currentCompanionRealActorsEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentCompanionRealActorsEnv))) {
case "0", "off", "false", "no":
return false
}
if _, err := os.Stat(currentCompanionRealActorsFlagFile); err == nil {
return true
}
return false
}
// publishCurrentDungeonCompanionRealActors returns true only when every ready
// volunteer was published as a real actor. A partial failure returns false so
// the caller can keep the proven projection path instead of showing a broken
// half-team.
func (s *Service) publishCurrentDungeonCompanionRealActors(
session *gameSession,
runtime *runtimeDungeonState,
) bool {
if s == nil || session == nil || runtime == nil || runtime.Companion == nil ||
!currentCompanionRealActorsEnabled() {
return false
}
run := runtime.Companion
if !run.Ready || len(run.Sources) == 0 {
return false
}
ownerContext := currentPartySceneOwnerContext(session)
if ownerContext == 0 && session.channel.ID > 0 && session.channel.ID <= 0xff {
ownerContext = byte(session.channel.ID)
}
if ownerContext == 0 {
s.logGameEvent(session, "game-companion-real-actors-skipped",
"owner_character_id", session.selectedCharacterID,
"reason", "party_owner_context_not_committed")
return false
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
type realRow struct {
id uint16
name string
mode0 []byte
mode1 []byte
}
rows := make([]realRow, 0, len(run.Sources))
for _, entry := range run.Sources {
if !entry.Ready || entry.SourceCharacterID == 0 {
continue
}
state := entry.ProgressionSnapshot
if state.Source.CharacterID == "" {
return false
}
name := strings.TrimSpace(state.Source.Name)
if name == "" {
name = entry.SourceName
}
mode0, err := s.buildCurrentSceneObjectListBodyForSessionInContextStrict(
ctx, session, entry.SourceCharacterID, name, state.Source, true, ownerContext)
if err != nil || len(mode0) < 3 {
s.logGameEvent(session, "game-companion-real-actor-failed",
"source_character_id", entry.SourceCharacterID,
"stage", "mode0", "error", err)
return false
}
reader := csharpLegacyUserInfoReader{
ctx: ctx,
characterID: state.Source.CharacterID,
service: s,
session: session,
suppressActorSkillResources: true,
equipmentSnapshot: &state.Equipment,
}
mode1 := reader.buildCurrentUserInfoMode1WithEquipmentForOwner(
state.Source, true, entry.SourceCharacterID, true, 0,
currentSceneObjectRoute, ownerContext)
if len(mode1) < 3 {
s.logGameEvent(session, "game-companion-real-actor-failed",
"source_character_id", entry.SourceCharacterID,
"stage", "mode1")
return false
}
rows = append(rows, realRow{id: entry.SourceCharacterID, name: name, mode0: mode0, mode1: mode1})
}
if len(rows) == 0 {
return false
}
// The client's native multi-row reader creates scene actors for a party
// prelude. Sending one row at a time produced global visuals that a dungeon
// room never rendered; the roster must be one counted mode0/mode1 body.
var multiMode0, multiMode1 packetWriter
multiMode0.writeByte(0)
multiMode0.writeUint16(uint16(len(rows)))
multiMode1.writeByte(1)
multiMode1.writeUint16(uint16(len(rows)))
for _, row := range rows {
multiMode0.writeBytes(row.mode0[3:])
multiMode1.writeBytes(row.mode1[3:])
}
for _, body := range [][]byte{multiMode0.bytes(), multiMode1.bytes()} {
if err := s.sendGameUpperRawClass(
session, uint16(dnfenum.CmdPacketSetUDPIPPort), body, 0); err != nil {
s.logGameEvent(session, "game-companion-real-actor-failed",
"stage", "multi-row-send", "error", err)
return false
}
}
session.townMu.Lock()
position := session.townPositionSnapshot
session.townMu.Unlock()
stateBits := currentActorDefaultUserStateBits
if runtime.Character.Stats != nil {
if value, exists := runtime.Character.Stats["user_state_bits"]; exists && value >= 0 && value <= 0xff {
stateBits = byte(value)
}
}
for index, row := range rows {
x := int(position.PositionX) + index*40
y := int(position.PositionY)
if !position.PositionValid {
x = int(numericCharacterStatValue(runtime.Character, "pos_x")) + index*40
y = int(numericCharacterStatValue(runtime.Character, "pos_y"))
}
if x < 0 {
x = 0
}
if x > 0xffff {
x = 0xffff
}
if y < 0 {
y = 0
}
if y > 0xffff {
y = 0xffff
}
areaBody := buildCurrentTownUserAreaNotificationBody(
currentSceneActorObjectKey(row.id),
position.TownID, 0xff, uint16(x), uint16(y),
position.MovementCode, stateBits,
)
if err := s.sendGameUpperRawClass(
session, currentTownUserAreaNotificationMsgID, areaBody, 0); err != nil {
s.logGameEvent(session, "game-companion-real-actor-failed",
"source_character_id", row.id,
"stage", "op23", "error", err)
return false
}
s.logGameEvent(session, "game-companion-real-actor-sent",
"source_character_id", row.id,
"name", row.name,
"owner_context", ownerContext,
"object_key", currentSceneActorObjectKey(row.id),
"mode0_bytes", len(row.mode0),
"mode1_bytes", len(row.mode1),
"path", currentCompanionRealActorsMode0Kind)
}
sent := len(rows)
if sent == 0 {
return false
}
s.logGameEvent(session, "game-companion-real-actors-published",
"owner_character_id", session.selectedCharacterID,
"count", sent,
"party_owner_context", ownerContext,
"aic", "pending_client_attachment")
return true
}
@@ -0,0 +1,268 @@
package dnfbridge
import (
"context"
"encoding/binary"
"os"
"strconv"
"strings"
"time"
dnfcompanion "longheng.io/server/internal/modules/dnf/companion"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// The teammate squad is the default support-soldier entry shape. It can be
// disabled per deployment with DNF_COMPANION_TEAM_MODE=0; the GM settings stay
// authoritative for which characters are preferred, but the server fills the
// remaining slots from the same account at dungeon entry.
const currentDungeonCompanionTeamModeEnv = "DNF_COMPANION_TEAM_MODE"
const currentDungeonCompanionTeamRole = "teammate"
func currentDungeonCompanionTeamModeEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentDungeonCompanionTeamModeEnv))) {
case "0", "off", "false", "no":
return false
default:
return true
}
}
// autoMatchCurrentDungeonCompanionSources expands the operator's saved
// selection into a frozen teammate roster for this dungeon.
//
// This is a read-only matchmaking step: no source character is modified and no
// session is created for it. The actual snapshot/AIC/projection pipeline still
// runs per selected source exactly as before, so a bad or deleted character
// fails on its own row instead of aborting the whole team.
func (s *Service) autoMatchCurrentDungeonCompanionSources(
ctx context.Context,
repos dnfrepo.Group,
accountID string,
ownerCharacterID uint16,
configured []string,
) ([]string, []dnfcompanion.TeamSkip, bool) {
if s == nil || repos.Character == nil || strings.TrimSpace(accountID) == "" ||
!currentDungeonCompanionTeamModeEnabled() {
return configured, nil, false
}
records, err := repos.Character.ListByAccount(ctx, accountID, dnfrepo.MaxCharacterSlotLimit)
if err != nil {
s.logPacketEvent("dnf-companion-team-match-deferred",
"owner_character_id", ownerCharacterID,
"reason", "character_list_unavailable",
"error", err)
return configured, nil, false
}
ownerLevel := 0
if owner, ownerFound, ownerErr := repos.Character.Load(ctx, strconv.FormatUint(uint64(ownerCharacterID), 10)); ownerErr == nil && ownerFound {
ownerLevel = owner.Level
}
levelFloor := ownerLevel - 10
if levelFloor < 1 {
levelFloor = 1
}
candidates := make([]dnfcompanion.TeamCandidate, 0, len(records))
for _, record := range records {
id := numericCharacterID(record)
if id <= 0 || id >= int(^uint16(0)) {
continue
}
if ownerLevel > 0 && record.Level > 0 && record.Level < levelFloor {
// A level-21 character in a level-90 party's dungeon entry preflight
// makes the whole party fail the level gate. Do not auto-fill with a
// character the dungeon will reject.
s.logPacketEvent("dnf-companion-team-match-level-skipped",
"owner_character_id", ownerCharacterID,
"candidate_character_id", id,
"candidate_level", record.Level,
"owner_level", ownerLevel,
"level_floor", levelFloor)
continue
}
session, online := s.onlineGameSession(uint16(id))
if online && session != nil && session.companionHeadless {
// Our own headless presence is not a competing real player. Without
// this, every later auto-match (preflight/dungeon entry) excludes
// the volunteers it just logged in.
online = false
}
candidates = append(candidates, dnfcompanion.TeamCandidate{
CharacterID: strconv.Itoa(id),
Level: record.Level,
Job: strings.TrimSpace(record.Job),
Slot: record.Slot,
Online: online,
Deleted: record.Stats["delete_flag"] != 0,
})
}
match := dnfcompanion.MatchTeam(
strconv.FormatUint(uint64(ownerCharacterID), 10),
configured,
candidates,
dnfcompanion.TeamMaxCompanions,
)
skipped := make([]string, 0, len(match.Skipped))
for _, entry := range match.Skipped {
if len(skipped) >= 16 {
break
}
skipped = append(skipped, entry.CharacterID+":"+entry.Reason)
}
s.logPacketEvent("dnf-companion-team-match",
"owner_character_id", ownerCharacterID,
"configured", strings.Join(configured, ","),
"selected", strings.Join(match.Selected, ","),
"selected_count", len(match.Selected),
"skipped", strings.Join(skipped, ","),
"limit", dnfcompanion.TeamMaxCompanions)
return match.Selected, match.Skipped, true
}
func currentDungeonCompanionTeamSourceIDs(run *currentDungeonCompanionRun) []uint16 {
if run == nil {
return nil
}
out := make([]uint16, 0, len(run.Sources))
for _, entry := range run.Sources {
if entry.Ready {
out = append(out, entry.SourceCharacterID)
}
}
if len(out) == 0 && run.Ready && run.SourceCharacterID != 0 {
out = append(out, run.SourceCharacterID)
}
return out
}
func currentDungeonCompanionTeamRevision(run *currentDungeonCompanionRun) uint64 {
if run == nil {
return 1
}
if len(run.Body) >= currentDungeonCompanionProjectionHeaderSize {
if revision := binary.LittleEndian.Uint64(run.Body[8:16]); revision != 0 {
return revision
}
}
for _, entry := range run.Sources {
if len(entry.Body) >= currentDungeonCompanionProjectionHeaderSize {
if revision := binary.LittleEndian.Uint64(entry.Body[8:16]); revision != 0 {
return revision
}
}
}
return 1
}
func (s *Service) markCurrentDungeonCompanionTeamOnline(
session *gameSession,
run *currentDungeonCompanionRun,
source string,
) {
if s == nil || run == nil || !run.Ready {
return
}
run.Online = true
run.OnlineAt = s.gameplayNow()
run.OfflineAt = time.Time{}
run.OfflineReason = ""
run.LogoutSent = false
s.logGameEvent(session, "game-dungeon-companion-team-online",
"source", source,
"team_mode", run.TeamMode,
"team_leader_id", run.TeamLeaderID,
"team_size", run.TeamSize,
"ready_count", currentDungeonCompanionReadyCount(run),
"source_character_ids", uint16ListLog(currentDungeonCompanionTeamSourceIDs(run)),
"online_at", run.OnlineAt)
}
// sendCurrentDungeonCompanionTeamOffline publishes the explicit present=0
// projection. The client clears every queued/live support actor on that body,
// so one packet is enough for the whole squad and it is safe to call from every
// dungeon boundary: the run's LogoutSent flag makes it idempotent.
func (s *Service) sendCurrentDungeonCompanionTeamOffline(
session *gameSession,
runtime *runtimeDungeonState,
reason string,
) {
if s == nil || session == nil || runtime == nil {
return
}
run := runtime.Companion
if run == nil || !run.Ready || run.LogoutSent {
return
}
run.LogoutSent = true
run.Online = false
run.OfflineAt = s.gameplayNow()
run.OfflineReason = strings.TrimSpace(reason)
// The dungeon party frame goes away; the synthetic friend sessions do not.
s.clearCurrentDungeonCompanionNativeParty(session, run)
ids := currentDungeonCompanionTeamSourceIDs(run)
body := buildCurrentDungeonCompanionOfflineBody(currentDungeonCompanionTeamRevision(run))
s.logGameEvent(session, "game-dungeon-companion-team-offline",
"reason", run.OfflineReason,
"team_mode", run.TeamMode,
"team_leader_id", run.TeamLeaderID,
"team_size", run.TeamSize,
"offline_count", len(ids),
"source_character_ids", uint16ListLog(ids),
"offline_at", run.OfflineAt,
"private_opcode", currentDungeonCompanionProjectionOpcode,
"body_len", len(body))
if err := s.sendGameUpperRawClass(
session,
currentDungeonCompanionProjectionOpcode,
body,
0,
); err != nil {
s.logGameEvent(session, "game-dungeon-companion-team-offline-send-failed",
"reason", run.OfflineReason,
"error", err)
}
}
func (s *Service) sendCurrentDungeonCompanionTeamOfflineCurrent(
session *gameSession,
reason string,
) {
if s == nil || session == nil {
return
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
session.dungeon.mu.Unlock()
s.sendCurrentDungeonCompanionTeamOffline(session, runtime, reason)
}
func uint16ListLog(values []uint16) string {
if len(values) == 0 {
return ""
}
var builder strings.Builder
for index, value := range values {
if index != 0 {
builder.WriteByte(',')
}
builder.WriteString(strconv.Itoa(int(value)))
}
return builder.String()
}
// runtimeDungeonCompanionCurrent is the compare step used by exit paths that
// opportunistically publish the logout before taking session.dungeon.mu. The
// final authority remains retireCurrentDungeonRuntimeLocked; this only avoids
// sending a stale run's packet when a newer dungeon already replaced it.
func (s *Service) runtimeDungeonCompanionCurrent(
session *gameSession,
runtime *runtimeDungeonState,
) bool {
if s == nil || session == nil || runtime == nil {
return false
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
return session.dungeon.runtime == runtime
}
@@ -0,0 +1,111 @@
package dnfbridge
import (
"bytes"
"encoding/binary"
"net"
"testing"
"time"
dnfcompanion "longheng.io/server/internal/modules/dnf/companion"
)
// companionOfflineTestConn is intentionally local to this test so the file can
// be compiled without the large shared service_test.go harness.
type companionOfflineTestConn struct {
net.Conn
write bytes.Buffer
}
func (c *companionOfflineTestConn) Write(data []byte) (int, error) { return c.write.Write(data) }
func (c *companionOfflineTestConn) SetWriteDeadline(time.Time) error {
return nil
}
func TestBuildCurrentDungeonCompanionOfflineBodyMatchesPresentZeroContract(t *testing.T) {
body := buildCurrentDungeonCompanionOfflineBody(12345)
if len(body) != currentDungeonCompanionProjectionHeaderSize {
t.Fatalf("body length=%d want=%d", len(body), currentDungeonCompanionProjectionHeaderSize)
}
if got := binary.LittleEndian.Uint32(body[0:4]); got != currentDungeonCompanionProjectionMagic {
t.Fatalf("magic=%#x want=%#x", got, currentDungeonCompanionProjectionMagic)
}
if body[4] != currentDungeonCompanionProjectionVersion {
t.Fatalf("version=%d want=%d", body[4], currentDungeonCompanionProjectionVersion)
}
if body[5] != 0 {
t.Fatalf("flags=%#x want 0 (present must be clear)", body[5])
}
if got := binary.LittleEndian.Uint16(body[6:8]); got != 0 {
t.Fatalf("source_character=%d want 0", got)
}
if got := binary.LittleEndian.Uint64(body[8:16]); got != 12345 {
t.Fatalf("revision=%d want 12345", got)
}
for offset := 16; offset < len(body); offset++ {
if body[offset] != 0 {
t.Fatalf("byte %d=%#x want 0", offset, body[offset])
}
}
}
func TestSendCurrentDungeonCompanionTeamOfflineIsIdempotent(t *testing.T) {
connection := &companionOfflineTestConn{}
session := &gameSession{conn: connection, connID: "companion-team-logout"}
run := &currentDungeonCompanionRun{
Ready: true,
TeamMode: true,
TeamLeaderID: 1,
TeamSize: 2,
Online: true,
Sources: []currentDungeonCompanionSourceRun{
{Ready: true, SourceCharacterID: 2, Body: buildCurrentDungeonCompanionOfflineBody(77)},
{Ready: true, SourceCharacterID: 3, Body: buildCurrentDungeonCompanionOfflineBody(77)},
},
}
runtime := &runtimeDungeonState{Companion: run}
service := &Service{}
service.sendCurrentDungeonCompanionTeamOffline(session, runtime, "dungeon_return_town")
if !run.LogoutSent || run.Online {
t.Fatalf("run state after logout = sent:%v online:%v", run.LogoutSent, run.Online)
}
if run.OfflineAt.IsZero() || run.OfflineReason != "dungeon_return_town" {
t.Fatalf("offline evidence = at:%v reason:%q", run.OfflineAt, run.OfflineReason)
}
first := connection.write.Len()
if first == 0 {
t.Fatal("logout packet was not sent")
}
// A second boundary must not replay the packet.
service.sendCurrentDungeonCompanionTeamOffline(session, runtime, "return_select_character")
if connection.write.Len() != first {
t.Fatalf("second logout wrote %d extra bytes", connection.write.Len()-first)
}
stream := connection.write.Bytes()
headerSize := service.gameUpperHeaderSize()
if len(stream) < headerSize {
t.Fatalf("logout stream too short: %d", len(stream))
}
total := int(binary.LittleEndian.Uint32(stream[3:7]))
if total != len(stream) {
t.Fatalf("logout packet total=%d stream=%d", total, len(stream))
}
if msgID := binary.LittleEndian.Uint16(stream[1:3]); msgID != currentDungeonCompanionProjectionOpcode {
t.Fatalf("msg id=%#x want=%#x", msgID, currentDungeonCompanionProjectionOpcode)
}
body := stream[headerSize:total]
if len(body) != currentDungeonCompanionProjectionHeaderSize || body[5] != 0 {
t.Fatalf("logout body length=%d flags=%d", len(body), body[5])
}
}
func TestMatchTeamAutoFillKeepsProjectionPipelinePerSource(t *testing.T) {
// The matcher is pure; this guard pins the cap the bridge passes so a future
// change cannot silently form a five-player dungeon party.
if dnfcompanion.TeamMaxCompanions != 3 {
t.Fatalf("TeamMaxCompanions=%d want 3", dnfcompanion.TeamMaxCompanions)
}
}
@@ -45,6 +45,20 @@ type gameSession struct {
completedAutoNextRuntime *runtimeDungeonState completedAutoNextRuntime *runtimeDungeonState
conn net.Conn conn net.Conn
// companionVirtual marks a synthetic session that exists only so the native
// friend list and party frame can resolve an offline volunteer as an online
// character. It has no socket and must never receive a normal send path.
companionVirtual bool
// companionHeadless is a real session in gameSessions with a sink transport.
// Party/dungeon owners treat it as a real member; no client is attached.
companionHeadless bool
companionOwnerID uint16
// companionForceEnter marks the admin-driven op16 so dungeon entry can log
// its exact refusal reason to the plaintext headless debug file.
companionForceEnter bool
// headlessActorPublished marks that this headless member's own actor rows
// were projected to the leader after the dungeon room committed.
headlessActorPublished bool
// writer owns this session's blocking socket writes so the event loop never // writer owns this session's blocking socket writes so the event loop never
// parks on a client that stopped reading. See game_session_writer.go for the // parks on a client that stopped reading. See game_session_writer.go for the
// live 48-second stall measurement that made it necessary. nil means "no // live 48-second stall measurement that made it necessary. nil means "no
@@ -13,6 +13,10 @@ type boundGameSessionCharacter struct {
session *gameSession session *gameSession
character uint16 character uint16
generation uint64 generation uint64
// virtual marks a companion presence session. Such a session is not in
// Service.gameSessions (it has no socket), so the liveness check below must
// consult Service.companionSessions instead.
virtual bool
} }
func (s *Service) bindGameSessionCharacter(session *gameSession, charID uint16) { func (s *Service) bindGameSessionCharacter(session *gameSession, charID uint16) {
@@ -53,6 +57,13 @@ func (s *Service) bindGameSessionCharacter(session *gameSession, charID uint16)
s.stopCurrentSpendTimeClock(session, "selected_character_changed") s.stopCurrentSpendTimeClock(session, "selected_character_changed")
s.stopCurrentPetGrowthClock(session, "selected_character_changed") s.stopCurrentPetGrowthClock(session, "selected_character_changed")
} }
if oldID := session.selectedCharacterID; oldID != 0 && oldID != charID {
session.dungeon.mu.Lock()
pendingCompanionRuntime := session.dungeon.runtime
session.dungeon.mu.Unlock()
s.sendCurrentDungeonCompanionTeamOffline(
session, pendingCompanionRuntime, "selected_character_changed")
}
session.dungeon.mu.Lock() session.dungeon.mu.Lock()
if oldID := session.selectedCharacterID; oldID != 0 && oldID != charID { if oldID := session.selectedCharacterID; oldID != 0 && oldID != charID {
runtime := session.dungeon.runtime runtime := session.dungeon.runtime
@@ -143,6 +154,9 @@ func (s *Service) bindGameSessionCharacter(session *gameSession, charID uint16)
session.collectboxSelectionRefreshSent = false session.collectboxSelectionRefreshSent = false
} }
session.selectedCharacterID = charID session.selectedCharacterID = charID
// A real client binding this character always retires the synthetic
// volunteer presence row, so the friend list cannot claim two online copies.
s.dropCompanionPresenceCharacter(charID)
if resident != nil && resident != session { if resident != nil && resident != session {
if current := s.gameSessions[charID]; current == resident { if current := s.gameSessions[charID]; current == resident {
s.mu.Unlock() s.mu.Unlock()
@@ -436,6 +450,7 @@ func (s *Service) releaseDisconnectedDungeonAttempt(session *gameSession, reason
// 掉线不退队 —— 座位由 raid_disconnect.go 的宽限窗口守着,这里存的坐标只作 // 掉线不退队 —— 座位由 raid_disconnect.go 的宽限窗口守着,这里存的坐标只作
// 诊断与重连参考(重连实际一律回 PVF 等待室起点,见 runtimeFiendWarRejoinLocation)。 // 诊断与重连参考(重连实际一律回 PVF 等待室起点,见 runtimeFiendWarRejoinLocation)。
s.captureRuntimeFiendWarRejoinPosition(session) s.captureRuntimeFiendWarRejoinPosition(session)
s.sendCurrentDungeonCompanionTeamOfflineCurrent(session, "disconnected")
session.dungeon.mu.Lock() session.dungeon.mu.Lock()
runtime := session.dungeon.runtime runtime := session.dungeon.runtime
if runtime == nil { if runtime == nil {
@@ -538,6 +553,9 @@ func (s *Service) boundGameSessionCharacterCurrent(identity boundGameSessionChar
if s == nil || identity.session == nil || identity.character == 0 { if s == nil || identity.session == nil || identity.character == 0 {
return false return false
} }
if identity.virtual {
return s.companionSessionCurrent(identity)
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
return s.gameSessions[identity.character] == identity.session && return s.gameSessions[identity.character] == identity.session &&
@@ -73,6 +73,7 @@ func (s *Service) commitCurrentAvatarSocketOpen(session *gameSession, request cu
if err != nil { if err != nil {
return currentSocketMutationResult{}, err return currentSocketMutationResult{}, err
} }
socketTypes = currentAvatarSocketTypesForOpen(rule, socketTypes)
if len(socketTypes) == 0 { if len(socketTypes) == 0 {
return currentSocketMutationResult{}, fmt.Errorf("%w: avatar socket definitions missing item=%d", errCurrentSocketPVFInvalid, request.TargetItemID) return currentSocketMutationResult{}, fmt.Errorf("%w: avatar socket definitions missing item=%d", errCurrentSocketPVFInvalid, request.TargetItemID)
} }
@@ -0,0 +1,283 @@
package dnfbridge
// gm_activity_switches.go 提供「活动开关」的运行时配置:服主在启动器的
// 「游戏设置 → 活动开关」页里切换某个活动是否下发,这里负责持久化 + 缓存,
// 并让活动目录下发(buildCurrentOnlineEventInfoBodyAt)与各活动投影读取它。
//
// 为什么单独一份 scope 而不是塞进 gm-gameplay:
//
// gameplay 那一份 scope 管的是"数值旋钮"(掉落率、倍率、疲劳、燃烧),
// 活动开关管的是"这个活动要不要在客户端显示/可参与",语义与读写频率都不同。
// 分开后,启动器页面可以各自独立刷新,也不用担心一份 scope 里键越堆越多。
//
// 持久化与 gameplay 设置同模式:repositories.Settings + scope + mutex 缓存。
// 缓存未就绪时首次读取落一次仓储,之后走内存;写入即时更新缓存并落盘。
//
// 开关的默认值(defaults)来自当前部署的真实状态,不是"全开":
//
// spend_time 累计在线得好礼(2347) 默认关:本部署走 PC 房泡点(228)分支
// level_aiming 等级预约(222) 默认关:按运营要求已关闭
// joust 骑士马战(2365) 默认关:活动已取消
// monthly 月度签到(2370) 默认开
// weekly 周周签到(2208) 默认开
// pcroom PC 房时间点(228) 默认开:当前生效的泡点活动
import (
"context"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const gmActivitySwitchesScope = "global:dnf90:gm-activity-switches"
// 活动开关键名。值与 true/false 对应("1"/"true" 为开)。
const (
gmActivitySpendTimeKey = "spend_time"
gmActivityLevelAimingKey = "level_aiming"
gmActivityJoustKey = "joust"
gmActivityMonthlyKey = "monthly_attendance"
gmActivityWeeklyKey = "weekly_attendance"
gmActivityPCRoomKey = "pcroom_timepoint"
)
// gmActivitySwitchDefinition 描述一个可切换的活动。
type gmActivitySwitchDefinition struct {
Key string // 持久化键名
Title string // 中文名
Description string // 中文说明
Default bool // 默认开关状态
}
// gmActivitySwitchDefinitions 全部可切换活动(顺序即启动器展示顺序)。
var gmActivitySwitchDefinitions = []gmActivitySwitchDefinition{
{
Key: gmActivityPCRoomKey,
Title: "PC房时间点奖励",
Description: "泡点/挂机得奖励(事件 228/561)。当前部署默认开启。",
Default: true,
},
{
Key: gmActivitySpendTimeKey,
Title: "累计在线得好礼",
Description: "按累计在线时长领取奖励(事件 2347)。与 PC 房泡点互斥,默认关闭。",
Default: false,
},
{
Key: gmActivityMonthlyKey,
Title: "月度签到",
Description: "每月推荐副本通关计数领奖(事件 2370)。默认开启。",
Default: true,
},
{
Key: gmActivityWeeklyKey,
Title: "周周签到",
Description: "每周签到领奖(事件 2208)。默认开启。",
Default: true,
},
{
Key: gmActivityLevelAimingKey,
Title: "等级预约(命运/决策)",
Description: "全职LV预约得好礼(事件 222)。按运营要求默认关闭。",
Default: false,
},
{
Key: gmActivityJoustKey,
Title: "骑士马战",
Description: "骑士马战竞猜活动(事件 2365)。已取消,默认关闭。",
Default: false,
},
}
// GMActivitySwitch 单个活动的当前开关状态。
type GMActivitySwitch struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
}
// GMActivitySwitches 活动开关总览(供 GM 接口 / 启动器展示)。
type GMActivitySwitches struct {
Switches []GMActivitySwitch `json:"switches"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// gmActivitySwitchesFromRecord 从设置记录解析全部活动开关。
// 缺失的键回落对应默认值;非法值回落默认值(不做硬失败,保持可恢复)。
func gmActivitySwitchesFromRecord(record dnfrepo.SettingsRecord, found bool) map[string]bool {
out := make(map[string]bool, len(gmActivitySwitchDefinitions))
for _, def := range gmActivitySwitchDefinitions {
out[def.Key] = def.Default
}
if !found {
return out
}
for _, def := range gmActivitySwitchDefinitions {
raw := strings.TrimSpace(record.Values[def.Key])
if raw == "" {
continue
}
out[def.Key] = raw == "1" || strings.EqualFold(raw, "true")
}
return out
}
// GMActivitySwitches 返回当前活动开关总览(带缓存)。
func (s *Service) GMActivitySwitches(ctx context.Context) (GMActivitySwitches, error) {
if s == nil {
return GMActivitySwitches{}, ErrGMUnavailable
}
s.gmActivitySwitchesMu.Lock()
defer s.gmActivitySwitchesMu.Unlock()
if s.gmActivitySwitchesReady {
return s.gmActivitySwitchesSnapshotLocked(), nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return GMActivitySwitches{}, ErrGMUnavailable
}
record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope)
if err != nil {
return GMActivitySwitches{}, err
}
s.gmActivitySwitches = gmActivitySwitchesFromRecord(record, found)
s.gmActivitySwitchesAt = record.UpdatedAt
s.gmActivitySwitchesReady = true
return s.gmActivitySwitchesSnapshotLocked(), nil
}
// GMSetActivitySwitch 切换某个活动的开关状态,返回更新后的总览。
func (s *Service) GMSetActivitySwitch(ctx context.Context, key string, enabled bool) (GMActivitySwitches, error) {
if s == nil {
return GMActivitySwitches{}, ErrGMUnavailable
}
key = strings.TrimSpace(strings.ToLower(key))
if !validGMActivitySwitchKey(key) {
return GMActivitySwitches{}, fmt.Errorf("%w: 未知的活动开关 %q", ErrGMInvalidRequest, key)
}
s.gmActivitySwitchesMu.Lock()
defer s.gmActivitySwitchesMu.Unlock()
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return GMActivitySwitches{}, ErrGMUnavailable
}
// 先确保缓存已就绪(读当前值),再改。
if !s.gmActivitySwitchesReady {
record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope)
if err != nil {
return GMActivitySwitches{}, err
}
s.gmActivitySwitches = gmActivitySwitchesFromRecord(record, found)
s.gmActivitySwitchesAt = record.UpdatedAt
s.gmActivitySwitchesReady = true
}
// 读最新记录用于落盘(避免覆盖其它并发写入)。
record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope)
if err != nil {
return GMActivitySwitches{}, err
}
if !found {
record = dnfrepo.SettingsRecord{Scope: gmActivitySwitchesScope}
} else {
record = dnfrepo.CloneSettings(record)
}
if record.Values == nil {
record.Values = make(map[string]string)
}
record.Values[key] = strconv.FormatBool(enabled)
record.UpdatedAt = time.Now().UTC()
if err := dnfrepo.SaveSettingsFields(ctx, repositories.Settings, record, dnfrepo.SettingsFieldValues); err != nil {
return GMActivitySwitches{}, err
}
s.gmActivitySwitches[key] = enabled
s.gmActivitySwitchesAt = record.UpdatedAt
return s.gmActivitySwitchesSnapshotLocked(), nil
}
// activitySwitchEnabled 读取某个活动的当前开关(内部无锁读取,调用方自行加锁)。
func (s *Service) activitySwitchEnabledLocked(key string) bool {
if s == nil || s.gmActivitySwitches == nil {
return false
}
enabled, ok := s.gmActivitySwitches[key]
return ok && enabled
}
// ActivitySwitchEnabled 读取某个活动的当前开关(公开、带锁)。
//
// 语义:开关「开启」意味着该活动照常下发/可参与;「关闭」意味着不下发、
// 客户端不显示该活动。未持久化过时回落定义里的默认值。
//
// 注意默认值的含义:本方法在缓存未就绪时会尽力加载一次(懒加载),
// 加载失败或仓储不可用时,回落"默认值"——即该活动在定义里的 Default。
// 这样即使 GM 接口从未被调用,玩法侧也能按部署默认(而非全关)工作。
func (s *Service) ActivitySwitchEnabled(ctx context.Context, key string) bool {
if s == nil {
return false
}
key = strings.TrimSpace(strings.ToLower(key))
s.gmActivitySwitchesMu.Lock()
defer s.gmActivitySwitchesMu.Unlock()
if !s.gmActivitySwitchesReady {
if repositories, ok := s.repositoryGroup(); ok && repositories.Settings != nil {
if record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope); err == nil {
s.gmActivitySwitches = gmActivitySwitchesFromRecord(record, found)
s.gmActivitySwitchesAt = record.UpdatedAt
s.gmActivitySwitchesReady = true
}
}
}
if s.gmActivitySwitches == nil {
for _, def := range gmActivitySwitchDefinitions {
if def.Key == key {
return def.Default
}
}
return false
}
if enabled, ok := s.gmActivitySwitches[key]; ok {
return enabled
}
for _, def := range gmActivitySwitchDefinitions {
if def.Key == key {
return def.Default
}
}
return false
}
// gmActivitySwitchesSnapshotLocked 组装总览(调用方须持 gmActivitySwitchesMu)。
func (s *Service) gmActivitySwitchesSnapshotLocked() GMActivitySwitches {
switches := make([]GMActivitySwitch, 0, len(gmActivitySwitchDefinitions))
for _, def := range gmActivitySwitchDefinitions {
enabled := def.Default
if s.gmActivitySwitches != nil {
if value, ok := s.gmActivitySwitches[def.Key]; ok {
enabled = value
}
}
switches = append(switches, GMActivitySwitch{
Key: def.Key,
Title: def.Title,
Description: def.Description,
Enabled: enabled,
})
}
return GMActivitySwitches{
Switches: switches,
UpdatedAt: s.gmActivitySwitchesAt,
}
}
func validGMActivitySwitchKey(key string) bool {
for _, def := range gmActivitySwitchDefinitions {
if def.Key == key {
return true
}
}
return false
}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more