diablo2-web/HANDOVER.md

367 lines
24 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.

# 交接文档 · d2web
**项目路径:`/root/diablo_web`**(原 `/root/codex_web/dsh/d2web`,2025-09-12 迁移;目录内没有任何写死的绝对路径,迁移后功能与迁移前一致,验证见文末「迁移后复验」)
一句话:**用纯 TypeScript + Vite + WebGL2 从零写的暗黑破坏神 2 引擎**,读取**用户自备**的经典版 MPQ,
目标是浏览器内可玩的垂直切片。仓库**不含任何暴雪素材**,零运行时依赖(只用平台 API)。
---
## 1. 现在能玩什么
```bash
cd /root/diablo_web
npm install # 只装 devDependencies:vite / typescript / @types/node
# 夹具(仓库不含素材,夹具是脚本生成的)
node scripts/make-map-fixtures.ts samples/fixtures
node scripts/make-mpq-fixture.ts samples/fixtures # 把夹具打成真 MPQ
npm run dev # http://127.0.0.1:5173/
```
| 页面 | 地址 | 内容 |
| --- | --- | --- |
| 资源检查器 | `/` 或 `/?sample=samples/spawn.mpq` | MPQ 头/存储标志/压缩掩码分布、成员列表、精灵逐帧预览 |
| 可行走演示 | `/walk.html?sample=samples/spawn.mpq` | **真实暗黑 1 归档**:CEL/CL2 精灵、调色板、8 方向走动、碰撞 |
| 单人战役沙盒 | `/map.html?data=samples/fixtures` 或 `?mpq=<你的.mpq>` | DS1+DT1 地图、战斗、掉落与拾取、背包、技能、任务、NPC 对话、存档/读档(K/L) |
| 联机合作 | `/net.html?data=samples/fixtures&ws=ws://127.0.0.1:8787&peers=3&peer=0` | 2–4 人同一个世界,各控一名角色 |
| **diablo2 地图 + 女法师** | 本地 `/acts.html?act=1`;线上 `https://www.laiseek.xyz/diablo2/?act=1`(`?act=1..5`、`?level=<label>`、`?quadrant=<DS1>`、`?live=1`)。页面上是三级选择器:**章节 → 场景 → 细分场景**(如 第一章 / 罗格营地 / 北),中文场景名见 `src/game/level-names-zh.ts` | **真实 D2 数据**:默认读预解包资源包(`samples/d2-packs`,索引 PNG + JSON),没有包或 `?live=1` 时直接按 HTTP range 读归档并解码。等距投影地图 + 8 方向走动 + 碰撞 + **真·女法师(DCC+COF 合成,16 层/8 方向)**,**无怪物**;可破坏物品位置与美术成员已进包,静态帧待接 |
| 旧 act 页(已下线) | `/acts/`、`/acts-packs/`、`/acts-data/` | 全部 `410 Gone`;页面迁到 `/diablo2/`,资源包 `/diablo2/packs/`,归档 `/diablo2/data/*.mpq` |
联机需要先起中继(自写、零依赖、只转发字节、不懂游戏):
```bash
npm run net-server # 等价于 node scripts/net-server.ts 8787
```
操作:WASD/方向键移动,空格或 J 攻击,E/F 拾取,T 说话,1–4 选技能,K 存档,L 读档。
`acts.html` 只需 WASD/方向键:输入是**屏幕方向**(菱形格子相对屏幕转了 45°,按"上"沿格子对角线向上走)。
资源包(离线解包,页面默认路径):
```bash
npm run pack:data # scripts/pack-act-assets.ts → samples/d2-packs(62 个地图块 / 35 关,约 46.8 MB)
npm run verify:packs # 与现读现解逐项比对:绘制序/坐标、碰撞栅格逐字节、每帧像素 FNV 哈希(992/992)
npm run verify:listfile # 用社区 1.13c listfile 逐归档判定"这个成员到底存不存在"
npm run verify:deploy # 线上 /diablo2/ 页面 + 资源包 + Range 206 + 旧入口 410(17/17)
npm run verify:alignment # 墙/地面基线是否与引擎公式一致、D2MOO 的 80px 差是不是常量(6/6)
npm run verify:tiles # 每个槽位画的是不是它该有的瓦片类型(地面=0)+ 位置公式(5/5)
npm run verify:object-lookup # DS1 对象 id → token/mode 与 Objects.txt 交叉核对(19/19)
```
线上实测(真实 TLS,同一台机):
| | 请求数 | 传输 | 出画面 |
| --- | --- | --- | --- |
| act1 资源包 | 4 | 1.21 MB | 1.5 s |
| act1 读归档(`?live=1`) | 1,344 | 6.22 MB | 12.1 s |
| act5 资源包 | 9 | 3.80 MB | 2.1 s(首帧只要 7 页里的 4 页) |
| act5 读归档(`?live=1`) | 3,971 | 11.24 MB | 36.5 s |
线上部署(nginx 在 `/etc/nginx/sites-enabled/default` 里,页面 `/diablo2/`、资源包 `/diablo2/packs/`、归档 `/diablo2/data/*.mpq`;旧 `/acts*` 一律 410):
```bash
npm run build:game # → dist-game/(base=/diablo2/)
rm -rf /var/www/d2web && mkdir -p /var/www/d2web && cp -r dist-game/* /var/www/d2web/
rm -rf /var/www/d2packs && mkdir -p /var/www/d2packs && cp -r samples/d2-packs/* /var/www/d2packs/
# 归档用硬链接暴露给 nginx(/root 是 0700,nginx 读不到):
# mkdir -p /var/www/d2data && ln -f samples/d2/*.mpq /var/www/d2data/
systemctl reload nginx
```
真数据相关的验收命令:
```bash
npm run verify:implode # 四个归档全量成员解码(implode 端到端证明)
npm run verify:acts # 五个 act 城镇:表→DS1→DT1→等距场景 + 碰撞
# 归档没有 (listfile) 时(Patch_D2.mpq 就是),成员名在 Storm 里是加密的:
# MpqArchive.open(source, { listfile }) 可挂社区名单,样例见 scripts/verify-listfile.ts
#
# 对象/瓦片这几件事都按 OpenDiablo2 的引擎口径对齐过(副本在 samples/od2/):
# 1. DS1 对象的 `id` 不是 Objects.txt 的行号,而是"该 act 对象表"的索引:
# src/game/object-lookup.ts(数据由 npm run port:object-lookup 从 OD2 的表生成)。
# identity 映射会把 act1 的喷泉(id 0)读成 Objects.txt 第 0 行 "Expansion"。
# 2. 瓦片匹配的键是 `style:sequence:type`,其中 `type` 对应 DT1 头 **+20 的 `Type`**,
# **不是 +0 的 `Direction`**(那是朝向/变体索引,实测 1..5,永远不会等于 14 树/15 屋顶)。
# 键错会让树/屋顶/影子全落 loose 兜底、画成地面——罗格营地的帐篷"没屋顶"就是这个。
# 改键后 35 关 9,595 条墙引用 100% 精确命中(verify:alignment 有永久断言)。
# 同一 style:sequence:type 下有多张变体图(Direction 与 RarityFrameIndex 不同),引擎**逐格**
# 按 RarityFrameIndex 加权随机选一张,种子来自 (level seed, cellX, cellY):pickVariant + levelSeed。
# 3. 一格的 sub-tile flags 是**所有层做 OR**(OD2 SubTileFlags.Combine),影子层是 type 13 的
# 独立层,也必须并进碰撞;实测三张城镇里影子贡献 0 个阻挡子格(scene.shadowBlockedSubtiles)。
# 4. 方向:引擎是 64 方向空间经 5 张查表映射到 COF 方向(OD2 Dir64ToCof),
# src/game/character.ts 的 dir64ToCof 已按表移植;8 向输入下 4 方向 COF 的映射与
# 旧启发式**不同**(2/3 都映到 1),16/32/64 方向下相同。
# 4b. **地面槽位只能画 DT1 type 0**:DS1 的 floor 记录没有 type 字段,引擎语义是 type 0
# (OD2 `TileData(style, sequence, 0)`)。曾经这里传 null 走"类型无关"兜底池,池里混着
# 墙/柱子/影子/树/屋顶;加上变体加权随机后,地面槽位会随机挑到暗色石墙瓦片,而地面绘制
# 不做墙那套 minBlockY+80 补偿 → 画面里出现"悬在地面上的黑色方块"(35 关实测
# 1,918/28,704 个地面槽画错类型;修道院大教堂 28.2%、地下墓穴 4 层 41.5%)。
# `npm run verify:tiles` 把这个普查变成永久断言(错类型 0 / looseRefs 0 / missingTiles 0
# / 地面·墙·屋顶位置公式自洽)。
# 5. 屋顶(DS1 wall type 15)单独成层、最后绘制,偏移用引擎的 -roofHeight:
# scene.json 里是 `roofs` 数组,页面在地面/墙/角色之后画它(verify:packs 单独比对)。
# 当前 62 个预置关卡里 9 张有屋顶,共 737 个绘制(act 4 城镇 53 个)。
# (打包器的 DT1 解码缓存有上限 16 个库:不设上限时整轮烘焙会涨到 4 GB 以上。)
#
# 页面 UI:header 只留标题 + HUD(原来的三个站内链接已去掉);三个下拉框是
# `#act`(章节)/ `#scene`(场景,取自 pack 索引的 slug 分组)/ `#variant`(细分场景,
# 该场景下的 DS1 变体)。变体名:城镇/回廊/鲁高因按 `TownN1`/`CourtW`/`LutN` 后缀给方位名,
# 其余显示 DS1 名。中文场景名是**社区通用译名**(手工整理,见 level-names-zh.ts 头部说明;
# 我们这份安装包的 `data\local\LNG\CHI\string.tbl` 解出来是乱码,`.tbl` 解码器尚未对真文件校准)。
# 回归断言:`scripts/browser/checks/map-switch.js`(8 项:无多余链接、三级结构、三个下拉框的跳转参数)。
npm run verify:d2 # samples/d2 里每个 MPQ 的头/块槽/sha256
```
---
## 2. 目录结构
```
src/
mpq/ MPQ v1 容器:header/tables/sectors、crypt(HashString/块加密/文件键)、解压掩码分发
formats/ dc6 ds1 dt1 pal pl2 cel pcx tbl sprite(各格式的纯解码器,无 DOM 依赖)
game/ combat(战斗/怪物 AI/经验)items(基物/词缀/背包/掉落)skills(投射物)
quests(任务/NPC/对话)map(DS1+DT1 合成、子瓦片碰撞、深度插入)
animation(按方向的帧控制器)save(快照 + .d2s)rng(可复现随机)tables(表解析)
hitflash/… 等辅助
net/ protocol(二进制消息)transport(内存对 / 内存 hub / WebSocket)lockstep(锁步内核)
netplay(握手门控 + 输入游标 + 哈希收发 + 超时)
render/ atlas(精灵图集打包)renderer(WebGL2 单批次四边形 + 顶点色 tint)
sim/ loop(定点 25 Hz 循环,含追帧上限)input(键盘)
scene/ map-scene.ts(单人战役,~1200 行)net-scene.ts(联机,~600 行)
index.html walk.html map.html net.html 四个入口
scripts/ 夹具生成、验证脚本、中继服务器、浏览器验证工具(见 scripts/browser/README.md)
tools/go-oracle/ 独立 Go 参考解码器源码(差分验证用,见 §6)
samples/ 夹具与被 .gitignore 忽略的归档(spawn.mpq、fixtures/)
dist/ 构建产物
```
---
## 3. 里程碑状态与证据
| 里程碑 | 状态 | 证据 |
| --- | --- | --- |
| M0 资源管线 | ✅ | 真暗黑 1 `spawn.mpq`(25.8 MB / 1029 项)全量解包 + 精灵解码;自写 MPQ 写入器往返 + `mpyq` 独立读取;与 3 个独立实现全字段差分 |
| M1 可走地图 | ✅ | DS1+DT1 渲染、5×5 子瓦片碰撞、8 方向动画、深度插入;`map.html`(夹具/真归档)、`walk.html`(真 D1 归档,25 tps) |
| M2 战斗沙盒 | ✅ | `verify-combat.ts` 38 项;浏览器内实测 |
| M3 物品系统 | ✅ | `verify-items.ts` 58 项 |
| M4 技能/任务/NPC | ✅ | `verify-m4.ts` 56 项 |
| M5 存档与联机 | ✅ | `verify-m5.ts` 33 项(读档续跑逐 tick 一致);`verify-net.ts` 143 项;三浏览器同时联机实测 |
一条命令跑完无头验证:
```bash
npm run verify:all # combat + items + m4 + m5 + net
```
浏览器端证据用 `scripts/browser/`(见其 README)。实测过的数字(本轮迁移前):
- 单人战役:存档后继续到 tick 364,读档回到存档点(位置 276、击杀 4、金币 46、地面 2、等级 2),24.6 tps、`glError 0`
- 真归档:`walk.html?sample=samples/spawn.mpq` → `ready: true`、25.0 tps、`glError 0`
- 三人联机:三页 `peers 3`、各听到 2/2 名队友、都跑到 **worldTick 306 且完全齐平**、各比对 110–115 个哈希全一致、`malformed 0`、无 desync、三页 `players` 数组逐字段相同
---
## 4. 关键设计约定(改代码前先读这段)
1. **模拟必须是纯函数**。联机靠锁步:每台机器各自算整个世界,网络上**只传按键**。
所以任何读时钟、读 `Math.random`、读宿主环境的行为都会变成 desync。
随机一律走 `Rng`(种子进存档),时间一律走 25 Hz 的 tick 计数。
2. **tick 只在所有对等方输入到齐时才推进**(`LockstepSession.step` 返回 `waiting`)。
抢跑 = 分叉;等待 = 正确。
3. **输入延迟**(`inputDelayTicks`)是延迟预算:某一 tick 的输入提前若干 tick 发出。
发送游标要从 `0` 补齐到应付 tick,否则 `0..D-1` 谁都不发,游戏在第一 tick 前就死锁。
4. **握手门控**:没确认对方在监听(收到对方的 hello)之前**不发任何输入**。
先发出去的帧如果对面还没连上,中继无人可转发、发送方无从得知,那些输入永久消失 → 两边永久互等。
确认用 `ackTo` 指名道姓(四人局里"我听到了"必须说清听到了谁)。
5. **`CombatWorld.player` 与 `CombatWorld.players[i]` 必须是同一个对象**。
读档/快照是靠对象展开拼世界的,展开后 `player` 是新的、`players` 是旧的,
症状是"世界模拟一个身体、屏幕画另一个"。加新字段时要么避开,要么调用 `rebindPlayer()` 重新绑定。
6. **一个 tick 里所有玩家先动、怪物后动,且只动一次**(`tickCombatMulti`)。
每人各调一次 `tickCombat` 会把怪物跑两遍。
7. **怪物只攻击"本 tick 开始时还活着"的玩家**;全场无人存活时怪物回合整个跳过。
这条是单人死亡/重生手感的来源(死亡时世界停住、重生当 tick 不挨打)。
8. **解码器宁可拒绝也不猜**。没有真文件对照的字段(`.d2s` 数据段、部分表列)
一律**原样保留字节**,不做"看起来合理"的解析——猜错的解码器会静默产出坏存档/坏贴图。
9. **渲染器画完必须 `renderer.flush()`**。批处理器不 flush 就只剩清屏色,
看起来和"场景没加载"一模一样。
10. **MPQ 文件键用纯文件名**(不是成员全路径),查到表项用的是全路径;存储型成员**没有扇区偏移表**
(只有 `COMPRESS` 才需要)。这三条都踩过。
---
## 5. 验证方法论(为什么这些数字可信)
- **差分验证**:与独立实现对照,而不是自己跟自己对。用过:StormLib(MIT,只读语义)、
DevilutionX/Devilution、`OpenDiablo2/{dc6,ds1,dt1,pl2,tbl_text}`(Go)、npm `dc6png`。
差分脚本 `scripts/verify-format-parity.sh`(见 §6)。
- **真数据**:暗黑 1 试玩版 `spawn.mpq` 是唯一的**真实归档**证据,用于 MPQ 容器、CEL/CL2、调色板、
精灵宽度推断。
- **判据要能失败**:`verify-net.ts` 里的篡改用例会改写飞行中的输入,要求**在正确的 tick 报出 desync**;
"永远不会响的检测器"比没有检测器更糟。
- **不要谎报核对**:联机时落后方收到的是自己还没算到的 tick 的哈希,
这些哈希**缓存到本地历史追上再比**,无法核对的单独计数(`hashesIgnored`),不算作"一致"。
- **浏览器侧的判定靠状态字段,不靠看图**:这套环境里模型读不了图,
且 `readPixels` 在合成后返回清空缓冲。页面把状态挂在 `window.__d2webNet` /
`window.__d2webMap` / `window.__d2web` 上,由 CDP 读取断言。
---
## 6. 环境依赖(都在本机,但**不在仓库里**,重装机器要重建)
| 用途 | 位置 | 重建方式 |
| --- | --- | --- |
| Node | v22.22.2(系统) | Node 的类型剥离模式跑 `.ts` 脚本,**不支持 TS 参数属性**(`constructor(private x)`),脚本里别写 |
| headless Chromium | `/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome` | `npx playwright install chromium`;启动参数见 `scripts/browser/README.md` |
| Go 参考解码器 | 源码已收进 `tools/go-oracle/main.go` | `cd tools/go-oracle && /opt/go1.22/bin/go mod init ref && /opt/go1.22/bin/go get github.com/OpenDiablo2/{dc6,ds1,dt1,pl2}@latest && /opt/go1.22/bin/go build -o /tmp/refdump .`(Go 在 `/opt/go1.22`,GOPROXY 用 `https://goproxy.cn,direct`) |
| `dc6png`(npm) | 曾在 `/tmp/dc6png` | `npm i dc6png` 到任意目录,用 `DC6PNG=<…>/src/index.js` 指过去 |
| Python + PIL | 系统 | 差分脚本比像素时用 |
跑完整差分:
```bash
REFDUMP=/tmp/refdump DC6PNG=/tmp/dc6png/node_modules/dc6png/src/index.js \
bash scripts/verify-format-parity.sh /tmp/d2fix
```
三个外部依赖都是**可选**的:缺了会打印 `SKIP`,剩余检查照跑。
---
## 7. 还没做完的(按"能不能自己做"分类)
### A. 原来卡在"没有真 D2 MPQ"上的(**已解除**,`samples/d2/` 有真数据)
现状:用户自备的 1.13c 数据落在 `samples/d2/`(11 个 MPQ,约 1.9GB,来源/字节数/sha256/
版本证据在 `samples/d2/MANIFEST.md`)。**但那是汉化/免 CD 整合安装**,客户端 DLL 与官方
1.13c 补丁文件集不一致,别拿它做字节级对照。
**第一前置已做完**:`src/mpq/implode.ts` 实现了 **PKWARE DCL implode**,`decompress.ts` 接线,
`archive.ts` 的读法修正。现在实测能读:
```bash
npm run verify:implode # 30914 个成员、893 MB 全部解出,12/12 断言
npm run verify:acts # 五个 act 城镇解析 + 合成,40/40
```
只剩 **ADPCM+Huffmann 音频**(572 个成员,`mask 0x41/0x81`,本轮不做)。
这一轮踩到的坑(都写进了代码注释,别再踩):
1. **掩码字节与压缩标志是两种写法**。`MPQ_FILE_COMPRESS(0x200)` 才有扇区首字节掩码;
`MPQ_FILE_IMPLODE(0x100)` 单独出现时**没有掩码字节**,整段就是 implode 流。
`Patch_D2.mpq` 的每张表都是后者;旧代码按"有掩码"读,遇到首字节 0x00 就当成 stored,
**静默返回压缩字节**(`levels.txt` 能返回 66,615 字节却不含 `LevelName`、尾部全是 0)。
现在的规则:`body.length === expected` → stored;否则按标志选 implode 或掩码,且长度不符一律抛错。
2. **DT1 的 `blockSize` 不是 `numBlocks * 20`**。真文件里 25 个块该字段是 6900
(= 25×20 头 + 25×256 体)。旧断言用 20×N 校验,于是**只认自己生成的夹具、拒绝所有真文件**。
现在按 `numBlocks*20 + Σlength` 校验,不符只记 warning。
3. **DS1 的 `style` 匹配的是 DT1 瓦片自己的 `style` 字段**,不是该 DT1 在 `LvlTypes` 里的下标。
`Act1/Outdoors/River.dt1` 里的瓦片内部 style 是 2/3,`Fence.dt1` 只有 0;按"库下标"解会把
大部分城镇解对、再丢掉 219 个引用。改成合并所有库按 `style:sequence:direction` 查之后 missing=0。
4. **`LvlPrest` 要用 `LevelId` 关联,不是 `Def`**。Act 2 城镇 `Def=301 / LevelId=40`;用 `Def` 会
给四个 act 返回**别的关卡**的地图。
5. **D2 是等距投影**:格子在屏幕上是 80×40 的菱形(`(cx-cy)*80, (cx+cy)*40`),5×5 子格是 16×8;
地板画在 `cell+(-80, 0)`,墙要加 `minBlockY+80`(墙的美术长在格子上方)。矩形摆放只对夹具成立。
6. **空槽要跳过**:DS1 每个格子都带固定数量的墙槽,其中 `prop1 == 0` 的是占位(Act 1 城镇 4674 个
墙槽里 4275 个如此),画出来就是垃圾。
**可破坏物品的美术卡在同一个解码器上**:`data\global\objects\` 下是 **1748 个 DCC + 1461 个 COF,只有 13 个 DC6** ——
也就是说 D2 画对象用的是和角色一样的 COF+DCC 合成管线。资源包因此只烘了它们的**位置与元数据**
(`Objects.txt` 的名称/HP/Token + 选定的美术成员名),像素留给 DCC/COF 解码器(和法师同一件事)。
不要把这些文件当 DC6 硬解:头部对不上,解出来是垃圾(本轮实测过)。
implode 落地后,§7A 剩下这四件(现在都有真文件可对照):
1. `node scripts/inspect-mpq.ts <file> header` 和 `hist` → 头字段与压缩掩码分布。
2. **DT1 数据块偏移基准**:`src/formats/dt1.ts` 现在按"先相对、失败再绝对"处理 `FileOffset`,需要真文件确认。
3. **经典 `string.tbl` 索引布局**:`src/formats/tbl.ts` 目前只有**构造检查**级证据
(Go 侧 `tbl_text` 实现的是后来带哈希表的变体,不能当对照)。
4. **DCC / COF 动画**:未实现。Go 参考实现自己就报 `bottom up frames are not implemented`,
所以当时判断做不出可信对照;真文件到手后按格式文档 + 逐字节对照可以做。
5. **`.d2s` 数据段布局**:现在只实现签名/版本/名字/职业/等级/校验和,其余段原样保留。
6. **D2 表列映射**:`src/game/*` 里读表用的是夹具列名 + 少量官方文档确认的列名。
接真表要逐列核对(列名集中在各 `*FromTable`/`monsterStatsFromRow`,改动集中)。
### B. 工程上可以做但还没做的
- **入场大厅 / 断线重连策略**:现在是"少一人就等"(正确但生硬),没有"踢人/暂停/恢复"的选择界面。
- **输入回滚**:只做了"等待",没做预测 + 回滚(对 25 Hz 局域网够用,跨洋会明显卡顿)。
- **联机与战役状态**:联机是独立沙盒——世界和角色共享,但**背包/任务不进网络**。
要打通需要把物品/任务状态纳入确定性模拟并进哈希。
- **联机场景的怪物/掉落**:目前联机场景只有战斗与经验,没有联机的物品掉落与拾取。
- **DCC/COF 之外的美术缺口**:联机场景没有背包 UI、没有血蓝球(单人场景有)。
---
## 8. 迁移后复验(本次迁移做的检查)
```bash
cd /root/diablo_web
npx tsc --noEmit # 类型检查
npm run build # 四个入口都进产物
node scripts/verify-net.ts # 143 项
```
三者在迁移后均通过(见本次会话最后一条工具输出);开发服务器与中继已改为从新路径启动:
```bash
npm run dev # 后台任务,http://127.0.0.1:5173/
npm run net-server # 后台任务,ws://127.0.0.1:8787
```
页面级复验(`scripts/browser/`):
```bash
CHROME=/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome
node scripts/browser/inspect-page.mjs "$CHROME" \
"http://127.0.0.1:5173/map.html?data=samples/fixtures" \
scripts/browser/checks/map-save-load.js
node scripts/browser/many-pages.mjs "$CHROME" \
"http://127.0.0.1:5173/net.html?data=samples/fixtures&ws=ws://127.0.0.1:8787&peer=0" \
"http://127.0.0.1:5173/net.html?data=samples/fixtures&ws=ws://127.0.0.1:8787&peer=1"
```
---
## 9. 从哪读起
1. `README.md` —— 里程碑叙述 + **「验收:整条目标链的证据」** 一节(逐项对照可复现命令)。
2. 本文 §4「关键设计约定」—— 十条踩过的坑,改代码前必读。
3. `src/net/lockstep.ts` 顶部注释 —— 锁步为什么这么设计。
4. `src/net/netplay.ts` 的握手与发送游标 —— 四个真 bug 的现场。
5. `scripts/verify-net.ts` —— 读测试比读实现更快理解协议与时序。
## 4f. 生成关卡(101 关)现状与修复清单 —— 暂缓,未烘焙
页面上的 **35 个场景全部是 `DrlgType=2` 的固定 DS1**;生成类关卡一个都没进包:
| `DrlgType` | 关卡数 | 已烘焙 |
| --- | --- | --- |
| 2 预置 | 35 | 35 |
| 1 随机迷宫 | 70 | 0 |
| 3 野外 | 31 | 0 |
例:**鲜血荒野 = `Act 1 - Wilderness 1`(Id 2,DrlgType=3)**,未烘焙。`scripts/pack-act-assets.ts`
里以 `pending: { wilderness: 31, maze: 70 }` 如实记录,页面不会假装有这些图。
`npm run verify:generators` 实测(2026-09-13):
1. **9 关直接抛错** `no usable LvlPrest pieces`:Act 2 Harem 2、Act 3 憎恨囚牢 1/2、
Act 5 冰窟 1/1A/2/2A/3/3A。根因:这些迷宫的 `LvlPrest` **没有该 `LevelId` 的行**
(实测行数 = 0),房间件要按 `LvlMaze` 行 + **关卡类型的件名**解析。
2. **可达率**:75 关中 15 关 < 50%,其中 8 关 = **0.0%**(Crypt 1A/2A/3A/3B/3C/3D、
Pandemonium 1、Wilderness 5)——开口方向/拼接没对上,或出生点被围死。
3. **瓦片引用缺失**:36/75 关存在,最高 **56.8%**(Act 2 Lair/Tomb 系列)。
4. **野外不可玩**:鲜血荒野可达 1.6%、黑色沼泽 0.0%、墓地 0.0%、冰冷之原 3.2%。
修复顺序(每步都要过 `verify-generators` 的阈值门:确定性 + 可达率 + 缺引用 + 房间数 ≥
`LvlMaze.Rooms`):① 件来源改写(`LvlMaze` + 类型件名)→ ② 开口拼接与出生点连通 →
③ 件→瓦片映射(按 `Dt1Mask` 与件自身 style 选库,注意 type(+20) 那条教训)→
④ 野外 `LvlSub` 替换规则。烘进包时必须在 pack 索引/HUD/文档里标注
**“按官方参数表复现,非引擎原版布局”**(生成逻辑移植自 D2MOO,不是逐字节还原)。