diablo2-web/README.md

451 lines
36 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
Web 原生的暗黑破坏神 II 引擎(进行中)。**仓库不含任何游戏素材**:引擎只读取你自备的
经典版 MPQ(`d2data.mpq` / `d2exp.mpq` / `patch_d2.mpq` 等),素材始终留在你自己机器上。
零运行时依赖(纯 TypeScript + Vite + 平台 API)。项目位于 `/root/diablo_web`;
**接手/继续开发先读 [`HANDOVER.md`](HANDOVER.md)**(目录导览、十条设计约定与踩坑、验证方法论、
环境依赖重建、未完成项分类)。
## 现状
### M0 · 资源管线 ✅
- **MPQ v1 容器**:头解析、哈希表/块表解密、名字查找、扇区定位、按范围随机读取
(`File`/`Blob` 直接读,**从不把整个归档载入内存**)。
- **Storm 密码学**:crypt table、`HashString`、块密码。密钥取**纯文件名**(basename)——
哈希查找用全路径、解密密钥用 basename,混用会静默解出乱码(而且总长度还对,只校验
大小发现不了)。
- **解压**:zlib 走平台 `DecompressionStream('deflate')`。自适应 Huffman / PKWARE implode /
ADPCM / bzip2 / sparse 按 StormLib 的掩码顺序留位,遇到未实现的掩码**明确报错**而非产生坏数据。
- **精灵解码**:`.cel` 与 `.cl2`(含多组 = 多方向、逐帧宽度、帧头跳过、跨行游程)。
元数(0x80 透明游程 / ≤0xBE 填充 / >0xBE 字面)与 CEL 的行封闭规则都按 DevilutionX 实现。
- **调色板 / 变体**:768 字节 `.pal`、256 字节 `.trn` 索引重映射(怪物换色与 D2 的 palette shift 同一机制)。
- **浏览器检查器**:拖入 MPQ → 列文件/筛选 → PCX 渲染、音频解码、文本/十六进制预览。
### D2 格式解码层 🚧(已实现,等真 MPQ 收口)
- **DC6** 精灵:24 字节文件头 + 帧指针表 + 32 字节帧头;游程三字母表
(`0x80` 换行 / `(b&0x7f)` 透明游程 / 否则字面游程),**行序自下而上**,索引 0 透明。
- **DT1** 瓦片库:276 字节文件头 + 96 字节瓦片记录 + 20 字节块头;两种块编码都实现——
RLE(`(skip,count)` 对,`(0,0)` 换行)与等距(固定 `xjump`/`nbpix` 菱形表,256 字节);
25 个子瓦片碰撞标志(bit0 阻挡行走、bit1 阻挡视线…)可直接用于 M1 碰撞。
- **PL2** 调色板:1024 字节基础调色板 + **1727 张 256 字节换色表**(光照 32 / 反色 16 / 选中单位 1 /
粗粒度 alpha 3×256 / 加色 256 / 乘色 256 / 色相 111 / 红绿蓝色调 3 / 未命名 14 / 最大分量 256 / 变暗 1)
+ 13 色文本调色板 + 13 张文本换色表,共 **443175 字节**——格式**完全没有头/计数/目录**,
表序与数量就是格式本身,所以解码器里把每组的数量逐条写出来而不是推算。
D2 的光照/混合/单位换色全靠"索引→索引"的换色表完成,这也是为什么解码器始终保留索引、最后才落色彩。
- **TBL** 字符串表(经典布局):`crc` + 条目数 + 偏移表 + 每条目 `u16 字符数 + UTF-16LE`;
未用索引解析为 `undefined`(区别于空串)。
- **DS1** 地图:版本化段序列(act / substitution / 内嵌文件名 / 4+4 层流 / 地板层 / 对象 / NPC 路径),
含低于 4 版的旧式层顺序与低于 7 版的方向查表;产出逐格 `walls/floors/shadows/substitutions` + 对象列表。
### 独立实现差分验证(关键证据)
没有真 D2 文件时,"解码器不报错"毫无意义。所以每个格式都与**独立实现**逐字段比对:
| 检查 | 独立实现 | 结果 |
| --- | --- | --- |
| DC6 像素 | `dc6png`(npm,JS) | **128/128 像素一致** |
| DC6 全字段 | `OpenDiablo2/dc6`(Go) | **JSON 完全相同** |
| DS1 全字段 | `OpenDiablo2/ds1`(Go) | **JSON 完全相同** |
| DT1 元数据 | `OpenDiablo2/dt1`(Go) | **JSON 完全相同**(该包的像素解码是死代码,见下) |
| DT1 像素落位 | 夹具编码时的期望网格 | **102400/102400 像素一致** |
| PL2 全表 | `OpenDiablo2/pl2`(Go) | **完全一致**(基础/文本调色板 + 1727 张表的逐表摘要) |
```bash
REFDUMP=/path/to/refdump DC6PNG=/path/to/dc6png/src/index.js \
bash scripts/verify-format-parity.sh /tmp/d2fix
```
**差分测试当场抓到了我自己的一个真 bug**:我最初按 16 字节读 DT1 块头,而
[社区权威文档](http://paul.siramy.free.fr/_divers/dt1_doc/) 与参考实现都是 20 字节
(`X, Y, 2 字节保留, GridX, GridY, Format, Length, 2 字节保留, FileOffset`),
把 `Format/Length/FileOffset` 全部读错了偏移;同时 `FileOffset` 是**相对该瓦片自己的块头起点**。
另外 `dc6png` 独立确认了 DC6 的行序与游程字母表。
### 归档驱动路径 ✅("丢进 MPQ → 出地图"已跑通)
`map.html` 支持三种数据源:**拖入 `.mpq`**、`?mpq=<url>`、`?data=<fixture 目录>`。
打开归档后会按扩展名自动挑成员(`town`/`act1` 优先),页面上有 **DS1 / DT1 / 调色板选择器**与重新加载按钮,
因为真实归档里有成百上千个关卡与瓦片库需要人来选。
为了让这条路径在**没有任何暴雪数据**时也能验证,我写了 `scripts/make-mpq-fixture.ts`:
把夹具打成**真正的 MPQ v1 归档**(表加密、成员混用"原样存储"与"zlib 分扇区"两条路径),
于是"用户丢归档 → 引擎读取 → 解码 → 渲染地图"整条链现在就能端到端测试。
| 检查 | 结果 |
| --- | --- |
| 我的读取器往返 | **5/5 成员逐字节一致**(54354 字节) |
| 独立读取器(Python `mpyq`) | **4/4 已发布成员逐字节一致** |
| 归档驱动场景 | `source=mpq`、自动选中 3 个成员、9 地面 / 9 墙体、**未解析引用 0**、108/225 阻挡 |
| 运行 | **25.0 tps**、双轴移动、撞墙被拒、`glError = 0`、截图 2946 种颜色 |
**这里又抓到两个只有独立实现能发现的问题**:
1. **存储型成员必须带 `SINGLE_UNIT` 标志**。不带的话,标准读取器会走"多扇区表"路径,
把文件自身的数据当成偏移表读出垃圾——而**我自己的读取器恰好容忍**(我实现成"无表直读")。
于是出现了"自己读写全对、别人读不出来"的假绿:`fixture.ds1`(zlib)能读、`palette.pal`(存储)读出 0 字节,
这个不对称正是定位问题的线索。
2. **读取器对未压缩成员多读了一张扇区表**。StormLib 只在 `COMPRESS` 标志下加载偏移表,
未压缩文件是从块起点直读的;我原先对非 single-unit 文件一律读表。已按参考实现修正,并对加密+存储的成员按 `key + 扇区号` 逐扇区解密。
### M5 · 存档与联机 🚧(存档、确定性锁步、**2–4 人浏览器联机合作已跑通**;`.d2s` 待真文件确认)
**存档(快照)**:把续跑所需的一切装进一个带版本号的纯数据结构——战斗世界、背包(**按原有格子位置**)、
任务进度、**随机流的位置**,以及地面掉落物。随机流位置是最容易被漏掉的一项:不保存它,读档后
同一批掉落会以不同顺序出现,看起来"存档能用",直到有人注意到掉的东西不对。
验证方式是存档唯一有意义的验证方式:**读档后继续模拟,要求与原进程逐 tick 完全一致**——
漏掉任何字段都会在几百 tick 后表现为状态分叉。另含畸形存档与未来版本存档的拒绝。
**`.d2s` 角色档**:实现了签名、版本、名字/职业/等级字段与**校验和**;物品/任务/传送点/佣兵等段
**原样保留字节而不去猜**(没有真文件对照时猜这些会写出静默损坏存档的解码器)。
这轮在这里抓到一个**真 bug**:校验和用 JS 的 `<< 1`(32 位有符号移位)会把高位丢弃而不是折叠进位,
导致**离末尾 32 字节以外的字节对校验和完全不可见**——那种位置的篡改检测不出来。已改为按规范做进位折叠。
**确定性锁步**:这一块**完全不依赖暴雪数据**,所以可以强验证。三条规则:
① **某个 tick 只有在所有对等方的输入都到齐时才推进**(缺输入就 waiting,绝不猜测或抢跑——抢跑必分叉);
② **输入延迟**若干 tick 作为网络延迟预算;③ **逐 tick 状态哈希**互相比对,第一处不一致即 desync,
且报告里带上是**哪个 tick** 分叉的(这决定 bug 能不能查)。
`scripts/verify-m5.ts` 无头断言 **33 项**:快照续跑一致性、畸形/未来版本存档拒绝、地面掉落随档保存、
`.d2s` 往返与校验和篡改检测、双会话 300 tick 哈希全同、缺输入等待且计数、输入延迟语义、
过期输入被丢弃、以及**篡改输入必须被识别为 desync 并报出正确 tick**(不会响的 desync 检测器比没有更糟)。
浏览器内实测:存档后状态继续变化(击杀 3→5、金币 39→46、地面 2→3),读档后
**击杀/背包/金币/地面掉落/等级/位置/怪物存活/任务进度/经验全部回到存档点**,随后继续运行,
`glError = 0`、25 tps。这里也抓到一个真实场景 bug:场景里缓存了 `const player = world.player`,
读档替换世界后**这个引用仍指向旧世界**,导致读档那一 tick 的后半段还在写旧数据——已改为读档后重新绑定。
**联机合作(`net.html`)**:每页一个浏览器,**2–4 人同一个世界、各控一名角色**(`?peers=` 指定人数,`?peer=` 是本页编号)。网络上只传四类消息
(谁、按了什么键、我这 tick 的状态哈希、再见),**世界状态一个字节都不传**——每台机器各自把整个
世界算出来,这正是 25 Hz 能跑在普通连接上的原因。
* `src/net/protocol.ts` —— 手写小端编解码。移动量按**定点 ×1000** 传,因为浮点在两台机器上可能
舍入出不同的结果;每条消息都做长度与范围校验,畸形包在网络层就被拒掉,不会流进模拟。
* `src/net/transport.ts` —— 传输是一根「可靠的字节管道」:`memoryTransportPair()`(进程内成对,
可 `hold` 住模拟慢链路)与 `socketTransport()`(浏览器 `WebSocket` 与 Node 全局 `WebSocket` 共用同一份代码,
所以真实 socket 路径能无头验证)。消息尺寸上限在传输层就挡住。
* `src/net/netplay.ts` —— 把锁步内核接到传输上:**按对等方记账的握手门控**、输入发送游标、哈希收发与超时。
握手不是"听到对方"就算完成,而是**对方确认收到了我的 hello**(`ackTo` 指名道姓,因为四人局里
"我听到了"必须说清是听到了谁);**所有对等方都到齐才开跑**。
* `src/scene/net-scene.ts` + `net.html` —— 2–4 人合作场景:每人一个角色、怪物**追最近的那个活人**、
经验记给**补刀的那个**、`?ws=` 联机 / 不带则单机跑同一条模拟路径。
四个**真 bug**,全靠验证抓出来,都不是"写错一个字段"级别的:
1. **输入延迟导致的死锁**:延迟 D tick 时,第一个该发的 tick 是 D,`0..D-1` 谁都不发,游戏在第一 tick 前就卡住。
改为发送游标从 `0` 补齐到「当前应付 tick」。
2. **握手是单向的**:只听见对方 hello 的peer 认为自己已完成握手,于是**永远不发自己的 hello**;
对方因此一直等,双方都 ready 不了。现在收到 hello 必回一个 hello,且**只认「对方确认收到我的 hello」才算完成**,
否则每 25 次 pump 重发一次。
3. **首发帧掉进虚空**:先连上中继的一页把 `0..4` 帧发出去时对面还没连上,中继无人可转发(发送方无从得知),
而发送游标已经前进——**这些 tick 的输入永久消失,两边永久互等**。现在有握手门控:**没确认对方在听,就不发输入**。
4. **超时判定用「世界 tick」计时**:世界一旦卡住,tick 不再前进,超时因此**永远不会触发**——恰好在它唯一有用的场景里失效。
现在按「距上次收到消息经过了多少次 pump」计。
另外两处是**设计加强**,同样由数字逼出来:哈希比对面向前进中的世界时,
落后方收到的是自己还没算到的 tick,于是**大量哈希只能被丢弃**(落后方几乎什么都没验证到);
现在把对方哈希缓存起来、等本地历史追上再比对,并单独统计 `hashesIgnored`——**把无法核对的哈希谎报成"一致",
比不检查更糟**。
`scripts/verify-net.ts` 无头断言 **143 项**:协议编解码(含固定字节布局与全部畸形包)、
内存管道与**内存 hub** 的排队/上限/关闭语义、双 peer 200 tick 世界完全一致、
**慢链路只停世界不坏世界**(落后方靠已有积压追上同 tick 后世界逐字段相同)、
**四人局**:三个 peer 不会因为第四个还没来就抢先开跑(少一人就等,否则先发出的输入会掉进虚空)、
迟到者入场后四人**逐 tick 齐平**且四个世界逐字段相同、四人中一人掉线时其余三个只停不裂、
恢复后各自用积压追平;篡改飞行中的输入→**在正确 tick 报 desync**、垃圾包被丢弃且世界不受影响、
静默 peer → **超时而非 desync**、老旧哈希计为"无法核对",以及**真 socket**(自写 RFC6455 中继 +
两个真客户端 150 tick、**三个真客户端 160 tick**)无分叉。
浏览器实测(**三个独立 Chromium 实例** + 真中继):三页 `peers 3`、各听到 2/2 名队友、
`handshaked/acknowledged`,三页都跑到 **worldTick 306 且完全齐平**、各比对 110–115 个哈希**全部一致**、
`malformed 0`、无 desync、`glError 0`,三页 `players` 数组**逐字段相同**(位置/血量/经验),
击杀数一致(7),三张截图互不相同(各页相机跟各自角色)。
### M4 · 技能 / 任务 / NPC ✅(结构层已跑通;真实 MPQ 数据待接入)
- **技能**(`Skills.txt` 形状):法力消耗、冷却、射程、伤害随等级成长;两种施放形态——
**投射物**(按 25 Hz 飞行、撞墙即毁、超时消失、命中后伤害走战斗模块的同一套击杀/经验/尸体管线)
与**瞬发范围**(半径内全部命中)。伤害成长是唯一显式简化的地方:真实表用 5 个等级区间
(`MinLevDam1..5`)插值,我读单一斜率 `PerLevel`,公式隔离在 `skillDamageAt` 里等映射层替换。
- **名字来自真 TBL**:表里的数字单元格通过 `data/local/string.tbl` 解析(我上轮标记为"弱验证"的解码器
现在真正进入了游戏路径);没有 TBL 时数字保持字面量,两种路径都保留。
- **任务/NPC**:`Quest.txt` 形状的目标(按**怪物 id** 或 `*` 通配计数)、奖励(经验+金币)、
状态机 `inactive → active → complete` 且**不重复发奖**;NPC 台词按状态选择
(未接/进行中/已完成各一套)——"NPC 在你已接任务时还在推销"是经典 bug,这里用状态选台词避免。
- **交互**:数字键选技能位(1..4)、攻击键施放、T 键与最近 NPC 对话(自动接取任务)、
拾取 E/F;对话显示在页面面板上。
- **两个真实缺陷在这轮被浏览器测试抓出**:① 选中法术且魔法耗尽后空格既不施法也不近战,
玩家彻底失去攻击能力 → 改为"法术放不出时回退近战"并加魔法缓慢回复;
② 台词先按 `|` 拆、再解析 TBL,而 TBL 字符串本身含 `|`,导致多行台词没被拆开 → 改成先解析再拆行。
另外还修了施法决策与近战推进的顺序(原先二者会同一 tick 同时触发)。
`scripts/verify-m4.ts` 无头断言 **56 项**:TBL 名字解析三种情形(命中/字面量/无表)、
技能表解析与投射物判定、伤害随等级单调、施放的冷却与法力门(含"刚好够")、
投射物飞行/命中/撞墙/超时/忽略尸体、任务按 id 与通配计数、阈值达成发奖且不重复、
NPC 台词随状态切换、施法可复现。
浏览器内实测(夹具归档):`textSource=tbl`、技能名 `['Basic Attack','Fire Bolt','Frost Nova']`
(来自归档内的真 TBL)、施放 7 次、走到 NPC 前对话得到两行台词并**接取任务**(`den:active`)、
追问台词切换为进行中版本、随后击杀使任务计数 **推进到 1/3**、`glError = 0`、25.6 tps。
(3/3 完成与奖励发放由无头断言覆盖;浏览器脚本受合成地图通行性限制未打满。)
### M3 · 物品系统 ✅(结构层已跑通;真实 MPQ 数据待接入)
物品的建模方式跟着 D2 的真实存储走:物品 = **基物**(`weapons.txt`/`armor.txt`/`misc.txt` 行)
+ 可选**前缀/后缀**(`magicprefix.txt`/`magicsuffix.txt` 行),词缀的修饰项指向
`ItemStatCost.txt` 里的属性名。没有硬编码——剑就是一行带尺寸和伤害的表行,
"Cruel" 就是一行带等级要求、可用物品类型与数值范围的表行。
两个关键决定:
- **随机数是注入的**(`src/game/rng.ts`,mulberry32,零依赖)。掉落是种子的纯函数,
因此可以被测试精确复现——这同时也是 M5 联机 lockstep 能成立的前提。
- **背包是格子占用模型,不是列表**。D2 物品有宽高、不能重叠、形状固定,
不建模这个,"背包满了"就没有任何意义。
已实现的约束:词缀**等级门**与**物品类型门**(`itype1..7`)、前缀/后缀/基物的名字组合、
属性合并(基础 + 各修饰项)、堆叠到上限后**溢出到新格子而不是丢弃**、金币堆叠、
拾取时背包满**拒绝而不是销毁物品**(并计数)、按类型着色的地面物品与背包网格覆盖层。
`scripts/verify-items.ts` 无头断言 **58 项**,涵盖表解析(含真实文件常见的**前导空列**)、
词缀资格、掷取可复现、名字与属性合成、背包边界/重叠/堆叠/移除/金币、掉落表的等级门与可复现。
浏览器内实测(夹具归档):`items=archive · tables=archive`、3 次击杀产生 3 次掉落
(1 次金币 39 自动入账 + 2 件地面物品)、拾取 2 件 → 背包 3 件 / **6/40 格**、护甲防御合计 12、
**24.6→25.6 tps**、`glError = 0`;掉落名为 **`Sturdy Buckler of the Fox`**(前缀仅限护甲 + 后缀不限)
与 `Minor Healing Potion of the Fox`——正是表驱动的词缀资格在起作用。
### M2 · 战斗沙盒 ✅(结构层已跑通;真实 MPQ 数据待接入)
战斗模拟刻意**不依赖渲染**:一次只推进一个 25 Hz tick,输入是一条小船小记录、地形是一个碰撞谓词。
这样整套系统能在 Node 里无头模拟与断言——只能靠看屏幕验证的战斗,通常是坏的。
- **数据表驱动**:怪物血量/伤害/冷却/攻击距离/仇恨半径/速度/经验**全部来自表行**
(`data/global/excel/monstats.txt` 与 `experience.txt`,D2 用的制表符文本格式,`.bin` 只是它的编译形式),
换真表不用改代码。表缺失时回退到内置演示表并在 HUD 标注 `tables builtin`。
- **AI 状态机**:`idle → chase → attack`,按仇恨半径发现、按攻击距离切换、按各自冷却出手;
尸体保留 100 tick 再消失。
- **玩家**:近战按冷却与魔法消耗,命中最近目标;经验按表累加升级(升级提升并回满资源);
死亡后按计时重生。
- **移动权限收归模拟**:地形谓词返回**重叠计数**而不是布尔——已经卡在实心格里的身体必须能走出来,
规则是"不让重叠变深";布尔表达不了这件事,而"永久卡死"比"短暂蹭到墙角"糟糕得多。
- **渲染**:角色与怪物**一起按深度插进墙体序列**(怪物复用同一套图集,按类型着色),
带血条;左下/右下是血蓝球(屏幕锚定、世界坐标绘制)。
`scripts/verify-combat.ts` 无头断言 **38 项**:表解析(空单元格/`(null)`/默认值/大小写)、
经验表单调化、生成不落进实心格且可复现、仇恨/追击/攻击距离、冷却决定出手次数(24 tick 冷却在 100 tick 内正好 5 次)、
魔法消耗与不足时不落伤害、击杀/经验/升级/资源回满、死亡与重生、以及**同样输入产生同样结果**。
浏览器内实测(夹具归档):`tables=archive`、生成 8 只、**击杀 3 只**、经验 28 → **升到 2 级**(上限血 60→70)、
魔法 30→11、**25.2 tps**、`glError = 0`;HUD 显示 `hp 70/70 · mana 11/35 · lvl 2 (28 xp) · monsters 5/8 alive · kills 3`。
### M1 · 地图渲染与碰撞 ✅(结构层已跑通;真实 MPQ 数据待接入)
`map.html`:把解码出的 **DS1 布局 + DT1 瓦片库**渲染成地图——**逐格分层绘制**
(地面按格序、墙体按画家序:先远后近),碰撞**直接来自 DT1 的 5×5 子瓦片标志**
(`blockWalk` / `blockPlayerWalk`),网格步长 = 瓦片边长 / 5 = 标准 160px 瓦片的 32px。
浏览器内实测(无头 Chromium + SwiftShader,合成夹具):
| 检查 | 结果 |
| --- | --- |
| 绘制 | 9 次地面 + 9 次墙体,4 张瓦片图集,**未解析引用 0** |
| 碰撞网格 | 15×15 子瓦片,**108/225 为阻挡**(由瓦片标志推导) |
| 定步长 | **25.2 tps** |
| 移动 | 空位可走;撞到实心子瓦片被拒;**无穿墙**、无越出地图边界 |
| 渲染 | `glError = 0`;截图 3207 种颜色、70.9% 非背景像素、5205 个角色像素 |
**角色动画已接入**(`src/game/animation.ts`):格式无关的 clip/方向/帧推进系统,
按 **25 Hz tick 计数**播放(而不是毫秒——否则在不同机器上速度不同),
`walk` / `stand` 自动切换、方向由移动向量决定(0=南、6=东、4=北,与文件内组序一致)。
**绘制顺序也做对了**:角色不再永远画在最上层,而是**按深度插进墙体序列**
(规则 = 第一个"比角色更近"的墙之前),所以站到墙后会被正确遮挡。这条规则抽成了纯函数
`depthInsertIndex` 并单独验证(16 个位置、0 问题)——小地图上肉眼很难看出插桩点变化。
角色素材解析顺序:**DC6 优先**(D2 大量单位本身就是逐方向 DC6 图集,且每帧自带尺寸)→
**D1 CL2 回退**(用游戏表宽度 96)→ 都没有才用黄色方块占位。
为了在没有真数据时也能验证,夹具里生成了一个**合成角色 DC6**(8 方向 × 8 帧 × 64×64,
每个方向图案不同,便于看出方向/帧错位),并打进夹具归档。
浏览器内实测:`actor=actor.dc6`、东=6 / 北=4 / 南=0、走→站切换、帧推进、角色位于 9 个墙体绘制的第 6 位、**25.0 tps**、`glError = 0`。
### 引擎骨架(D1 演示,仍可用)
`walk.html`:真实角色精灵(战士行走/站立,宽度 96 = 游戏表数值)+ 8 方向朝向 +
碰撞 + 相机跟随 + **25 Hz 定步长循环**;地面与墙是占位几何,等 DS1/DT1 解码器落地后替换。
浏览器内实测(无头 Chromium + SwiftShader):`25.6 tps`、朝北 `facing=4`、朝东 `facing=6`、
撞墙后两次推挤位置不变、`glError = 0`。
## 验证方式(可复现)
```bash
node scripts/inspect-mpq.ts samples/spawn.mpq header # 头 + 存储标志分布
node scripts/inspect-mpq.ts samples/spawn.mpq hist # 压缩掩码分布 → 该归档需要哪些解码器
node scripts/verify-archive.ts samples/spawn.mpq # 全量解包 + 魔数校验
node scripts/verify-sprites.ts samples/spawn.mpq # 全量精灵解码 + 宽度推断
node scripts/verify-widths.ts <archive> <member> <widths-file> # 逐帧宽度精灵
node scripts/make-fixtures.ts /tmp/d2fix # 生成 DC6 夹具 + 期望网格
node scripts/make-map-fixtures.ts /tmp/d2fix # 生成 DT1/DS1 夹具
node scripts/verify-dc6.ts /tmp/d2fix # DC6 自校验
node scripts/verify-dt1-pixels.ts /tmp/d2fix # DT1 像素落位校验
node scripts/make-mpq-fixture.ts samples/fixtures # 把夹具打包成真 MPQ
node scripts/verify-mpq-roundtrip.ts samples/fixtures # 归档往返验证(含 mpyq 交叉)
node scripts/make-pl2-fixture.ts /tmp/d2fix # 合成 PL2(1727 张表,模式可辨位置)
node scripts/verify-tbl.ts # TBL 构造检查(CJK/星形平面/长串)
node scripts/verify-depth-order.ts # 角色深度插入规则(纯函数,无需渲染器)
node scripts/verify-combat.ts # 战斗行为无头断言(38 项)
node scripts/verify-items.ts # 物品/词缀/背包/掉落无头断言(58 项)
node scripts/verify-m4.ts # 技能/投射物/任务/NPC 无头断言(56 项)
node scripts/verify-m5.ts # 存档快照/角色档/确定性锁步无头断言(33 项)
node scripts/verify-net.ts # 协议/传输/2–4 人锁步/真 socket 无头断言(143 项)
bash scripts/verify-format-parity.sh /tmp/d2fix # 与独立实现的完整差分
```
多人联机需要中继(自写、零依赖,只转发字节,不懂游戏):
```bash
node scripts/net-server.ts 8787 # 或 npm run net-server
# 每个浏览器开一页,2–4 人都可以(也可以在多台机器上,把 127.0.0.1 换成主机 IP):
# http://127.0.0.1:5173/net.html?data=samples/fixtures&ws=ws://127.0.0.1:8787&peers=3&peer=0
# ...&peers=3&peer=1 ...&peers=3&peer=2
```
地图场景需要夹具被 HTTP 提供(`samples/` 已在 .gitignore 内):
```bash
node scripts/make-map-fixtures.ts samples/fixtures
# 打开 http://127.0.0.1:5173/map.html?data=samples/fixtures
# 或走归档路径:http://127.0.0.1:5173/map.html?mpq=samples/fixtures/fixture.mpq
```
在暗黑1 试玩版 `spawn.mpq`(25.8 MB / 1029 项)上的实测结果:
| 检查 | 结果 |
| --- | --- |
| 成员解包 | **1024/1029 精确解出,0 个长度不符**(5 项是归档 listfile 与实际条目不一致) |
| PCX / CEL / TRN 魔数 | 全部通过 |
| 压缩掩码 | 1010 zlib、14 原样存储 → **只需 zlib** |
| `.cl2` 精灵 | **305/305 用游戏表宽度解出**(玩家 96、怪物 128) |
| `.cel` 精灵 | **217/221 单宽度自动推断成功**(含 12 张 640px 过场图) |
| 逐帧宽度精灵 | `data\inv\objcurs.cel` 179 帧按宽度表全部解出,无空帧 |
### 宽度的真相(踩坑记录)
CEL / CL2 **都不存储帧宽度**:
- `.cel` 的游程是**行封闭**的,所以"每个游程正好填满一行"这个判据是真的——
错误宽度会让游程溢出整行而被拒绝,因此单宽度自动推断是**可靠**的。
- `.cl2` 的游程**可以跨行**,任何宽度都能把游程流消费到帧尾,"精确消费"这个判据**恒真**。
宽度只能来自游戏数据(`SetPlrAnims` 给玩家站立/行走 96、近战攻击 128;
`monsterdata[].width` 给怪物 128)。可用的弱判据是"游程流正好落在列边界"(`cl2WidthCandidates`)。
- 地砖表与光标表是**逐帧不同宽度**的(`data\inv\objcurs-widths.txt`、地砖定义),
单宽度推断必然失败——这不是解码器 bug,见 `decodeSpriteFile` 的 `widths` 选项。
## 浏览器端
```bash
npm install
npm run dev
# http://127.0.0.1:5173/ 资源检查器(?sample=samples/spawn.mpq 免拖拽自检)
# http://127.0.0.1:5173/walk.html 可行走场景(同上)
# http://127.0.0.1:5173/map.html?data=samples/fixtures 单人地图场景
# http://127.0.0.1:5173/net.html?data=samples/fixtures&ws=ws://127.0.0.1:8787&peers=3&peer=0 多人联机
```
## 验收:整条目标链的证据(12 轮)
目标是从零做一个**浏览器内可玩**的暗黑破坏神 2 引擎,读**用户自备**的旧版 D2 MPQ,
依次打通 M0→M5,最终形成可玩的垂直切片。逐项对照如下——每条都给出**可复现的命令或页面**,
不是"应该能跑"。
| 里程碑 | 交付物 | 证据(可复现) | 强度 |
| --- | --- | --- | --- |
| M0 资源管线 | `src/mpq/`(v1 容器、crypt、解压掩码)、`src/formats/`(DC6/DS1/DT1/PAL/PL2/CEL/CL2/TBL)、`index.html` 可视化检查器 | `verify-archive.ts` 全量解包 **暗黑1 正版 `spawn.mpq`**(25.8 MB / 1029 项)+ 魔数校验;`verify-sprites.ts` 全量精灵解码;`verify-mpq-roundtrip.ts` 自写 MPQ 写入器往返 + `mpyq` 独立读取;`bash scripts/verify-format-parity.sh` 与 `dc6png`、OpenDiablo2 三个 Go 实现全字段差分 **ALL PARITY CHECKS PASSED** | 强(真 D1 归档 + 独立实现) |
| M1 可走地图 | `map.html` + `src/game/map.ts`(DS1+DT1 合成、5×5 子瓦片碰撞、深度插入)、8 方向动画、25 Hz 循环 | `verify-depth-order.ts` 深度规则;浏览器实跑 `map.html?mpq=samples/fixtures/fixture.mpq`(真 MPQ → 真地图);25 tps 实测 | 强(结构)/ 中(DT1 像素落位仅夹具自洽) |
| M2 战斗沙盒 | `src/game/combat.ts`(追逐/攻击/冷却/受击/死亡/重生/经验/升级)、血蓝球 HUD | `verify-combat.ts` **38 项**:命中判定、冷却、法力消耗、经验与升级、死亡与重生顺序、确定性重放 | 强(自洽行为) |
| M3 物品系统 | `src/game/items.ts`(基础物品、前后缀、词缀适用范围、按格背包/堆叠/金币、掉落流) | `verify-items.ts` **58 项**:词缀资格、掉落确定性、格子放置与堆叠、金币 | 强(自洽行为) |
| M4 技能/任务/NPC | `src/game/skills.ts`、`quests.ts`、`tables.ts`、`TBL` 文本源、对话 | `verify-m4.ts` **56 项**:投射物、技能伤害曲线、任务计数与奖励、对话通过 TBL 解析 | 强(自洽行为)/ TBL 布局弱(见下) |
| M5 存档与联机 | `src/game/save.ts`(快照 + `.d2s`)、`src/net/`(协议、传输、锁步、2–4 人)、`net.html` | `verify-m5.ts` **33 项**(读档续跑逐 tick 一致);`verify-net.ts` **143 项**(2/3/4 人、慢链路、篡改检测、真 socket);浏览器:**三个 Chromium 实例**同时联机,三页 worldTick 306 齐平、哈希全一致、世界逐字段相同 | 强(无外部实现可对照,故只证明自家实现自洽) |
**浏览器里现在能玩什么**(都只读用户自备 MPQ/夹具,仓库不含任何暴雪素材):
```bash
npm install && npm run dev # 夹具:node scripts/make-map-fixtures.ts samples/fixtures
# 单人战役沙盒(走路/砍怪/掉落/拾取/技能/任务/NPC/存读档):map.html?data=samples/fixtures
# 用真归档:map.html?mpq=<你的 .mpq>(暗黑1 试玩版 spawn.mpq 已验证)
# 2–4 人联机:先 npm run net-server,再每页开一个 net.html?ws=ws://127.0.0.1:8787&peers=3&peer=N
# 真实 D2 五个 act 的城镇(无怪物,走位/碰撞;数据来自 samples/d2,按 HTTP range 只读所需字节):
# http://127.0.0.1:5173/acts.html?act=1 # act 1..5;?quadrant=<DS1 名> 切换地图块
# 线上同一页面:https://www.laiseek.xyz/diablo2/?act=1(默认走预解包资源包;?live=1 强制直接读归档)
#
# 资源包(离线解包成 web 原生格式:索引 PNG 图集 + scene.json):
# npm run pack:data # 预置关卡 → samples/d2-packs(当前 62 个地图块 / 46.7 MB)
# npm run verify:packs # 与"现读现解"逐项比对(绘制序、碰撞栅格逐字节、每帧像素哈希)
# 线上产物:
# npm run build:game # → dist-game/(base=/diablo2/),再拷到 /var/www/d2web
# 线上实测(同一台服务器、真实 TLS):
# act1 资源包 4 请求 / 1.21 MB / 首帧 1.5 s ←→ 读归档 1344 请求 / 6.22 MB / 12.1 s
# act5 资源包 9 请求 / 3.80 MB / 首帧 2.1 s ←→ 读归档 3971 请求 / 11.24 MB / 36.5 s
```
### 仍然缺什么(诚实记录)
1. **真 D2 MPQ 已到手,且能读了**。用户自备的 1.13c 数据在 `samples/d2/`(11 个 MPQ,
来源与 sha256 见 `samples/d2/MANIFEST.md`),**PKWARE implode 解码器已实现**
(`src/mpq/implode.ts`):`npm run verify:implode` 实测 30,914 个成员、893 MB 全部解出;
`npm run verify:acts` 把五个 act 的城镇从 `levels/lvltypes/lvlprest` 一路解到等距场景,40/40。
**`/acts.html?act=1..5` 现在能在浏览器里走真实城镇**(无怪物),线上
<https://www.laiseek.xyz/diablo2/?act=1>;**DCC + COF 解码器已实现**
(`src/formats/dcc.ts` / `cof.ts`,按 OpenDiablo2 的 d2dcc/d2cof 独立移植,
`npm run verify:dcc` 逐层比对),角色不再是占位棋子而是**真·女法师**:
行走 `SOWLHTH` 16 方向 × 8 帧 × 8 层、站立 `SONUHTH` 合成后画在脚下,
`d2char.mpq` 只按 HTTP range 取需要的成员。
仍缺的:**可破坏物品的静态美术帧**(`Objects.txt` 的 token/mode 与成员已进资源包,
解码器也已就绪,只差把它们插进绘制序)、**ADPCM+Huffmann 音频**(572 个成员)、
以及下面这些"待真文件确认"的项——它们现在有真数据了:
DT1 block 数据偏移基准(真文件已解出,`dt1.ts` 已按真布局修正)、经典 `string.tbl` 索引布局、
`.d2s` 各数据段布局。引擎对这些一律**保持原字节而不猜**。
2. **D2 的表列映射层**:真实 `MonStats.txt`/`weapons.txt` 等是制表符大表,列名映射只按夹具列名
与少量官方文档验证过,接上真表后需要逐列核对(代码里已把列名集中,改起来是一处)。
3. **多人尚未覆盖的工程项**:入场大厅/断线重连的策略选择(现在是"少一人就等")、
输入回滚(现在只做等待)、以及把联机接进单人战役那套存档/任务状态
(目前联机是独立沙盒:世界与角色是共享的,背包/任务不进网络)。
## 路线
| 里程碑 | 内容 |
| --- | --- |
| **M0** ✅ | MPQ 容器 + 解压 + 精灵/调色板解码 + 浏览器检查器 |
| **M1** 🚧 | DS1+DT1 地图渲染、子瓦片碰撞、**8 方向角色动画与深度排序**均已在浏览器内验证(合成夹具);**待接入真实 MPQ 数据**;DCC/COF(复合动画)留待有真文件时做——Go 参考实现自己就未实现 bottom-up 帧,做不了完整对照 |
| **M2** ✅ | 战斗沙盒:表驱动怪物、AI 状态机、近战与冷却、血蓝球、经验与升级(真表待接入) |
| **M3** ✅ | 物品:表驱动基物与词缀、掉落(可复现随机)、背包网格、金币、拾取(真表待接入) |
| **M4** ✅ | 技能(投射物/瞬发)、TBL 文本、任务状态机与奖励、NPC 状态台词(真表待接入) |
| **M5** ✅ | 存档快照(读档续跑逐 tick 一致)、`.d2s` 核心字段与校验和、确定性锁步(输入延迟 + 逐 tick 哈希 + 检测到分歧即报 tick)、**WebSocket 传输层与 2–4 人浏览器实测**均已跑通;`.d2s` 数据段布局待真文件确认 |
## 第三方参考
格式语义的移植参考:
- [StormLib](https://github.com/ladislav-zezula/StormLib)(MIT,© Ladislav Zezula)— MPQ 容器、密码学、解压
- [DevilutionX](https://github.com/diasurgical/DevilutionX) / [Devilution](https://github.com/diasurgical/devilution) —
CEL/CL2 帧结构与游程语义、玩家/怪物宽度表、方向顺序
### 验证强度分级(诚实记录)
| 格式 | 独立对照 | 强度 |
| --- | --- | --- |
| MPQ 容器 | `mpyq`(Python)+ StormLib 语义 | 强(往返 + 独立读取) |
| DC6 | `dc6png`(JS)+ `OpenDiablo2/dc6`(Go) | 强(像素 + 全字段) |
| DS1 / DT1 元数据 / PL2 | `OpenDiablo2` 对应 Go 包 | 强(全字段 / 逐表摘要) |
| DT1 像素落位 | 夹具期望网格(编码器↔解码器) | 中(自洽,非独立) |
| CEL / CL2(D1) | 真实归档全量 + 游戏表宽度 | 强(真数据) |
| 联机锁步 | 无(自行设计,无外部实现可对照) | 强(两独立世界 + 真 TCP/WebSocket + 真浏览器两实例;**但只验证过自家实现**) |
| TBL | 无(Go 包实现的是带哈希表的扩展变体) | **弱(仅构造检查,待真文件确认)** |
详见 `THIRD_PARTY_NOTICES.md`。本项目为独立 TypeScript 实现,不复制 C/C++ 源码。