docs:新增踩坑记录,13 条坑的成因复盘与判断规则

和「排查记录」不同,这份文档不写怎么修,只回答「为什么会踩、为什么没早发现」,
给的是可迁移的判断,而不是单次操作步骤。

- P-001 启动器套娃开窗:三层成因(打包改变 sys.executable 语义 / Popen 只保证
  进程创建不保证脚本执行 / 端口顺延把必然失败伪装成正常启动)、四个掩盖因素、
  四个不成立的隐含假设,以及「拉起子进程要校验副作用而非返回值」等五条教训
- P-002~P-013:DLL 指纹绑定、CRC32 种子、CRC 槽位扫错节、CSS 特异性、IE11、
  旧进程占端口、WebView2 缓存、console=False、图片占位时序、仓库与部署副本、
  replace 静默失败、补丁包缺文件
- 结尾提炼八条通用规则与发版检查清单
- 文档索引:根 README、docs/README 各补一行;排查文档顶部加成因复盘的交叉链接
This commit is contained in:
hanfuit committed 2026-11-05 17:27:47 +08:00
1 parent c6587d88ee
commit adcc5b0f3a
4 files changed
+321

No files matched your search

+1
View File
@@ -194,6 +194,7 @@ pvfexport -pvf Script.pvf -out dnf_items.json -compact
| [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` 模板 |
+2
View File
@@ -9,6 +9,7 @@
| [PVF解析开发文档.md](PVF解析开发文档.md) | **PVF 解析** 套件(pvfine)、pvfexport 工具、离线名称显示集成 |
| [客户端补丁与CRC校验.md](客户端补丁与CRC校验.md) | **客户端补丁(90CN.dll)** 自校验机制、`patch_dll_crc.py` 用法、与「服务端 DLL 指纹」的区别、排查手册 |
| [启动器窗口风暴排查.md](启动器窗口风暴排查.md) | **启动器重复开窗口 / 页面空白** 的根因(打包后 `sys.executable` 指向自身)、复现手法、修复点与日志判读 |
| [踩坑记录.md](踩坑记录.md) | **踩坑合集**(13 条):每条只写现象 / 成因 / 为什么没早发现 / 教训,开头是一句话教训总表,结尾是发版检查清单 |
| [launcher-README.md](launcher-README.md) | 整合启动器 + GM 工具的原始 README |
其余子项目的 README 分散在各自目录:
@@ -26,3 +27,4 @@
3. **改服务端 GM 接口**:看 [服务端开发文档.md](服务端开发文档.md) 的「GM 接口契约」。
4. **改 PVF 解析 / 物品名**:看 [PVF解析开发文档.md](PVF解析开发文档.md)。
5. **改客户端补丁 / 重编 DLL**:看 [客户端补丁与CRC校验.md](客户端补丁与CRC校验.md)——改完 DLL 要补 CRC,并且要重编服务端。
6. **遇到怪问题先翻 [踩坑记录.md](踩坑记录.md)**:13 条坑的一句话教训和判断规则,可能已经记过了。
+2
View File
@@ -2,6 +2,8 @@
> 版本:v2026.11.05 之后修复(启动器 15,945,162 字节)
> 现象只在部分电脑出现,开发机复现不了;本文记录根因、复现手法、修复点与现场取证方法。
>
> 想看「为什么会写出这种代码、为什么长期没被发现」的成因复盘,见 [踩坑记录.md](踩坑记录.md) 的 **P-001**。
## 一、现象
+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 指纹绑定 |