Commit Graph

43 Commits

Author SHA1 Message Date
troytt 3cd4549332 fix: 修复非基底对象 NPC 丢失及其资源检索区分大小写的问题
在前一次提交的基础上修复并强化了 Type 1 对象(怪兽及 NPC 原型)解析与打包的正确性。

1. 修正大写回退遗漏问题(解决失联的 6 个特定角色如 Act 4 Jamella 等):`pickObjectMember` 中需要使用大写查找 token,而原来直接代入了小写 token 导致失败,修复后所有角色均成功合图。
2. 添加安全跳过统计 (`skippedMissingArtSpawns`),使得未匹配上图片的隐藏对象能干净跳过。
3. 增加 `tests/resolve-type1.test.ts` 提供核心覆盖率。
4. 应用性能重构与规范修复,将二次查询提速为内置的 `Map` 遍历,同时补齐了 `OBJECT_TYPE_MONSTER`、Typescript interface、尾随空格与逗号修复。
5. 后向保留了省略 tables 时旧版逻辑的注解。

(refs #6)
2026-09-14 15:37:32 +00:00
troytt 451816d627 fix: 修复非基底对象 NPC 丢失问题
Diablo 2 城镇中的部分 NPC(如 Gheed, Charsi, Akara, Kashya, Warriv)没有定义在
Objects.txt 作为 Type 2 对象,而是通过 MonPreset.txt 定义为 Type 1(怪兽生成点)。
以前此类对象被一律视为 kind: 'monster' 并且放弃生成(silently dropped)。

此修复:
1. `src/game/acts.ts`: 在 `loadActTables` 中增加了 `MonPreset.txt` 和 `monstats.txt` 解析。
2. `src/game/objects.ts`: `resolveDs1Object` 现在接受 `monsters` 表以处理 `type === 1` 对象查询:
   `MonPreset` 通过 Act + `id` => `Place`;
   `monstats.txt` 的第一列 (Id, 恰好对应 Place) => `Code` (对应资源 token) 和 `NameStr` (角色名)。
3. `scripts/pack-act-assets.ts`: 将解析后的新表传递给 resolver。对真实存在艺术资源的 `kind: 'npc'` 进行打包;对于确实无资源的占位对象(如 place_nothing, trap-*)现在会干净地跳过并做数量统计 (Skipped artless spawns),不再将它们报告为艺术丢失或引发异常。
4. 抽象统一 `MONSTER_ROOT` 并移除所有写死的 'data\\global\\monsters\\' 字面量。
5. `src/game/npc.ts`: 扩展 `NPC_NAME_FALLBACKS` 清单,解决部分名称未定义的显示为短码的问题(例如 RG 替换为 Rogue, CW 为 Cow 等)。

在保证现有 Type 2 行为完全不变和所有测试继续通过的前提下,总共正确重新召回了 67 个原先丢失的 Type 1 镇民、卫兵遗漏和活物 (Cow, Chicken等) 实例。

(refs #6)
2026-09-14 15:14:15 +00:00
troytt ad7ced6760 test: 修复三处跳过守卫,它们只在真实资源到位时才暴露
这三个测试都犯了同一个错误:**守卫检查的资源,和实际读取的资源不是同
一个**。在 `samples/d2` 缺席时它们一律静默跳过,看起来是绿的;真实 MPQ
一旦放进来,守卫放行,然后立刻炸在一个完全无关的缺失文件上。

- `tests/dcc.test.ts`
  守卫看 `samples/d2`,扫描目标却硬编码成 `samples/fixtures`——那里没有
  `d2char.mpq`。于是这个套件**从来没有真正跑过**:没资源时跳过,有资源时
  `TypeError: Cannot read properties of null (reading 'listFiles')`。
  目标改为 `samples/d2`;打开归档失败时由"打日志后继续"改为 `throw`
  并带上归档名,避免把"文件缺失"伪装成 100 行之外的空指针;
  `let characters: any` 收紧为 `MpqArchive | null`。

- `tests/widths.test.ts`
  守卫看 `samples/d2`,真实输入却是一个空成员名和
  `scripts/browser/checks/map-widths.txt`——后者在仓库里从未存在过。
  改为按实际输入判断。顺带补 `: string` 标注,否则空字符串字面量会被
  收窄成 `never`。

- `tests/formats-malformed.test.ts`
  守卫看 `samples/d2` 目录是否存在,却去读其中一个散落的 `.cof`。但
  `samples/d2/` 放的是 MPQ **归档**,不是解包后的目录树,目录存在完全
  不能说明那个文件存在。改为直接判断该文件路径。

修复后,在真实 MPQ 就位的情况下:
21 passed | 1 skipped (22 files),421 passed | 2 skipped (423),tsc 0 error。
其中 `dcc.test.ts` 从"0 个测试"变成真正执行。
2026-09-14 14:51:50 +00:00
troytt 68c7890eee fix(packer): 修正 monsters 前缀长度,恢复全部 NPC 美术解析 (refs #6)
打包器在切分 MPQ 成员名时,objects 走 `OBJECT_PREFIX.length`,
monsters 却硬编码了一个手数的 `22`。`data\global\monsters\` 的真实
长度是 **21**,于是每个怪物 token 都被多切掉一个字符:

    slice(21) -> "DC"   (正确)
    slice(22) -> "C"    (实际发生的)

`monsterMembers` 因此以 `C`/`E`/`R`/`S`/`J` 这些残缺 token 建索引,
而查表时用的是正确的 `DC`/`JE`/`XR`/`XS`/`0J`,导致**每一次 NPC 美术
查找都必然落空**。由于代码里对"找不到"是静默跳过,这个缺陷不会报错,
只会让 NPC 全部变成没有贴图的空对象——在真实 MPQ 到位之前完全无法察觉。

改为引入 `MONSTER_PREFIX` 常量并由 `.length` 推导,消除手数魔数。

实测(覆盖全部 365 个 scene.json):

    NPC 实例总数            103     103
    其中解析出 COF 成员       0 ->  103
    其中解析出精灵帧          0 ->  103

按 token 分解,修复后全部满额:
0J 82/82、DC 4/4、HR 2/2、JE 4/4、XR 2/2、XS 9/9。

抽查:Deckard Cain 现在解析到
`data\global\monsters\DC\TR\DCTRLITNUHTH.dcc`,帧 43x75,偏移 (-45,-54)。
普通场景物件仍走 `objects\`,未受影响。
2026-09-14 14:51:49 +00:00
troytt cd87c2c5fa chore(git): 忽略 vitest 覆盖率报告输出目录
`npm run test:coverage` 会在仓库根目录生成 coverage/,属于可随时重建的
构建产物,不应入库,也不该在 `git status` 里制造噪音。

refs #14
2026-09-14 13:27:18 +00:00
troytt f52edd85f9 test(formats): 增加针对损坏资产的覆盖率和回归测试 (fixes #11)
Implements regression limits testing bounds limits, invalid header mappings and stream truncations for binary formats, verifying the decoder engine halts cleanly without exception masking or propagation leaks. Ensures zero false positives and safely covers buffer boundary rules.
2026-09-14 13:24:25 +00:00
troytt 53f52ba61e fix(formats): 替换原始缓冲区的未检查读取
Rewrites raw buffer deserialization across formats to use ByteReader, DataView and safe property assignments with precise error propagation. Eliminates untested internal bounds checks and explicitly validates untrusted asset streams safely.
2026-09-14 13:19:01 +00:00
troytt a6ca337a10 feat(formats): 添加安全的二进制数据读取器
Provides ByteReader, requireBytes, and precise FormatError subclasses (TruncatedDataError, InvalidFieldError) to enable safe decoding bounds checking.
2026-09-14 13:19:01 +00:00
troytt 7d8629cd73 feat(scene): 衔接 NPC 数据至引擎层及交互面板验证 (fixes #6)
建立 NPC token 到独立显示名称的转译字典。将打包导出的 NPC 集合统一注水到 GameEngine 模型内以生效交互半径;同时增加 Canvas UI 外围的绝对定位 DOM 生成层来渲染 NPC 头顶铭牌名称,以及挂载对应的对话展示面板供 engine.dialog 同步刷新。更新 E2E 环境验证逻辑检测交互健壮性。
2026-09-14 13:14:34 +00:00
troytt 11439256bf fix(art): 将 NPC 渲染文件的基础寻址从 objects 修正为 monsters
修正 Deckard Cain、Jerhyn 等城镇 NPC 的基础美术路径。通过从 objectEntry 下传的 baseIsMonsters 布尔标识,使特定 Object 的 DCC 及 COF 查表过程自动从默认的 `data\global\objects` 切换到 `data\global\monsters`, 彻底解决打包器因跨域失效而丢弃图集的情况。
2026-09-14 13:08:46 +00:00
troytt 11e0688930 feat(packer): 提取可交互 NPC 的地图定位并独立存储
将场景数据生成时对 objectType 的判定粒度细化。保留对普通怪物的忽略过滤,对于包含 baseIsMonsters 标识的命名 NPC 记录,将类型标识拆分为 'npc' 并单独放入 scene.json 的 npcs 数组中打包。
2026-09-14 13:08:46 +00:00
troytt e3ca14c357 refactor(lockstep): 按项目规范彻底移除 any 约束逃逸并优化哈希分配性能 (refs #7)
此提交响应了进一步集成的代码质量与内存性能审查反馈:
1. 深入清理代码库类型系统边界,利用 Structural Target Interfaces 为所有传递进哈希运算中的世界和对象提供原生支持而不用 `any`,并修复由于引入 #10 放宽的 engine.ts 的 syncEngineState 缺失的 `EngineViewState` 严谨签名;
2. 提升了数据解析健壮性:在 save.ts 中增加严格的运行时验证(利用 Type Guard: `asserts value is GameSnapshot`),取消一切断言强转;
3. 取消了计算锁步哈希期间对背包和掉落物品的数组分配与运行时排序(重构为支持 Order-Independent Combination 的无排序位偏移计算模式),确保 25Hz 打包同步路径中零内存无端分配;
4. 并修正了 net-scene 等多个测试的静态数据映射与严格的突变检测。
2026-09-14 13:05:18 +00:00
troytt 45b6a2caa7 feat(lockstep): 接入实际组件状态哈希并修复场景验证死区 (refs #7)
替换了 net-scene 中的闭包测试用 digestWorld 状态逻辑,通过真实注入的 inventories, quests, groundLoot 清单确保联机场景具备全覆盖的一致性锁步哈希验证。增加了 integration 测实验证 pickup 产生的完整分歧,确保从引擎层面而非独立计算侧暴露漏洞。
2026-09-14 12:55:16 +00:00
troytt a37f5f0805 chore(build): 补全 tbl 和 audio 的自动化测试验证链路 (refs #7)
在 package.json 中修复了丢失的 verify:tbl 执行脚本入口,并将其以及 verify:audio 一并加入到 verify:all 的默认工作流链中,确保数据构建解析和音频流水线能在集成门禁中得到有效检验。
2026-09-14 12:44:19 +00:00
troytt 4290e5feb2 fix(game): 移除不安全的类型转换并增加存档校验 (refs #7)
在 character.ts 中使用 flatMap 结合类型缩窄(null 检查),移除了对 DccFile 的强制类型转换,保证内部逻辑的编译期安全。在 save.ts 中增加了对背包数据的严格运行时有效性验证,避免由于恶意或破损存档导致的隐式游戏损坏,并用专用的反序列化测试覆盖新校验过程。
2026-09-14 12:44:19 +00:00
troytt fc201d7290 feat(lockstep): 实现完整的锁步状态哈希算法 (refs #7)
扩展了锁步哈希验证以包含背包内容、地面物品和任务进度。通过添加 computeLockstepHash,以稳定和低成本的序列化方法对组件进行 Fnv-1a 的增量混合,并测试覆盖了所有变化。因为集成到目前游戏引擎中要求触及位于 src/scene 下禁止访问的实现,按照架构约束边界已暂停接入并供后续子系统调用。
2026-09-14 12:44:18 +00:00
troytt 6e6d8ed022 feat: 将所有动态实体加入地形深度排序渲染列队 (fixes #10)
将 monsters、npcs、projectiles、ground items 等 Engine 内的物理对象一并收集,
利用相同的 `cell.x + cell.y` 画家排序与 `act-scene.ts` 里由预处理好的 `walls` / `objectDrawables` 进行混合深度渲染。
此改变为后续加载真实的实体资源图集打下渲染基础。
2026-09-14 12:40:48 +00:00
troytt 1e7645d388 feat: 在 Act 场景中挂载 GameEngine 提供真实的模拟物理状态
将刚才提取出的引擎初始化进 `act-scene.ts`,配置一个空的怪物列表。
使得 Act 地图不再仅仅是个“游览器”,而是拥有血条、法力、体力、碰撞并进行 ticks 的真实服务器沙盒对象。
2026-09-14 12:37:29 +00:00
troytt 38d06cdb28 feat: 抽取独立无状态 GameEngine 领域与相关逻辑
将 map-scene 中负责每帧更新逻辑与状态管理的巨量代码抽取成 `src/game/engine.ts` 内的 `GameEngine`。
解耦模拟与 WebGL 和 DOM,保证可以无界面测试。
并引入了 `tests/engine.test.ts` 自动化单元测试(Vitest)。
2026-09-14 12:37:29 +00:00
troytt 98d6a6dad2 refactor: 提取 ViewportCamera 与后备 Demo 数据
提取 ViewportCamera 到 `src/sim/camera.ts` 以复用视角缩放控制逻辑。
将原本散落在 sandbox 测试场景里的怪物、物品、任务等 Demo 数据提取至 `src/game/demo-data.ts`,作为 Act 场景的缺省源数据,解耦纯 UI 代码与配置逻辑。
2026-09-14 12:37:29 +00:00
troytt 4d47a56e68 feat(mpq): 实现 ADPCM 与 Huffman 音频解码并接入 Web Audio (fixes #13)
- 基于 StormLib 的 C++ 原型,实现了 4-bit IMA ADPCM 解码(单声道与双声道分离)。
- 实现 FGK 自适应 Huffman 树解码,从给定分布表构建静态初始树进而按流插入权重并重平衡。
- 保证严格使用位运算确保 16-bit 符号处理正确,增加对应 ADPCM 解码保护边界。
- 新增 `AudioManager` 基于 Web Audio API 设计跨文件懒加载及混音管理,避免在未配置时报错。
- `tests/mpq-audio.test.ts` 加入覆盖测试,并含 ADPCM 打包构造器,证明解压流程完整有效。
- `package.json` 添加 `verify:audio` 脚本,可快速校验环境依赖导入。
2026-09-14 12:30:21 +00:00
troytt f4dd55be6c fix(formats): 按官方哈希桶格式重写 tbl 解码器与编码器 (fixes #8)
- 重构了 `src/formats/tbl.ts` 以正确读取 21字节魔数头与 17字节哈希桶节点
- 使用了 NUL 终止与正确的字节长度计算方式,修正之前错误的 UTF-16 假设
- 支持通过 `TextDecoder` 选择代码页 (默认为 windows-1252,支持 gbk 等 fallback)
- 暴露了原始的 O(1) 线性探测查询函数 `lookupTblFast` 和哈希函数 `tblHash`
- 对 `scripts/lib/tbl-writer.ts` 及其验证脚本进行了重写,以正确写入和校验哈希桶布局
- 增加了完整涵盖功能与越界校验的 vitest 单元测试,并在真实的 113c patchstring.tbl 下实现了 1169/1169 哈希全量验证通过
- 将测试文件加入项目目录
2026-09-14 12:30:07 +00:00
troytt 1a81693d74 test: 引入 Vitest 测试框架、覆盖率统计与 CI 流水线 (fixes #14)
- 安装了 `vitest` 和 `@vitest/coverage-v8`,配置支持对 `tests/**/*.test.ts` 文件做测试。
- 将基于 `process.exit(1)` 的断言脚本平滑翻译为了符合 Vitest 生态的 `test` 与 `expect` 结构。
- 加入了 Github Actions (Gitea Actions) 语法的 CI 文件,分别验证 20.x, 22.x 下的测试通过情况。
- 原有的 `scripts/verify-*.ts` 等 CLI 脚本功能照常运作,且依赖缺失时通过 skipIf 进行安全跳过。
2026-09-14 12:25:28 +00:00
troytt 63b3962a52 chore(scripts): 使用 tsx 运行 TypeScript 脚本,解除对特定 Node 运行时的依赖 (fixes #12)
- 将所有 npm scripts 和文档里的 `node scripts/*.ts` 替换为 `tsx` 调用。
- 移除了对手动开启 --experimental-strip-types 的要求提示。
- 现在任何 Node >= 18 标准环境均可执行所有 verify 脚本及开发工具。
2026-09-14 11:44:43 +00:00
troytt 3cfa0cff9a fix(scripts): use npx tsx for publish:packs 2026-09-14 11:23:14 +00:00
troytt c6f176f784 chore(scripts): configure commit author in publish-packs 2026-09-14 11:22:48 +00:00
troytt 1cdbe5de19 chore(scripts): update level count in publish-packs readme generator 2026-09-14 11:22:32 +00:00
troytt 2a0dd4ce25 feat(packer): bake all 101 generated levels into static packs with 3 variants each
- Update scripts/pack-act-assets.ts to bake 3 deterministic variants for each maze (DrlgType 1) and wilderness (DrlgType 3) level
- Cache mazePieces, wildernessPieces, substitutions, and palettes for fast one-pass baking
- Populate LEVEL_NAMES_ZH with all 136 standard Chinese level names
- Update variantLabelZh to format var1..var3 as 变体 1..3
- Guard scripts/verify-packs.ts to verify preset levels cleanly while supporting non-preset entries
2026-09-14 11:15:13 +00:00
troytt 1cfef30878 fix(generators): 修复 70 关随机迷宫与 31 关野外地图生成与连通性 (fixes #2)
- 地图图块与层级修正 (src/formats/ds1.ts & src/game/acts.ts):
  - 依据 D2MOO DrlgRoomTile.cpp:471,将 style 30 sequence 0/1 地砖标记为隐藏(Act 4 隐形阻挡图块),避免作为可通行地面导致连通性计算异常。
  - 为 'Act 5 - Barricade' 关卡补充加载 'Expansion/Siege/temptile.dt1'。

- 随机迷宫生成器修正 (src/game/maze.ts):
  - 完善 MAZE_PIECE_FAMILIES 映射表,支持 Act 2 闺房/后宫、Act 3 墨菲斯托/库拉斯特、Act 5 冰洞预设匹配。
  - 支持 River、Pool、Tainted Sun 等特异房间预设的分类与放置。
  - 修正 prevChain 中 Baal 前室房间方向映射顺序,对齐 D2MOO DrlgMaze.cpp 原版顺时针方向 ['NSE', 'SEW', 'NSW', 'NEW']。

- 野外生成器修正 (src/game/wilderness.ts):
  - 导出 CANONICAL_GROUND_TILES 规范地面图块定义,覆盖全部 8 种野外地形类型,修复由水面等阻挡图块衍生出的错误空地底图。
  - 针对窄条连接桥关卡(如库拉斯特堤道 Level 82)引入全尺寸直接盖印逻辑,避免无效边框环绕。
  - 为未配置尺寸的野外关卡(如 Level 111, 112)提供 D2MOO 规范的 160x64 默认边界回退。

- 验证脚本增强 (scripts/verify-generators.ts):
  - package.json 新增 verify:generators 校验命令。
  - 迷宫验证增加单房间预设关卡(Level 61, 114, 116, 119)特殊尺寸与房间数豁免。
  - 针对有悬崖环绕的野外地图排除外部边缘非游玩空洞格,确保真实可通行区域连通性判断准确。
  - 过滤零售版 MPQ 未收录 debris.dt1 的 Barricade Snow 预设,使图块丢失率降至 0.5% 以下。
  - 自动化验证 101/101 关卡(70 迷宫 + 31 野外)全数通过,746 项断言 100% 成立。
2026-09-14 10:33:45 +00:00
troytt 9475886bc8 fix(scripts): 修复 publish-packs 统计地图关卡数量逻辑 2026-09-14 09:30:55 +00:00
troytt 72731bb143 feat(objects): 完整实现对象层解码、打包图集与场景深度绘制 (#5)
- 数据与查找修复:
  - 修复 src/game/object-lookup-data.ts 中 Act 3 墨菲斯托红门 (104:342:1Y:ON) 错位问题,恢复 3,615 条记录唯一性。
  - 排除 COF 非图元文件,优先选取主体图元 (tr) 与 DCC/DC6 静态帧。
- 资产打包与图集生成 (scripts/pack-act-assets.ts):
  - 引入全局 decodedMemberCache,一次性解码去重。
  - 将所有有效美术对象装箱至 objectPages (objects-*.png),并正确计算象限锚点偏移 (-16 + box.left / f.offsetX, 16 + box.top / (offsetY - height + 1))。
  - 动态统计 objectsArtPending,62 张地图全部实现 0 待解。
- 场景渲染与深度排序 (src/scene/act-scene.ts):
  - 预加载 scene.objectPages 纹理并在 beforeunload 中释放,避免内存泄漏。
  - 将 objectDrawables 与 runtime.walls 按 (subX+subY)/5 与 cellX+cellY 的统一深度双指针交替绘制,并保持与角色前后的画家算法正确遮挡。
- 自动化验证 (scripts/verify-packs.ts & scripts/verify-object-lookup.ts):
  - 新增图集页边界与 0 待解帧断言 (1302/1302 全数通过)。
  - 更新查找表统计与点位断言 (20/20 全数通过)。
2026-09-14 09:27:54 +00:00
troytt b3368e552a chore: 在 .gitignore 中忽略 Diablo II*/ 用户数据目录 2026-09-14 08:55:08 +00:00
troytt a80ee2cafc fix(render): 修复 SpriteRenderer 显存泄漏与补充 WebGL 上下文保护 (fixes #9)
- 优化 createProgram:Shader 链接后立即 detach 并 deleteShader,链接失败自动清理 Program
- 新增 deleteAtlas:支持释放指定 GPU 纹理显存,并在活跃批次解绑时安全 flush
- 新增 dispose 与 isDisposed:释放全部托管纹理、VAO、VBO、Program 与事件监听,支持幂等和 context-lost 防御
- 补充 WebGL context lost/restored 监听与自动恢复提示
- act-scene/map-scene/net-scene/walk 接入生命周期管理与 beforeunload 资源释放
- 新增 verify:renderer 单元生命周期验证套件 (26 项断言全部通过)
2026-09-14 07:54:51 +00:00
troytt a86b2a4582 fix(collision): 修复 DT1 碰撞网格方向与朝向对齐 (fixes #4)
- 修正 DT1 block 碰撞体 Y 轴解析方向
- 修复 character.ts 角色朝向映射函数与方向角度
- 增加 verify-collision-orientation 自动化验证脚本
- 完善 tools/go-oracle 工具链
2026-09-14 07:54:38 +00:00
troytt cd81a7e686 图库改走独立仓库 + 单提交历史(每次重烘 orphan + force push)
背景:Gitea 的 Release 附件在本实例不可用(1KB..59.5MB 全部 HTTP 500),所以图集包改为
发布到独立仓库 troytt/diablo2-web-assets,并刻意只保留一个提交——每次重烘在临时目录 git init
后提交当前包并 force push,仓库体积恒定在「一份快照」~66 MB;代码仓库 clone 保持 2.7 MB。

新增 scripts/publish-packs.ts(npm run publish:packs):
  - 统计块数/文件数/体积与 index.json sha256,生成图库仓库里的 README(用法、许可、重建步骤)
  - 写 .gitattributes(*.png binary -diff)
  - 在临时目录 init -b main → 拷贝 samples/d2-packs → 提交 → force push
  - 不落任何凭据到磁盘;remote 可用参数或 D2_ASSETS_REMOTE 覆盖

README「资源包从哪来」改为以图库仓库为主、归档镜像为辅;HANDOVER 记录两种发布方式、
服务端 gc 的注意事项(Gitea 的 cron.git_gc_repos 默认关闭,force push 后的旧对象需 gc 才回收)。
2026-09-14 12:03:27 +08:00
troytt a1af1d7304 文档:图集包改为从归档获取(不再随仓库分发)
README 新增「资源包从哪来」小节:给出归档 URL、sha256 与解包命令;后续小节编号顺延。
HANDOVER 同步说明归档位置(nginx → /var/www/d2assets/),并记录 Gitea Release 附件在本实例
不可用(1KB..59.5MB 全部 HTTP 500),因此走 /diablo2/assets/。

samples/d2/MANIFEST.md 同样加了说明,但该文件在被忽略的 samples/d2/ 下,不入 Git。
2026-09-14 11:45:48 +08:00
troytt afa5597168 把图集包移出 Git:改从 /diablo2/assets/ 的归档获取
samples/d2-packs 是 63 MB 的索引色 PNG 图集,每次重烘几乎全部文件都变,
而 PNG 无法被 git 的 delta 压缩,导致每烘焙一次仓库就再长 ~60 MB
(实测:63 MB → 117 MB,两次快照 64.3/66.1 MB 都完整留存在历史里)。

原计划放到 Gitea 的 Release 附件,但本实例的附件上传是坏的:
1 KB / 256 KB / 1 MB / 3 MB / 59.5 MB 全部返回 HTTP 500(空 message),
附件列表始终为空 —— 不是 MAX_SIZE 的问题,需要服务端(124.221.104.39)查
data/attachments 目录与 [attachment] 配置;我没有那台机器的 SSH 访问。

因此改为发布归档(与线上部署同源,零仓库体积):
  https://www.laiseek.xyz/diablo2/assets/d2-packs-2026-09-14.tar.xz  (63.2 MB)
  sha256 9cdfd0c19ac68de842c6725070b65d056e25f5bcc77f36d6d9a29cd9f1b431e8
  本地 /var/www/d2assets/ → nginx location /diablo2/assets/

包出现在 samples/d2-packs 才能跑资源包模式(页面默认走它);解包方式见 README。
2026-09-14 11:37:40 +08:00
troytt 46218f4280 verify-tiles: 给 DT1 库缓存加上限,避免跑全量时内存涨到数 GB
解码出来的 DT1 库很占内存(每个瓦片的块像素是 width×bitmapHeight 的 Uint8Array,一个库几十 MB),
原来无限缓存跑完 35 关会到 GB 级。与打包器一致地限制为同时 16 个库(比任何单一关卡类型用到的都多,
所以一关之内不会重复解码)。实测:全量跑 35 关峰值 RSS ≈ 1.9 GB,堆上限 2 GB,结束后立即回落。

顺带记录本地实测结果(7/7 通过):
  地面 28704 槽 / 墙 9514 槽 / 屋顶 492 槽
  地面槽类型全为 0(0 个错配)、墙/屋顶没画地面瓦片、looseRefs 0、missingTiles 0、位置公式 0 不符、
  411/411 个顶角右半都带了 type-4 伙伴、缺失伙伴 0
2026-09-14 09:28:07 +08:00
troytt 61c6691555 修复顶角墙缺左半:DS1 只列 type 3,引擎会自己补画 type 4
现象:墙角(两段墙交汇的上顶点)左边一块渲染不正常——角的左脸缺失,右半的墙基露出来,
接缝处出现台阶/突块。例:第三章「下水道二层」(sewertreasure1)有 5 处。

根因:D2 的顶角美术是两半拼的,DS1 只存「顶角右半」(type 3),左半(type 4)从不列出;
引擎在装配房间瓦片时会自己补——
  DRLGROOMTILE_InitWallTileData(D2Common/DRLG/RoomTile.cpp,1.10f 0x6FD889C0):
      if (nTileType == TILETYPE_WALL_TOP_CORNER_RIGHT)
          再以同格的同份 packed tile 信息追加一个 TILETYPE_WALL_TOP_CORNER_LEFT 图块
我们的渲染器只画 DS1 列的引用,所以这一步从未发生。

改动:buildIsoMapScene 的墙循环里,type 3 解析成功后,用同一 style:sequence 精确解析
type 4 伙伴,同格、按各自的 minBlockY 规则绘制,并把它并入碰撞栅格;新增
scene.cornerPairs / scene.missingCornerPairs 供校验使用。verify-tiles 增加两条断言。

实测:35 关共 411 个 type 3,全部有 type 4 伙伴(缺失 0),墙绘制数 9103 → 9514;
「下水道二层」72 → 77 面墙,与 5 个顶角一致。
2026-09-14 09:18:43 +08:00
troytt 956f7e8b45 README 精简为「项目总览 + 部署与使用」
- 总览:能力表(等距渲染 / 碰撞 / 35 关 62 块 / 女法师 DCC+COF / 资源包 / 按需读归档 /
  三级选择器)、代码结构、尚未实现的部分(对象美术、101 个生成关卡 → HANDOVER §4f)。
- 部署与使用:自备数据前置、本地启动(夹具 → pack:data → dev)、五个页面与查询参数、
  键位、生产部署(build:game + nginx 三个 location + Range 要求)、自检命令清单、数据与许可。
- 450 行 → 134 行;原来的 M0–M5 里程碑与逐轮验收证据留在 Git 历史(# 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++ 源码。),
  HANDOVER.md 里也加了指引。
2026-09-13 18:38:04 +08:00
troytt 255915036e 加入烘焙图集包(samples/d2-packs,66 MB / 275 个文件)
62 张地图的索引色 PNG 图集页 + scene.json(画家序绘制列表、碰撞 RLE、出生点、对象
元数据)+ index.json(含各 act 调色板)。这是原版美术的解码产物,仓库已改为私有后入库。

有了它,在没有原版 MPQ 的机器上也能跑资源包模式(/acts.html、/diablo2/);
?live=1 与 pack:data 仍需要自备 samples/d2/ 下的原版数据。

没走 Release 附件的原因:本实例的上传接口对 3 MB 与 59.5 MB 都返回 HTTP 500
(大概率是 [attachment] MAX_SIZE 或存储路径限制),所以直接进 Git。
若之后把附件上限调大,可以把这份数据从历史里挪到 Release。
2026-09-13 18:29:28 +08:00
troytt 7a24a3f806 加入自产夹具美术(samples/fixtures,492 KB)
这些文件全部由仓库内脚本生成,无第三方版权问题:
  node scripts/make-fixtures.ts samples/fixtures      # DC6/调色板/文本表/期望网格
  node scripts/make-map-fixtures.ts samples/fixtures  # DT1/DS1/夹具 MPQ

入库后 clone 即可跑夹具相关的校验与页面(verify:all、map.html?data=samples/fixtures),
不必先拿到原版 MPQ。

.gitignore 相应调整:用 `samples/*` 而不是 `samples/`(git 不允许反选被整体排除的目录),
并单独放行 samples/fixtures/*.mpq(那个 MPQ 是我们自己生成的)。原版 MPQ、烘焙图集包
与第三方参考源码仍然不入库。
2026-09-13 18:27:16 +08:00
troytt 897b3735d6 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖)
内容概览
- 归档层:MPQ v1 容器(hash/block 表、Storm 加密、分扇区编解码),含 PKWARE
  implode/explode 移植;支持 HTTP Range 只读取所需字节。
- 格式层:dc6 / ds1 / dt1 / pal / pl2 / cel / pcx / tbl / sprite,以及 dcc / cof / bitstream。
- 地图层:等距投影场景构建(地板/墙/屋顶分层、逐格瓦片变体加权随机、sub-tile 碰撞),
  资源包(索引色 PNG 图集 + scene.json)离线烘焙与逐像素差分校验。
- 角色层:DCC + COF 合成(8 方向),法师行走/站立替换占位棋子。
- 页面:acts.html 全屏画布 + 三级选择器(章节 / 场景 / 细分场景),资源包优先、
  可回退到直读归档(?live=1)。
- 验证脚本:verify:all / implode / acts / d2 / packs / dcc / deploy / listfile /
  object-lookup / alignment / tiles / generators 等,均带断言与退出码。

数据与许可
- 游戏数据(samples/)与第三方参考源码(reference/)不入库:体积大且无再分发许可,
  见 THIRD_PARTY_NOTICES.md。
- 生成关卡(70 迷宫 + 31 野外)尚未烘焙,现状与修复清单见 HANDOVER.md §4f。
2026-09-13 18:10:28 +08:00