diablo2-web/HANDOVER.md

24 KiB
Raw Blame History

交接文档 · d2web

项目路径:/root/diablo_web(原 /root/codex_web/dsh/d2web,2025-09-12 迁移;目录内没有任何写死的绝对路径,迁移后功能与迁移前一致,验证见文末「迁移后复验」)

一句话:用纯 TypeScript + Vite + WebGL2 从零写的暗黑破坏神 2 引擎,读取用户自备的经典版 MPQ, 目标是浏览器内可玩的垂直切片。仓库不含任何暴雪素材,零运行时依赖(只用平台 API)。


1. 现在能玩什么

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

联机需要先起中继(自写、零依赖、只转发字节、不懂游戏):

npm run net-server              # 等价于 node scripts/net-server.ts 8787

操作:WASD/方向键移动,空格或 J 攻击,E/F 拾取,T 说话,1–4 选技能,K 存档,L 读档。 acts.html 只需 WASD/方向键:输入是屏幕方向(菱形格子相对屏幕转了 45°,按"上"沿格子对角线向上走)。

资源包(离线解包,页面默认路径):

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):

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

真数据相关的验收命令:

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 项;三浏览器同时联机实测

一条命令跑完无头验证:

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 系统 差分脚本比像素时用

跑完整差分:

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 的读法修正。现在实测能读:

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. 迁移后复验(本次迁移做的检查)

cd /root/diablo_web
npx tsc --noEmit                 # 类型检查
npm run build                    # 四个入口都进产物
node scripts/verify-net.ts       # 143 项

三者在迁移后均通过(见本次会话最后一条工具输出);开发服务器与中继已改为从新路径启动:

npm run dev          # 后台任务,http://127.0.0.1:5173/
npm run net-server   # 后台任务,ws://127.0.0.1:8787

页面级复验(scripts/browser/):

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,不是逐字节还原)。