Files
90CNServer/docs/踩坑记录.md
T
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

317 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 踩坑记录
> 记录本项目里**踩过、且容易再踩**的坑。每条只回答四个问题:现象是什么、为什么会踩、
> 为什么没早发现、以后怎么绕开。细节排查看对应文档,这里留的是**可复用的判断**。
>
> 新增一条的格式:`现象 → 一句话结论 → 成因 → 为什么没早发现 → 教训`。
| 编号 | 坑 | 一句话教训 |
|------|----|-----------|
| [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 指纹绑定 |