初始提交:从零实现的暗黑破坏神 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。
This commit is contained in:
commit
897b3735d6
|
|
@ -0,0 +1,16 @@
|
|||
# 依赖与构建产物(用 npm 脚本可重建)
|
||||
node_modules/
|
||||
dist/
|
||||
dist-game/
|
||||
dist-acts/
|
||||
*.log
|
||||
|
||||
# 用户自备的游戏数据与本机参考源码:体积大 / 无再分发许可,绝不入库
|
||||
samples/
|
||||
*.mpq
|
||||
reference/
|
||||
|
||||
# 临时调试产物
|
||||
.tmp/
|
||||
__pycache__/
|
||||
.DS_Store
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
# 交接文档 · 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,不是逐字节还原)。
|
||||
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
# 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++ 源码。
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# Third-party notices
|
||||
|
||||
## StormLib (reference for MPQ format semantics)
|
||||
|
||||
`src/mpq/crypt.ts` and `src/mpq/decompress.ts` implement algorithms whose
|
||||
semantics were ported from the public StormLib reference so that behaviour
|
||||
matches real archives exactly (hash selectors, the block cipher, the
|
||||
plain-name file key, and the fixed codec order of a combined compression
|
||||
mask). The TypeScript code here is an independent implementation; no C++
|
||||
source is copied verbatim.
|
||||
|
||||
StormLib — <https://github.com/ladislav-zezula/StormLib>
|
||||
Copyright (c) Ladislav Zezula — MIT License.
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>d2web · 暗黑破坏神 2 地图与法师</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; background: #0b0906; color: #e8dcc8;
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; }
|
||||
/* 画布铺满窗口;HUD 与控制器浮在上面 */
|
||||
#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; }
|
||||
.overlay { position: fixed; z-index: 2; background: rgba(11, 9, 6, .82); border: 1px solid #33291f;
|
||||
border-radius: 6px; backdrop-filter: blur(3px); }
|
||||
header { top: 0; left: 0; right: 0; padding: 6px 12px; border-radius: 0; border-width: 0 0 1px 0;
|
||||
display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
|
||||
h1 { font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
|
||||
a { color: #c8a15a; }
|
||||
#hud { color: #9c8d78; }
|
||||
#controls { left: 12px; bottom: 12px; padding: 8px 12px; display: flex; gap: 12px;
|
||||
align-items: center; flex-wrap: wrap; max-width: calc(100vw - 24px); }
|
||||
#controls label { color: #9c8d78; }
|
||||
select { background: #1c1813; color: #e8dcc8; border: 1px solid #33291f;
|
||||
border-radius: 4px; padding: 3px 6px; font: inherit; max-width: 28vw; }
|
||||
optgroup { background: #1c1813; color: #c8a15a; }
|
||||
option { background: #1c1813; }
|
||||
/* 状态面板放在控件行上方:贴在右下角会压住"细分场景"那个下拉框 */
|
||||
#status { right: 12px; bottom: 58px; padding: 6px 10px; max-width: 60vw; color: #9c8d78; }
|
||||
.hint { color: #6d5a34; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="overlay">
|
||||
<h1>d2web · 暗黑破坏神 2</h1>
|
||||
<span id="hud">—</span>
|
||||
</header>
|
||||
<div id="controls" class="overlay">
|
||||
<label>章节 <select id="act" disabled>
|
||||
<option value="1">第一章</option>
|
||||
<option value="2">第二章</option>
|
||||
<option value="3">第三章</option>
|
||||
<option value="4">第四章</option>
|
||||
<option value="5">第五章</option>
|
||||
</select></label>
|
||||
<label>场景 <select id="scene" disabled><option>正在读取场景列表…</option></select></label>
|
||||
<label>细分场景 <select id="variant" disabled></select></label>
|
||||
<span class="hint" style="margin:0">WASD / 方向键移动 · 滚轮或 +/− 缩放 · 三个下拉框都会换图并更新地址栏</span>
|
||||
</div>
|
||||
<div id="status" class="overlay">正在加载地图…</div>
|
||||
<canvas id="view" width="960" height="560"></canvas>
|
||||
<script type="module" src="/src/scene/act-scene.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>d2web · MPQ 资源管线</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #14110d;
|
||||
--panel: #1c1813;
|
||||
--line: #33291f;
|
||||
--ink: #e8dcc8;
|
||||
--dim: #9c8d78;
|
||||
--accent: #c8a15a;
|
||||
--accent-dim: #6d5a34;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h1 { font-size: 15px; margin: 0; letter-spacing: 0.06em; color: var(--accent); }
|
||||
.hint { color: var(--dim); }
|
||||
main { display: grid; grid-template-columns: minmax(280px, 380px) 1fr; gap: 0; height: calc(100vh - 52px); }
|
||||
.col { overflow: auto; }
|
||||
.col + .col { border-left: 1px solid var(--line); }
|
||||
.pad { padding: 14px 18px; }
|
||||
#drop {
|
||||
margin: 18px;
|
||||
padding: 26px 18px;
|
||||
border: 1px dashed var(--accent-dim);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
color: var(--dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
#drop.hot { border-color: var(--accent); color: var(--accent); }
|
||||
.stats { display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; margin: 0 18px 12px; }
|
||||
.stats dt { color: var(--dim); }
|
||||
.stats dd { margin: 0; }
|
||||
#filter {
|
||||
width: calc(100% - 36px);
|
||||
margin: 0 18px 10px;
|
||||
padding: 7px 9px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
}
|
||||
ul { list-style: none; margin: 0; padding: 0 0 24px; }
|
||||
li button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 3px 18px;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
li button:hover { background: #241d16; }
|
||||
li button[aria-current='true'] { background: var(--accent-dim); color: #fff; }
|
||||
li .meta { color: var(--dim); font-size: 11px; }
|
||||
#preview { padding: 16px 18px; }
|
||||
#preview h2 { font-size: 13px; margin: 0 0 4px; color: var(--accent); word-break: break-all; }
|
||||
#preview .sub { color: var(--dim); margin-bottom: 12px; }
|
||||
canvas { image-rendering: pixelated; background: #000; border: 1px solid var(--line); }
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
overflow: auto;
|
||||
max-height: 60vh;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.error { color: #e08a6a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>d2web</h1>
|
||||
<span class="hint">MPQ 资源管线 · 只读取你自己的归档,仓库不含任何游戏素材</span>
|
||||
</header>
|
||||
<main>
|
||||
<section class="col" id="left">
|
||||
<div id="drop">把 .mpq 拖到这里,或点击选择文件</div>
|
||||
<input id="picker" type="file" accept=".mpq,.MPQ" hidden />
|
||||
<dl class="stats" id="stats"></dl>
|
||||
<input id="filter" type="search" placeholder="筛选文件名…" disabled />
|
||||
<ul id="files"></ul>
|
||||
</section>
|
||||
<section class="col" id="preview"><div class="pad hint">选择一个文件查看解码结果</div></section>
|
||||
</main>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>d2web · DS1/DT1 地图场景(M1)</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #0d0b09; color: #e8dcc8;
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
header { padding: 10px 16px; border-bottom: 1px solid #33291f;
|
||||
display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
|
||||
h1 { font-size: 14px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
|
||||
a { color: #c8a15a; }
|
||||
#hud { color: #9c8d78; }
|
||||
#drop { margin: 10px 16px 6px; padding: 8px 12px; border: 1px dashed #6d5a34;
|
||||
border-radius: 6px; color: #9c8d78; cursor: pointer; }
|
||||
#drop.hot { border-color: #c8a15a; color: #c8a15a; }
|
||||
#controls { margin: 0 16px 8px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
#controls label { color: #9c8d78; }
|
||||
select { background: #1c1813; color: #e8dcc8; border: 1px solid #33291f;
|
||||
border-radius: 4px; padding: 3px 6px; max-width: 340px; font: inherit; }
|
||||
button { background: #241d16; color: #e8dcc8; border: 1px solid #33291f;
|
||||
border-radius: 4px; padding: 4px 10px; font: inherit; cursor: pointer; }
|
||||
#status { margin: 0 16px 10px; color: #9c8d78; }
|
||||
#dialog { margin: 0 16px 10px; padding: 10px 12px; max-width: 720px;
|
||||
background: #1c1813; border: 1px solid #6d5a34; border-radius: 6px;
|
||||
color: #e8dcc8; white-space: pre-wrap; }
|
||||
canvas { display: block; margin: 0 16px; background: #000; border: 1px solid #33291f;
|
||||
width: 960px; height: 560px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>d2web · DS1 + DT1 地图</h1>
|
||||
<a href="/">资源检查器</a>
|
||||
<a href="/walk.html?sample=samples/spawn.mpq">暗黑1 走动演示</a>
|
||||
<span id="hud">—</span>
|
||||
</header>
|
||||
<div id="drop">拖入 .mpq 打开你的归档;或用 ?mpq=<url> / ?data=<fixture 目录></div>
|
||||
<input id="picker" type="file" accept=".mpq,.MPQ" hidden />
|
||||
<div id="controls" hidden>
|
||||
<label>关卡 DS1 <select id="ds1"></select></label>
|
||||
<label>瓦片 DT1 <select id="dt1"></select></label>
|
||||
<label>调色板 <select id="pal"></select></label>
|
||||
<button id="reload">重新加载</button>
|
||||
</div>
|
||||
<p id="status">正在加载地图…</p>
|
||||
<div id="dialog" hidden></div>
|
||||
<canvas id="view" width="960" height="560"></canvas>
|
||||
<script type="module" src="/src/scene/map-scene.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>d2web · 联机合作(M5)</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #0d0b09; color: #e8dcc8;
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
header { padding: 10px 16px; border-bottom: 1px solid #33291f;
|
||||
display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
|
||||
h1 { font-size: 14px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
|
||||
a { color: #c8a15a; }
|
||||
#net { color: #9c8d78; }
|
||||
#net b { color: #c8a15a; font-weight: 600; }
|
||||
#net .bad { color: #d96a5a; }
|
||||
#status { margin: 10px 16px 6px; color: #9c8d78; }
|
||||
#peers { margin: 0 16px 8px; color: #9c8d78; }
|
||||
#peers span.you { color: #7fd08a; }
|
||||
canvas { display: block; margin: 0 16px; background: #000; border: 1px solid #33291f;
|
||||
width: 960px; height: 560px; }
|
||||
#help { margin: 8px 16px 16px; color: #6f6252; max-width: 900px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>d2web · 双人联机合作</h1>
|
||||
<a href="/">资源检查器</a>
|
||||
<a href="/map.html?data=samples/fixtures">单人地图场景</a>
|
||||
<span id="net">未联机</span>
|
||||
</header>
|
||||
<p id="status">正在加载地图…</p>
|
||||
<div id="peers"></div>
|
||||
<canvas id="view" width="960" height="560"></canvas>
|
||||
<p id="help">
|
||||
移动 WASD / 方向键,空格或 J 攻击。每个浏览器页开一个(2–4 人都可以,人数用
|
||||
<code>&peers=</code> 指定,<code>&peer=</code> 是本页编号):
|
||||
<code>?data=samples/fixtures&ws=ws://127.0.0.1:8787&peers=3&peer=0</code>,
|
||||
另外两页把 <code>peer</code> 换成 1 和 2。世界由每台机器各自模拟,网络上只传按键;
|
||||
同一 tick 的哈希不一致时页面会立刻报出分歧。
|
||||
</p>
|
||||
<script type="module" src="/src/scene/net-scene.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,977 @@
|
|||
{
|
||||
"name": "d2web",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "d2web",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.2",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
||||
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
||||
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
||||
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
||||
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
"integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
|
||||
"integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
|
||||
"integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
|
||||
"integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
|
||||
"integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
|
||||
"integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
|
||||
"integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
|
||||
"integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
|
||||
"integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
|
||||
"integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
|
||||
"integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
|
||||
"integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
|
||||
"integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
|
||||
"integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
|
||||
"integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
|
||||
"integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
|
||||
"integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
|
||||
"integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
|
||||
"integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
|
||||
"integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
|
||||
"integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
|
||||
"integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
|
||||
"integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
|
||||
"integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
|
||||
"integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
|
||||
"integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.2.tgz",
|
||||
"integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.21.5",
|
||||
"@esbuild/android-arm": "0.21.5",
|
||||
"@esbuild/android-arm64": "0.21.5",
|
||||
"@esbuild/android-x64": "0.21.5",
|
||||
"@esbuild/darwin-arm64": "0.21.5",
|
||||
"@esbuild/darwin-x64": "0.21.5",
|
||||
"@esbuild/freebsd-arm64": "0.21.5",
|
||||
"@esbuild/freebsd-x64": "0.21.5",
|
||||
"@esbuild/linux-arm": "0.21.5",
|
||||
"@esbuild/linux-arm64": "0.21.5",
|
||||
"@esbuild/linux-ia32": "0.21.5",
|
||||
"@esbuild/linux-loong64": "0.21.5",
|
||||
"@esbuild/linux-mips64el": "0.21.5",
|
||||
"@esbuild/linux-ppc64": "0.21.5",
|
||||
"@esbuild/linux-riscv64": "0.21.5",
|
||||
"@esbuild/linux-s390x": "0.21.5",
|
||||
"@esbuild/linux-x64": "0.21.5",
|
||||
"@esbuild/netbsd-x64": "0.21.5",
|
||||
"@esbuild/openbsd-x64": "0.21.5",
|
||||
"@esbuild/sunos-x64": "0.21.5",
|
||||
"@esbuild/win32-arm64": "0.21.5",
|
||||
"@esbuild/win32-ia32": "0.21.5",
|
||||
"@esbuild/win32-x64": "0.21.5"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.19.tgz",
|
||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.18",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.1.tgz",
|
||||
"integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.9"
|
||||
},
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/lzma-linux-x64-gnu": "1.5.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.63.1",
|
||||
"@rollup/rollup-android-arm64": "4.63.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.63.1",
|
||||
"@rollup/rollup-darwin-x64": "4.63.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.63.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.63.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.63.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.63.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.63.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.63.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.63.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.63.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.63.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.63.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.63.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.63.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.63.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.63.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.63.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.63.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.63.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.63.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.63.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.63.1",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
"rollup": "^4.20.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"less": "*",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"lightningcss": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "d2web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Web-native Diablo II engine: reads assets from a user-supplied classic MPQ. No game assets are bundled.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"inspect": "node scripts/inspect-mpq.ts",
|
||||
"net-server": "node scripts/net-server.ts",
|
||||
"verify:net": "node scripts/verify-net.ts",
|
||||
"verify:d2": "node scripts/verify-d2-data.ts",
|
||||
"verify:implode": "node scripts/verify-implode.ts",
|
||||
"verify:all": "node scripts/verify-combat.ts && node scripts/verify-items.ts && node scripts/verify-m4.ts && node scripts/verify-m5.ts && node scripts/verify-net.ts",
|
||||
"verify:acts": "node scripts/verify-acts.ts",
|
||||
"build:game": "vite build --base=/diablo2/ --outDir dist-game",
|
||||
"pack:data": "node scripts/pack-act-assets.ts",
|
||||
"verify:packs": "node scripts/verify-packs.ts",
|
||||
"verify:deploy": "node scripts/verify-deploy.ts",
|
||||
"verify:listfile": "node scripts/verify-listfile.ts",
|
||||
"verify:object-lookup": "node scripts/verify-object-lookup.ts",
|
||||
"port:object-lookup": "node scripts/port-object-lookup.ts",
|
||||
"verify:alignment": "node scripts/verify-tile-alignment.ts",
|
||||
"verify:tiles": "node scripts/verify-tiles.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.2",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# 浏览器验证工具(零依赖)
|
||||
|
||||
这些脚本用 Node 内置的 `WebSocket` 直接讲 CDP(Chrome DevTools Protocol),
|
||||
不需要 puppeteer/playwright 这类依赖。目的只有一个:**给"页面里到底发生了什么"留下证据**,
|
||||
因为像素和截图的判断在这套环境里不可靠(模型读不了图,`readPixels` 在合成后会返回清空缓冲)。
|
||||
|
||||
## inspect-page.mjs —— 单页检查
|
||||
|
||||
```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 --screenshot /tmp/map.png
|
||||
```
|
||||
|
||||
它会启动一个 headless Chromium(SwiftShader 软件 WebGL2),打开 URL,等待页面加载,
|
||||
把 check 脚本的返回值打印成 JSON,可选截图。
|
||||
|
||||
## many-pages.mjs —— N 页同时跑(联机用)
|
||||
|
||||
```bash
|
||||
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&peers=3&peer=0" \
|
||||
"http://127.0.0.1:5173/net.html?data=samples/fixtures&ws=ws://127.0.0.1:8787&peers=3&peer=1" \
|
||||
"http://127.0.0.1:5173/net.html?data=samples/fixtures&ws=ws://127.0.0.1:8787&peers=3&peer=2" \
|
||||
--seconds 20 --shot /tmp/coop
|
||||
```
|
||||
|
||||
每一页是一个**独立的 Chromium 实例**(标签页级复用在这套 CDP 流程里会把两次导航混在一起,
|
||||
曾经导致"两页都跑成了 peer 0"这种假象)。它按页分发不同的方向键,让每个角色只受本页按键驱动,
|
||||
结束时打印每页的 `window.__d2web*` 状态。判定靠状态字段,不靠看图。
|
||||
|
||||
## checks/ —— 页面内脚本
|
||||
|
||||
| 文件 | 目标页面 | 做什么 |
|
||||
| --- | --- | --- |
|
||||
| `map-save-load.js` | `map.html` | 走位砍怪 → 存档 → 继续跑 → 读档,比较存档点与恢复后的位置/击杀/金币/地面/等级 |
|
||||
| `walk-state.js` | `walk.html` | 读真实 MPQ(暗黑1 `spawn.mpq`)后的就绪状态、tick、drawCalls、`glError` |
|
||||
| `net-state.js` | `net.html` | 单机模式的状态:世界 tick、角色数、怪物数、tps |
|
||||
| `act-state.js` | `acts.html`(`?live=1`) | 读归档路径:归档是否读入、DS1 象限、格数、瓦片引用缺失数、图集尺寸,以及按键后的位移与朝向 |
|
||||
| `act-pack-state.js` | `acts.html`(默认) | 资源包路径:是否走 pack、首帧装了几页(证明懒加载)、出画面毫秒数、按键位移与朝向 |
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
// act-pack-state.js — 资源包路径的状态断言(acts.html 默认路径)
|
||||
//
|
||||
// 判定全来自 window.__d2webAct:是不是走了资源包、首屏只装了几页(证明懒加载)、
|
||||
// 出画面用了多久、以及按键后角色是否沿屏幕方向位移且朝向正确。返回 JSON。
|
||||
//
|
||||
// CHROME=/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome
|
||||
// node scripts/browser/inspect-page.mjs "$CHROME" \
|
||||
// "http://127.0.0.1:5173/acts.html?act=1" scripts/browser/checks/act-pack-state.js
|
||||
(async () => {
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
const deadline = Date.now() + 120000
|
||||
while (Date.now() < deadline) {
|
||||
const s = window.__d2webAct
|
||||
if (s && (s.ready || s.error)) break
|
||||
await sleep(200)
|
||||
}
|
||||
const atReady = { ...(window.__d2webAct ?? {}) }
|
||||
await sleep(6000)
|
||||
const after = { ...(window.__d2webAct ?? {}) }
|
||||
const before = { x: after.x, y: after.y }
|
||||
const press = (code, down) => window.dispatchEvent(new KeyboardEvent(down ? 'keydown' : 'keyup', { code, bubbles: true }))
|
||||
press('ArrowRight', true); await sleep(900); press('ArrowRight', false)
|
||||
await sleep(200)
|
||||
const moved = { x: window.__d2webAct?.x ?? 0, y: window.__d2webAct?.y ?? 0, facing: window.__d2webAct?.facing ?? -1 }
|
||||
return {
|
||||
source: atReady.source, ready: atReady.ready, error: atReady.error ?? null,
|
||||
base: atReady.base, act: atReady.act, level: atReady.level, quadrant: atReady.quadrant,
|
||||
cells: `${atReady.cellsX}x${atReady.cellsY}`,
|
||||
floors: atReady.floors, walls: atReady.walls, frames: atReady.frames, objects: atReady.objects,
|
||||
character: atReady.character,
|
||||
characterMembers: atReady.characterMembers,
|
||||
characterAfter: after.character,
|
||||
characterMembersAfter: after.characterMembers,
|
||||
levelOptions: document.querySelector('#level')?.querySelectorAll('option').length ?? -1,
|
||||
pickerSelected: document.querySelector('#level')?.selectedOptions?.[0]?.textContent ?? null,
|
||||
pagesAtReady: `${atReady.pagesLoaded}/${atReady.pagesTotal}`,
|
||||
pagesAtFirstFrame: `${atReady.pagesAtFirstFrame}/${atReady.pagesTotal}`,
|
||||
firstFrameMs: atReady.firstFrameMs,
|
||||
priorityPages: atReady.priorityPages,
|
||||
lazyFirstFrame: atReady.pagesAtFirstFrame < atReady.pagesTotal,
|
||||
pagesAfterWait: `${after.pagesLoaded}/${after.pagesTotal}`,
|
||||
lazyFirstPaint: (atReady.pagesLoaded ?? 0) < (atReady.pagesTotal ?? 0),
|
||||
loadMs: atReady.loadMs,
|
||||
skippedAtReady: atReady.skippedDraws,
|
||||
move: [Math.round(moved.x - before.x), Math.round(moved.y - before.y)],
|
||||
facing: moved.facing,
|
||||
tick: after.tick,
|
||||
walkable: Number((atReady.walkable ?? 0).toFixed(3)),
|
||||
// 画布是否铺满窗口 + 缩放是否至少覆盖视口
|
||||
canvas: [after.canvasWidth, after.canvasHeight],
|
||||
viewport: [window.innerWidth, window.innerHeight],
|
||||
zoom: after.zoom,
|
||||
coverZoom: Math.max(window.innerWidth / Math.max(1, after.widthPx), window.innerHeight / Math.max(1, after.heightPx)),
|
||||
canvasFillsWindow: after.canvasWidth >= window.innerWidth - 2 && after.canvasHeight >= window.innerHeight - 2,
|
||||
mapCoversViewport: (after.zoom ?? 0) + 1e-6 >= Math.max(window.innerWidth / Math.max(1, after.widthPx), window.innerHeight / Math.max(1, after.heightPx)),
|
||||
}
|
||||
})()
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
// act-state.js — act 城镇页的状态断言(acts.html)
|
||||
//
|
||||
// 判定全部来自 window.__d2webAct 的字段,不看图:归档是否真的读进来了、地图块
|
||||
// 有几格、瓦片引用是否全部解析、图集多大、以及按键之后角色是否真的沿屏幕方向
|
||||
// 位移且朝向正确。返回 JSON,由 scripts/browser/inspect-page.mjs 打印。
|
||||
//
|
||||
// CHROME=/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome
|
||||
// node scripts/browser/inspect-page.mjs "$CHROME" \
|
||||
// "http://127.0.0.1:5173/acts.html?act=1" scripts/browser/checks/act-state.js
|
||||
(async () => {
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
const deadline = Date.now() + 150000
|
||||
while (Date.now() < deadline) {
|
||||
const s = window.__d2webAct
|
||||
if (s && (s.ready || s.error)) break
|
||||
await sleep(500)
|
||||
}
|
||||
const first = { ...(window.__d2webAct ?? {}) }
|
||||
await sleep(1500)
|
||||
const before = { x: window.__d2webAct?.x ?? 0, y: window.__d2webAct?.y ?? 0, tick: window.__d2webAct?.tick ?? 0 }
|
||||
const press = (code, down) => window.dispatchEvent(new KeyboardEvent(down ? 'keydown' : 'keyup', { code, bubbles: true }))
|
||||
// 向东走 1.2 秒,再向南走 1.2 秒;记录位移与朝向
|
||||
press('ArrowRight', true); await sleep(1200); press('ArrowRight', false)
|
||||
const east = { x: window.__d2webAct?.x ?? 0, y: window.__d2webAct?.y ?? 0, facing: window.__d2webAct?.facing ?? -1 }
|
||||
press('ArrowDown', true); await sleep(1200); press('ArrowDown', false)
|
||||
await sleep(300)
|
||||
const south = { x: window.__d2webAct?.x ?? 0, y: window.__d2webAct?.y ?? 0, facing: window.__d2webAct?.facing ?? -1 }
|
||||
return {
|
||||
ready: first.ready, error: first.error ?? null,
|
||||
act: first.act, level: first.level, quadrant: first.quadrant, quadrants: first.quadrants,
|
||||
cells: `${first.cellsX}x${first.cellsY}`, scene: `${first.widthPx}x${first.heightPx}`,
|
||||
floors: first.floors, walls: first.walls, frames: first.frames,
|
||||
atlas: `${first.atlasWidth}x${first.atlasHeight}`,
|
||||
missing: first.missing, walkable: Number((first.walkable ?? 0).toFixed(3)),
|
||||
character: first.character, drawCalls: first.drawCalls, tickRate: Number((first.tickRate ?? 0).toFixed(1)),
|
||||
moved: { east: [Math.round(east.x - before.x), Math.round(east.y - before.y)], south: [Math.round(south.x - east.x), Math.round(south.y - east.y)], tickDelta: (window.__d2webAct?.tick ?? 0) - before.tick },
|
||||
facingEast: east.facing, facingSouth: south.facing,
|
||||
}
|
||||
})()
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
(async () => {
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||
const key = (type, code) => window.dispatchEvent(new KeyboardEvent(type, { code, bubbles: true }))
|
||||
const tap = async (code, ms) => { key('keydown', code); await sleep(ms); key('keyup', code); await sleep(60) }
|
||||
for (let i = 0; i < 40 && (window.__d2webMap === undefined || window.__d2webMap.tick < 5); i++) await sleep(250)
|
||||
const state = window.__d2webMap
|
||||
if (state === undefined) return { error: 'no scene state' }
|
||||
const snap = () => ({
|
||||
tick: state.tick, x: Math.round(state.x), y: Math.round(state.y), level: state.playerLevel,
|
||||
xp: state.playerXp, kills: state.kills, gold: state.gold, bag: state.bagItems,
|
||||
ground: state.groundItems, monsters: state.monstersNear ? state.monstersNear.length : null,
|
||||
quests: state.quests, hp: state.playerHp, mana: state.playerMana,
|
||||
})
|
||||
// Fight for a few seconds: walk and swing repeatedly.
|
||||
key('keydown', 'KeyD')
|
||||
for (let i = 0; i < 12; i++) await tap('Space', 220)
|
||||
key('keyup', 'KeyD')
|
||||
key('keydown', 'KeyS')
|
||||
for (let i = 0; i < 12; i++) await tap('Space', 220)
|
||||
key('keyup', 'KeyS')
|
||||
await sleep(600)
|
||||
const before = snap()
|
||||
key('keydown', 'KeyK'); await sleep(200); key('keyup', 'KeyK')
|
||||
await sleep(400)
|
||||
const savedAt = snap()
|
||||
key('keydown', 'KeyD'); await sleep(2000); key('keyup', 'KeyD')
|
||||
await sleep(600)
|
||||
const drifted = snap()
|
||||
key('keydown', 'KeyL'); await sleep(200); key('keyup', 'KeyL')
|
||||
await sleep(800)
|
||||
const after = snap()
|
||||
const gl = document.querySelector('#view').getContext('webgl2')
|
||||
return {
|
||||
before, savedAt, drifted, after,
|
||||
saves: state.saves, loads: state.loads, saveError: state.saveError,
|
||||
tickRate: state.tickRate, glError: gl ? gl.getError() : null, error: state.error ?? null,
|
||||
source: state.source ?? null, itemTablesSource: state.itemTablesSource ?? null,
|
||||
}
|
||||
})()
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
// map-switch.js — 断言"章节 / 场景 / 细分场景"三个下拉框真的跳到选中的地图
|
||||
//
|
||||
// 这里防的是一类会静默退化的 bug:下拉框写错查询参数(早期是 `?quadrant=` 而资源包路径读
|
||||
// `?level=`),于是换图失败、悄悄回到该 act 的城镇。断言方式是拦下处理函数构造出来的目标 URL
|
||||
// (`location.search` 的赋值在同一个任务里),再检查参数名与取值。
|
||||
//
|
||||
// 同时核对三级结构本身:章节是「第一章…第五章」、场景是中文场景名、细分场景是所选场景的变体
|
||||
// (罗格营地是 北/东/南/西)。
|
||||
//
|
||||
// CHROME=/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome
|
||||
// node scripts/browser/inspect-page.mjs "$CHROME" \
|
||||
// "https://www.laiseek.xyz/diablo2/?act=1" scripts/browser/checks/map-switch.js
|
||||
(async () => {
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
const deadline = Date.now() + 120000
|
||||
while (Date.now() < deadline) {
|
||||
const s = window.__d2webAct
|
||||
if (s && (s.ready || s.error)) break
|
||||
await sleep(200)
|
||||
}
|
||||
await sleep(800)
|
||||
|
||||
// 处理函数用的是 location.search 赋值,拦 URLSearchParams.toString 就能拿到目标。
|
||||
const seen = []
|
||||
const paramsToString = URLSearchParams.prototype.toString
|
||||
URLSearchParams.prototype.toString = function () { const value = paramsToString.call(this); seen.push(`?${value}`); return value }
|
||||
|
||||
const texts = (id) => Array.from(document.querySelectorAll(`#${id} option`)).map((o) => o.textContent)
|
||||
const result = {
|
||||
source: window.__d2webAct.source,
|
||||
headerLinks: Array.from(document.querySelectorAll('header a')).map((a) => a.textContent),
|
||||
actOptions: texts('act'),
|
||||
sceneOptions: texts('scene'),
|
||||
variantOptions: texts('variant'),
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中某个下拉框的某个值并捕获它构造出的目标 URL。
|
||||
*
|
||||
* @param id - select id.
|
||||
* @param pick - chooses a value out of the options.
|
||||
* @param match - how to judge the captured `?level=`: a variant label *is* the value,
|
||||
* while a scene navigates to its **first variant**, whose label starts with the slug.
|
||||
*/
|
||||
const trySwitch = (id, pick, match = (value, level) => level === value) => {
|
||||
const select = document.querySelector(`#${id}`)
|
||||
if (select === null) return { error: 'no such select' }
|
||||
const values = Array.from(select.querySelectorAll('option')).map((o) => o.value)
|
||||
const value = pick(values) ?? values[0]
|
||||
seen.length = 0
|
||||
select.value = value
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
const target = seen.at(-1) ?? null
|
||||
let params = null
|
||||
try { params = target === null ? null : new URL(target, location.origin).searchParams } catch { params = null }
|
||||
return {
|
||||
picked: value,
|
||||
target,
|
||||
levelParam: params === null ? null : params.get('level'),
|
||||
actParam: params === null ? null : params.get('act'),
|
||||
switchesMap: params !== null && match(value, params.get('level') ?? '') && params.get('act') !== null,
|
||||
}
|
||||
}
|
||||
|
||||
// 章节切换跳到该 act 的第一个场景的第一个细分:`?act=` 变了,`?level=` 也该跟着变。
|
||||
result.actSwitch = trySwitch('act', values => values[1] ?? values[0],
|
||||
(act, level) => act !== String(window.__d2webAct.act) && level !== '')
|
||||
result.variantSwitch = trySwitch('variant', values => values[1] ?? values[0])
|
||||
result.sceneSwitch = trySwitch('scene', values => values[1] ?? values[0],
|
||||
(slug, level) => level === slug || level.startsWith(`${slug}-`))
|
||||
URLSearchParams.prototype.toString = paramsToString
|
||||
|
||||
const checks = [
|
||||
['resource pack path', result.source === 'pack'],
|
||||
['header 上没有多余链接', result.headerLinks.length === 0],
|
||||
['章节是五章中文名', result.actOptions.join('') === '第一章第二章第三章第四章第五章'],
|
||||
['场景是中文名(不是 Act N - …)', result.sceneOptions.every(text => !/^Act \d/.test(text)) && result.sceneOptions.length > 1],
|
||||
['细分场景是方位(罗格营地 北/东/南/西)', result.variantOptions.includes('北') && result.variantOptions.includes('南')],
|
||||
['细分场景切换写 ?level=', result.variantSwitch.switchesMap === true],
|
||||
['场景切换跳到该场景的首个细分(?level= 以 slug 开头)且带 ?act=', result.sceneSwitch.switchesMap === true],
|
||||
['章节切换同时改 ?act= 与 ?level=', result.actSwitch.switchesMap === true],
|
||||
]
|
||||
return {
|
||||
...result,
|
||||
act: window.__d2webAct.act,
|
||||
level: window.__d2webAct.level,
|
||||
pass: checks.every(([, ok]) => ok),
|
||||
checks,
|
||||
}
|
||||
})()
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
(async () => {
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||
for (let i = 0; i < 40 && (window.__d2webNet === undefined || window.__d2webNet.worldTick < 10); i++) await sleep(250)
|
||||
const s = window.__d2webNet
|
||||
if (s === undefined) return { error: 'no state' }
|
||||
return {
|
||||
multiplayer: s.multiplayer, connected: s.connected, handshaked: s.handshaked,
|
||||
worldTick: s.worldTick, tickRate: Math.round(s.tickRate), glError: s.glError,
|
||||
players: s.players.length, monsters: s.monsters, error: s.error,
|
||||
}
|
||||
})()
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
(async () => {
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||
for (let i = 0; i < 80 && (window.__d2web === undefined || (window.__d2web.tick ?? 0) < 5); i++) await sleep(250)
|
||||
const s = window.__d2web
|
||||
if (s === undefined) return { error: 'no state', keys: Object.keys(window).filter(k => k.startsWith('__')) }
|
||||
const gl = document.querySelector('canvas')?.getContext('webgl2')
|
||||
return { ...s, glError: gl ? gl.getError() : null, canvas: (() => { const c = document.querySelector('canvas'); return c ? [c.width, c.height] : null })() }
|
||||
})()
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
// Minimal CDP driver: node cdp_inspect.mjs <chromeBin> <url> [scriptFile]
|
||||
// Launches headless Chromium, connects over the DevTools WebSocket (Node's
|
||||
// built-in WebSocket — no dependencies), evaluates one async script, prints JSON.
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const [chromeBin, targetUrl, scriptFile] = process.argv.slice(2)
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'cdp-'))
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
// Optional --window-size=WxH, so responsive behaviour can be checked at real sizes.
|
||||
const windowSizeArg = process.argv.find((arg) => arg.startsWith('--window-size='))
|
||||
const windowSize = windowSizeArg ? windowSizeArg.split('=')[1] : null
|
||||
|
||||
const chrome = spawn(chromeBin, [
|
||||
'--headless=new', '--no-sandbox', '--disable-dev-shm-usage',
|
||||
// Headless Chrome wants `--window-size=W,H`; a `WxH` string is accepted here too.
|
||||
...(windowSize ? [`--window-size=${windowSize.replace('x', ',')}`] : []),
|
||||
// Software WebGL2: headless has no GPU, and SwiftShader is the only way to
|
||||
// exercise the renderer path in CI-like runs.
|
||||
'--use-angle=swiftshader', '--use-gl=angle', '--enable-unsafe-swiftshader',
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
'--remote-debugging-port=0',
|
||||
targetUrl,
|
||||
], { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
let stderr = ''
|
||||
chrome.stderr.on('data', (d) => { stderr += d })
|
||||
|
||||
let port = null
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await sleep(200)
|
||||
try {
|
||||
port = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8').trim().split('\n')[0]
|
||||
if (port) break
|
||||
} catch {}
|
||||
}
|
||||
if (!port) { console.error('NO PORT', stderr.slice(-500)); process.exit(1) }
|
||||
|
||||
let page
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await sleep(200)
|
||||
try {
|
||||
const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
|
||||
page = list.find((x) => x.type === 'page')
|
||||
if (page) break
|
||||
} catch {}
|
||||
}
|
||||
if (!page) { console.error('NO PAGE', stderr.slice(-500)); process.exit(1) }
|
||||
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject })
|
||||
let msgId = 0
|
||||
const pending = new Map()
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
if (message.id && pending.has(message.id)) { pending.get(message.id)(message); pending.delete(message.id) }
|
||||
}
|
||||
const send = (method, params = {}) => new Promise((resolve) => {
|
||||
const id = ++msgId
|
||||
pending.set(id, resolve)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
|
||||
await send('Runtime.enable')
|
||||
await send('Page.enable')
|
||||
await sleep(3000)
|
||||
|
||||
const expression = scriptFile
|
||||
? await readFile(scriptFile, 'utf8')
|
||||
: `({ title: document.title, text: document.body.innerText.slice(0, 2000) })`
|
||||
const result = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
|
||||
const shotIndex = process.argv.indexOf('--screenshot')
|
||||
if (shotIndex !== -1) {
|
||||
const shotPath = process.argv[shotIndex + 1]
|
||||
const shot = await send('Page.captureScreenshot', { format: 'png' })
|
||||
const data = shot.result?.data
|
||||
if (typeof data === 'string') {
|
||||
const { writeFileSync } = await import('node:fs')
|
||||
writeFileSync(shotPath, Buffer.from(data, 'base64'))
|
||||
console.error(`screenshot written: ${shotPath}`)
|
||||
}
|
||||
}
|
||||
if (result.result?.exceptionDetails) {
|
||||
console.error('EVAL ERROR', JSON.stringify(result.result.exceptionDetails).slice(0, 1500))
|
||||
} else {
|
||||
console.log(JSON.stringify(result.result?.result?.value, null, 2))
|
||||
}
|
||||
ws.close()
|
||||
chrome.kill()
|
||||
process.exit(0)
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
// N separate Chromium instances, one page each: the co-op check the reliable way.
|
||||
// Usage: node cdp_many.mjs <chromeBin> <url0> <url1> [...] [--seconds N] [--shot prefix]
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const chromeBin = args[0]
|
||||
const urls = args.slice(1).filter((value) => value.startsWith('http'))
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(name)
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
const seconds = Number(arg('--seconds', '14'))
|
||||
const shotPrefix = arg('--shot', null)
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/** Launch Chromium on a URL and attach to its only tab. */
|
||||
async function connect(url) {
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'cdp-pair-'))
|
||||
const chrome = spawn(chromeBin, [
|
||||
'--headless=new', '--no-sandbox', '--disable-dev-shm-usage',
|
||||
'--use-angle=swiftshader', '--use-gl=angle', '--enable-unsafe-swiftshader',
|
||||
'--window-size=1000,700',
|
||||
`--user-data-dir=${userDataDir}`, '--remote-debugging-port=0', url,
|
||||
], { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
let stderr = ''
|
||||
chrome.stderr.on('data', (d) => { stderr += d })
|
||||
let port = null
|
||||
for (let i = 0; i < 120 && !port; i++) {
|
||||
await sleep(200)
|
||||
try { port = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8').trim().split('\n')[0] } catch {}
|
||||
}
|
||||
if (!port) throw new Error(`no devtools port; ${stderr.slice(-300)}`)
|
||||
let page = null
|
||||
for (let i = 0; i < 40 && page === null; i++) {
|
||||
await sleep(200)
|
||||
try {
|
||||
const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
|
||||
page = list.find((entry) => entry.type === 'page') ?? null
|
||||
} catch {}
|
||||
}
|
||||
if (page === null) throw new Error(`no page target; ${stderr.slice(-300)}`)
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject })
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
if (message.id && pending.has(message.id)) { pending.get(message.id)(message); pending.delete(message.id) }
|
||||
}
|
||||
const send = (method, params = {}) => new Promise((resolve) => {
|
||||
const msgId = ++id
|
||||
pending.set(msgId, resolve)
|
||||
ws.send(JSON.stringify({ id: msgId, method, params }))
|
||||
})
|
||||
await send('Runtime.enable')
|
||||
await send('Page.enable')
|
||||
return { send, ws, chrome, stderr: () => stderr }
|
||||
}
|
||||
|
||||
const pages = await Promise.all(urls.map((url) => connect(url)))
|
||||
// Collect page-level errors: an exception thrown inside a socket listener is
|
||||
// invisible from the outside, and is exactly how a "no messages arrive" bug
|
||||
// actually looks.
|
||||
for (const page of pages) {
|
||||
await page.send('Runtime.evaluate', { expression: `(() => { window.__errs = []; window.addEventListener('error', e => window.__errs.push(String(e.message)));
|
||||
window.addEventListener('unhandledrejection', e => window.__errs.push('rejection: ' + String(e.reason)));
|
||||
return 'ok'; })()`, returnByValue: true })
|
||||
}
|
||||
await sleep(4500)
|
||||
|
||||
const evaluate = async (page, expression, awaitPromise = false) => {
|
||||
const result = await page.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise })
|
||||
if (result.result?.exceptionDetails) return { __error: JSON.stringify(result.result.exceptionDetails).slice(0, 400) }
|
||||
return result.result?.result?.value ?? null
|
||||
}
|
||||
|
||||
console.log('where:', JSON.stringify(await Promise.all(pages.map(page => evaluate(page, 'location.href')))))
|
||||
|
||||
/** Press and hold a key, then release it. */
|
||||
async function hold(page, code, key, vk, ms) {
|
||||
const base = { code, key, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk }
|
||||
await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base })
|
||||
await sleep(ms)
|
||||
await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base })
|
||||
}
|
||||
|
||||
const tap = async (page, code, key, vk) => {
|
||||
const base = { code, key, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk }
|
||||
await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base })
|
||||
await sleep(700)
|
||||
await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base })
|
||||
}
|
||||
|
||||
// Each page walks on its own axis, so a fighter that moves only under its own
|
||||
// page's keys is visible in the final positions.
|
||||
const KEYS = [['KeyD', 'd', 68], ['KeyS', 's', 83], ['KeyA', 'a', 65], ['KeyW', 'w', 87]]
|
||||
const drive = (async () => {
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
await Promise.all(pages.map((page, index) => {
|
||||
const [code, key, vk] = KEYS[index % KEYS.length]
|
||||
return hold(page, code, key, vk, 800)
|
||||
}))
|
||||
await Promise.all(pages.map((page) => tap(page, 'Space', ' ', 32)))
|
||||
await sleep(300)
|
||||
}
|
||||
})()
|
||||
await Promise.race([drive, sleep(seconds * 1000)])
|
||||
|
||||
const states = []
|
||||
for (const page of pages) {
|
||||
const value = await evaluate(page, 'JSON.stringify(window.__d2webNet ?? null)')
|
||||
states.push(typeof value === 'string' ? JSON.parse(value) : value)
|
||||
}
|
||||
|
||||
if (shotPrefix !== null) {
|
||||
for (let index = 0; index < pages.length; index += 1) {
|
||||
const shot = await pages[index].send('Page.captureScreenshot', { format: 'png' })
|
||||
const data = shot.result?.data
|
||||
if (typeof data === 'string') writeFileSync(`${shotPrefix}-${index}.png`, Buffer.from(data, 'base64'))
|
||||
}
|
||||
}
|
||||
|
||||
// Pixel statistics, read straight from the framebuffer: a page whose world is
|
||||
// correct but whose canvas is empty has failed in a way the numbers cannot see.
|
||||
let pixels = []
|
||||
for (const page of pages) {
|
||||
const value = await evaluate(page, `(() => { const c = document.querySelector('#view'); const gl = c.getContext('webgl2');
|
||||
const px = new Uint8Array(c.width * c.height * 4); gl.readPixels(0, 0, c.width, c.height, gl.RGBA, gl.UNSIGNED_BYTE, px);
|
||||
let nonBlack = 0; const seen = new Set();
|
||||
for (let i = 0; i < px.length; i += 4) { if (px[i] + px[i+1] + px[i+2] > 24) nonBlack++;
|
||||
seen.add((px[i] >> 3) + ',' + (px[i+1] >> 3) + ',' + (px[i+2] >> 3)); }
|
||||
return JSON.stringify({ nonBlack, total: c.width * c.height, distinctColours: seen.size, glError: gl.getError() }); })()`)
|
||||
pixels.push(typeof value === 'string' ? JSON.parse(value) : value)
|
||||
}
|
||||
|
||||
const errors = []
|
||||
for (const page of pages) {
|
||||
const value = await evaluate(page, 'JSON.stringify(window.__errs ?? null)')
|
||||
errors.push(typeof value === 'string' ? JSON.parse(value) : value)
|
||||
}
|
||||
console.log(JSON.stringify({ states, pixels, errors }, null, 2))
|
||||
for (const page of pages) { page.ws.close(); page.chrome.kill() }
|
||||
process.exit(0)
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Canonical dumper: print a decoded Diablo II format file as the same JSON shape
|
||||
* the Go reference dumper emits, so the two can be diffed field by field.
|
||||
*
|
||||
* Usage: node scripts/dump-format.ts <dc6|dt1|ds1> <file>
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { decodeDc6 } from '../src/formats/dc6.ts'
|
||||
import { decodeDt1 } from '../src/formats/dt1.ts'
|
||||
import { decodeDs1 } from '../src/formats/ds1.ts'
|
||||
import { decodePl2 } from '../src/formats/pl2.ts'
|
||||
|
||||
const [format, path] = process.argv.slice(2)
|
||||
if (format === undefined || path === undefined) {
|
||||
console.error('usage: node scripts/dump-format.ts <dc6|dt1|ds1> <file>')
|
||||
process.exit(2)
|
||||
}
|
||||
const data = new Uint8Array(await readFile(path))
|
||||
|
||||
let output: unknown
|
||||
switch (format) {
|
||||
case 'dc6': {
|
||||
const sheet = decodeDc6(data)
|
||||
output = {
|
||||
format: 'dc6',
|
||||
directions: sheet.header.directions,
|
||||
framesPerDirection: sheet.header.framesPerDirection,
|
||||
frames: sheet.groups.map(group => group.frames.map(frame => ({
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
offsetX: frame.offsetX,
|
||||
offsetY: frame.offsetY,
|
||||
indices: [...frame.indices],
|
||||
}))),
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'dt1': {
|
||||
const library = decodeDt1(data)
|
||||
output = {
|
||||
format: 'dt1',
|
||||
tiles: library.tiles.map(tile => ({
|
||||
direction: tile.direction,
|
||||
height: tile.height,
|
||||
width: tile.width,
|
||||
type: tile.type,
|
||||
style: tile.style,
|
||||
sequence: tile.sequence,
|
||||
materialFlags: tile.materialFlags,
|
||||
subTileFlags: tile.subTileFlags.map(flag => flag.raw),
|
||||
blocks: tile.blocks.map(block => ({
|
||||
x: block.x,
|
||||
y: block.y,
|
||||
gridX: block.gridX,
|
||||
gridY: block.gridY,
|
||||
format: block.format,
|
||||
pixels: [...block.pixels],
|
||||
})),
|
||||
})),
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ds1': {
|
||||
const level = decodeDs1(data)
|
||||
output = {
|
||||
format: 'ds1',
|
||||
version: level.version,
|
||||
width: level.width,
|
||||
height: level.height,
|
||||
act: level.act,
|
||||
wallLayers: level.wallLayers,
|
||||
floorLayers: level.floorLayers,
|
||||
cells: level.cells.map(row => row.map(cell => ({
|
||||
walls: cell.walls.map(wall => ({
|
||||
prop1: wall.prop1, sequence: wall.sequence, style: wall.style, type: wall.type,
|
||||
unknown1: wall.unknown1, unknown2: wall.unknown2, hidden: wall.hidden,
|
||||
})),
|
||||
floors: cell.floors.map(floor => ({
|
||||
prop1: floor.prop1, sequence: floor.sequence, style: floor.style,
|
||||
unknown1: floor.unknown1, unknown2: floor.unknown2, hidden: floor.hidden,
|
||||
})),
|
||||
shadows: cell.shadows.map(shadow => ({
|
||||
prop1: shadow.prop1, sequence: shadow.sequence, style: shadow.style,
|
||||
unknown1: shadow.unknown1, unknown2: shadow.unknown2, hidden: shadow.hidden,
|
||||
})),
|
||||
}))),
|
||||
objects: level.objects.map(object => ({
|
||||
type: object.type, id: object.id, x: object.x, y: object.y, flags: object.flags,
|
||||
})),
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'pl2': {
|
||||
const palette = decodePl2(data)
|
||||
// Same digest and grouping as the reference dumper, so the two can be
|
||||
// compared array for array.
|
||||
const digest = (table: Uint8Array): number => {
|
||||
let hash = 2166136261
|
||||
for (const byte of table) {
|
||||
hash ^= byte
|
||||
hash = Math.imul(hash, 16777619) >>> 0
|
||||
}
|
||||
return hash >>> 0
|
||||
}
|
||||
const digests = (tables: readonly Uint8Array[]): number[] => tables.map(digest)
|
||||
output = {
|
||||
format: 'pl2',
|
||||
size: data.byteLength,
|
||||
base: [...palette.rgb],
|
||||
text: [...palette.textRgb],
|
||||
groups: {
|
||||
lightLevels: digests(palette.lightLevels),
|
||||
inverseColors: digests(palette.inverseColors),
|
||||
selectedUnitShift: digests([palette.selectedUnitShift]),
|
||||
alphaBlend: digests(palette.alphaBlend.flat()),
|
||||
additiveBlend: digests(palette.additiveBlend),
|
||||
multiplyBlend: digests(palette.multiplyBlend),
|
||||
hueVariations: digests(palette.hueVariations),
|
||||
redTones: digests([palette.redTones]),
|
||||
greenTones: digests([palette.greenTones]),
|
||||
blueTones: digests([palette.blueTones]),
|
||||
unknownVariations: digests(palette.unknownVariations),
|
||||
maxComponentBlend: digests(palette.maxComponentBlend),
|
||||
darkenedShift: digests([palette.darkenedShift]),
|
||||
textShifts: digests(palette.textShifts),
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
console.error(`unknown format: ${format}`)
|
||||
process.exit(2)
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(output, null, 2))
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* MPQ inspection CLI (Node-side tooling).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/inspect-mpq.ts <archive> [header|list|hist|extract <name> <out>]
|
||||
*
|
||||
* `header` prints the parsed v1 header, `list` the name list, `hist` the
|
||||
* compression masks the archive actually uses (which decoders are required),
|
||||
* and `extract` decodes one member to disk for external verification.
|
||||
*/
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
|
||||
const [path, command = 'header', ...rest] = process.argv.slice(2)
|
||||
|
||||
if (path === undefined) {
|
||||
console.error('usage: node scripts/inspect-mpq.ts <archive> [header|list|hist|extract <name> <out>]')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const archive = await MpqArchive.open(await fileSource(path))
|
||||
|
||||
switch (command) {
|
||||
case 'header': {
|
||||
const h = archive.header
|
||||
console.log(`archive ${path}`)
|
||||
console.log(`headerSize ${String(h.headerSize)}`)
|
||||
console.log(`archiveSize ${String(h.archiveSize)}`)
|
||||
console.log(`format v${String(h.formatVersion + 1)}`)
|
||||
console.log(`sectorSize ${String(h.sectorSize)}`)
|
||||
console.log(`hashTable @${String(h.hashTableOffset)} (${String(h.hashTableEntries)} entries)`)
|
||||
console.log(`blockTable @${String(h.blockTableOffset)} (${String(h.blockTableEntries)} entries)`)
|
||||
const occupied = archive.files().length
|
||||
console.log(`files ${String(occupied)} occupied block slots`)
|
||||
for (const [flag, count] of archive.flagHistogram()) {
|
||||
console.log(` ${flag.padEnd(12)} ${String(count)}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'list': {
|
||||
const names = await archive.listFiles()
|
||||
console.log(`${String(names.length)} names`)
|
||||
for (const name of names.slice(0, 40)) console.log(` ${name}`)
|
||||
if (names.length > 40) console.log(` … ${String(names.length - 40)} more`)
|
||||
break
|
||||
}
|
||||
case 'hist': {
|
||||
const hist = await archive.compressionHistogram(await archive.nameIndex())
|
||||
const total = [...hist.values()].reduce((a, b) => a + b, 0)
|
||||
console.log(`compression masks over ${String(total)} files`)
|
||||
for (const [mask, count] of [...hist].sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${String(count).padStart(6)} ${mask}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'extract': {
|
||||
const [name, out] = rest
|
||||
if (name === undefined || out === undefined) {
|
||||
console.error('usage: … extract <name> <out>')
|
||||
process.exit(2)
|
||||
}
|
||||
const file = archive.find(name)
|
||||
if (file === undefined) {
|
||||
console.error(`no such file: ${name}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const data = await archive.read(file)
|
||||
await writeFile(out, data)
|
||||
console.log(`wrote ${out} (${String(data.byteLength)} bytes, expected ${String(file.fileSize)})`)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
console.error(`unknown command: ${command}`)
|
||||
process.exit(2)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Minimal `.tbl` writer, shared by the fixture generator and its checker.
|
||||
*
|
||||
* Tooling only: the engine reads TBL files, it never writes them. It lives here so
|
||||
* the fixture generator and the construction check encode a table the same way,
|
||||
* instead of two copies drifting apart.
|
||||
*/
|
||||
|
||||
/** One entry: a string, or null for an unused index. */
|
||||
export type TblEntry = string | null
|
||||
|
||||
/**
|
||||
* Encode a classic `.tbl`: crc, entry count, index table, then
|
||||
* `u16 characterCount` + UTF-16LE strings.
|
||||
*
|
||||
* @param entries - the entries, in index order.
|
||||
* @returns the encoded bytes.
|
||||
*/
|
||||
export function encodeTbl(entries: readonly TblEntry[]): Uint8Array {
|
||||
const headerBytes = 4
|
||||
const indexBytes = entries.length * 2
|
||||
const blocks: { index: number; bytes: Uint8Array }[] = []
|
||||
let cursor = headerBytes + indexBytes
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
if (entry === null) continue
|
||||
// The count is in characters; an astral character is two UTF-16 units.
|
||||
let characters = 0
|
||||
for (const character of entry) characters += character.codePointAt(0)! > 0xffff ? 2 : 1
|
||||
const body = new Uint8Array(characters * 2)
|
||||
const view = new DataView(body.buffer)
|
||||
let units = 0
|
||||
for (const character of entry) {
|
||||
const code = character.codePointAt(0)!
|
||||
if (code > 0xffff) {
|
||||
const adjusted = code - 0x10000
|
||||
view.setUint16(units * 2, 0xd800 + (adjusted >> 10), true)
|
||||
view.setUint16((units + 1) * 2, 0xdc00 + (adjusted & 0x3ff), true)
|
||||
units += 2
|
||||
} else {
|
||||
view.setUint16(units * 2, code, true)
|
||||
units += 1
|
||||
}
|
||||
}
|
||||
const bytes = new Uint8Array([characters & 0xff, (characters >> 8) & 0xff, ...body])
|
||||
blocks.push({ index, bytes })
|
||||
cursor += bytes.byteLength
|
||||
}
|
||||
const out = new Uint8Array(cursor)
|
||||
const view = new DataView(out.buffer)
|
||||
view.setUint16(0, 0x1234, true)
|
||||
view.setUint16(2, entries.length, true)
|
||||
let at = headerBytes + indexBytes
|
||||
for (const block of blocks) {
|
||||
view.setUint16(headerBytes + block.index * 2, at, true)
|
||||
out.set(block.bytes, at)
|
||||
at += block.bytes.byteLength
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
/**
|
||||
* Fixture generator: encode a DC6 file from a known index grid.
|
||||
*
|
||||
* Format work without real assets needs its own evidence, and "my decoder reads
|
||||
* what my encoder wrote" proves nothing on its own. So the fixtures here are
|
||||
* written strictly to the published layout (the Kaitai `dc6.ksy` definition
|
||||
* plus the two independent decoders' agreement on the run alphabet and row
|
||||
* order), and every claim is then checked against an *independent* decoder:
|
||||
* `scripts/verify-dc6.sh` runs `dc6png` over the generated file and compares
|
||||
* the PNG it produces against the grid encoded here.
|
||||
*
|
||||
* Usage: node scripts/make-fixtures.ts <output-directory>
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { encodeTbl } from './lib/tbl-writer.ts'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/** Byte offset of the file header in a DC6. */
|
||||
const FILE_HEADER_SIZE = 24
|
||||
/** Byte offset of a frame header inside its frame. */
|
||||
const FRAME_HEADER_SIZE = 32
|
||||
/** Scanline terminator. */
|
||||
const END_OF_SCANLINE = 0x80
|
||||
/** Longest literal run a single chunk byte can introduce. */
|
||||
const MAX_LITERAL_RUN = 0x7f
|
||||
|
||||
/**
|
||||
* A frame as a top-down grid of palette indices, `0` meaning transparent.
|
||||
*/
|
||||
interface FixtureFrame {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
/** Rows top-down; each row must be `width` long. */
|
||||
readonly rows: readonly (readonly number[])[]
|
||||
readonly offsetX: number
|
||||
readonly offsetY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode one frame's run stream.
|
||||
*
|
||||
* Rows are emitted **bottom-up**, matching how the format stores them: the
|
||||
* first scanline written belongs to the frame's last row.
|
||||
*
|
||||
* @param frame - the frame to encode.
|
||||
* @returns the run stream.
|
||||
*/
|
||||
function encodeFrameStream(frame: FixtureFrame): Uint8Array {
|
||||
const bytes: number[] = []
|
||||
for (let rowIndex = frame.height - 1; rowIndex >= 0; rowIndex -= 1) {
|
||||
const row = frame.rows[rowIndex]!
|
||||
let x = 0
|
||||
while (x < frame.width) {
|
||||
const value = row[x]!
|
||||
let run = 0
|
||||
while (x + run < frame.width && row[x + run] === value && run < MAX_LITERAL_RUN) run += 1
|
||||
if (value === 0) {
|
||||
bytes.push(END_OF_SCANLINE | run)
|
||||
} else {
|
||||
bytes.push(run)
|
||||
for (let i = 0; i < run; i += 1) bytes.push(row[x + i]!)
|
||||
}
|
||||
x += run
|
||||
}
|
||||
bytes.push(END_OF_SCANLINE)
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a DC6 file.
|
||||
*
|
||||
* @param directions - directions to write.
|
||||
* @param framesPerDirection - frames per direction.
|
||||
* @param frameOf - builds the frame at a given position.
|
||||
* @returns the encoded file.
|
||||
*/
|
||||
function encodeDc6(
|
||||
directions: number,
|
||||
framesPerDirection: number,
|
||||
frameOf: (direction: number, frame: number) => FixtureFrame,
|
||||
): Uint8Array {
|
||||
const total = directions * framesPerDirection
|
||||
const encoded: { stream: Uint8Array; frame: FixtureFrame }[] = []
|
||||
for (let index = 0; index < total; index += 1) {
|
||||
const direction = Math.floor(index / framesPerDirection)
|
||||
const frame = index % framesPerDirection
|
||||
const spec = frameOf(direction, frame)
|
||||
encoded.push({ stream: encodeFrameStream(spec), frame: spec })
|
||||
}
|
||||
|
||||
const pointerTableSize = total * 4
|
||||
let offset = FILE_HEADER_SIZE + pointerTableSize
|
||||
const pointers: number[] = []
|
||||
for (const entry of encoded) {
|
||||
pointers.push(offset)
|
||||
// Frame header + run stream + 3-byte terminator.
|
||||
offset += FRAME_HEADER_SIZE + entry.stream.byteLength + 3
|
||||
}
|
||||
|
||||
const out = new Uint8Array(offset)
|
||||
const view = new DataView(out.buffer)
|
||||
view.setInt32(0x00, 6, true) // version
|
||||
view.setUint32(0x04, 0, true) // flags
|
||||
view.setUint32(0x08, 0, true) // encoding
|
||||
view.setUint32(0x0c, 0, true) // termination
|
||||
view.setInt32(0x10, directions, true)
|
||||
view.setInt32(0x14, framesPerDirection, true)
|
||||
pointers.forEach((pointer, index) => { view.setUint32(FILE_HEADER_SIZE + index * 4, pointer, true) })
|
||||
|
||||
encoded.forEach((entry, index) => {
|
||||
const at = pointers[index]!
|
||||
view.setInt32(at + 0x00, 0, true) // flipped
|
||||
view.setInt32(at + 0x04, entry.frame.width, true)
|
||||
view.setInt32(at + 0x08, entry.frame.height, true)
|
||||
view.setInt32(at + 0x0c, entry.frame.offsetX, true)
|
||||
view.setInt32(at + 0x10, entry.frame.offsetY, true)
|
||||
view.setUint32(at + 0x14, 0, true) // unknown
|
||||
view.setInt32(at + 0x18, 0, true) // next block
|
||||
view.setInt32(at + 0x1c, entry.stream.byteLength, true)
|
||||
out.set(entry.stream, at + FRAME_HEADER_SIZE)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A palette whose entries are unambiguous under channel reordering: red and
|
||||
* blue differ for every index, so a byte-swapped read is visible rather than
|
||||
* silent.
|
||||
*
|
||||
* @returns 768 bytes of RGB triples.
|
||||
*/
|
||||
function makePalette(): Uint8Array {
|
||||
const palette = new Uint8Array(768)
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
palette[index * 3] = index
|
||||
palette[index * 3 + 1] = (index * 7) & 0xff
|
||||
palette[index * 3 + 2] = 255 - index
|
||||
}
|
||||
return palette
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame with every interesting run shape: a full literal row, a mixed
|
||||
* literal/transparent row, an all-transparent row, and a two-chunk row.
|
||||
*
|
||||
* @param seed - shifts the index values so frames differ.
|
||||
* @returns the frame.
|
||||
*/
|
||||
function patternFrame(seed: number): FixtureFrame {
|
||||
const width = 8
|
||||
const height = 4
|
||||
const rows: number[][] = []
|
||||
// Top row: two literal runs (3 then 5) — exercises consecutive literal chunks.
|
||||
rows.push([seed + 1, seed + 2, seed + 3, seed + 4, seed + 5, seed + 6, seed + 7, seed + 8])
|
||||
// Second row: literal 3, transparent 2, literal 3.
|
||||
rows.push([seed + 9, seed + 10, seed + 11, 0, 0, seed + 12, seed + 13, seed + 14])
|
||||
// Third row: entirely transparent.
|
||||
rows.push([0, 0, 0, 0, 0, 0, 0, 0])
|
||||
// Bottom row: single transparent pixel then a full literal run.
|
||||
rows.push([0, seed + 15, seed + 16, seed + 17, seed + 18, seed + 19, seed + 20, seed + 21])
|
||||
return { width, height, rows, offsetX: seed, offsetY: -seed }
|
||||
}
|
||||
|
||||
/**
|
||||
* A synthetic walking actor: eight directions by eight frames.
|
||||
*
|
||||
* Diablo II units that are not composite-animated ship as per-direction DC6
|
||||
* sheets, so this is the shape the engine's actor path consumes. Each direction
|
||||
* gets its own filled block and each frame shifts it, so a decoder, an atlas or
|
||||
* a draw order that mixes directions up is visible in a screenshot rather than
|
||||
* silently plausible.
|
||||
*
|
||||
* @param directions - direction count.
|
||||
* @param framesPerDirection - frames per direction.
|
||||
* @param size - frame edge in pixels.
|
||||
* @returns the encoded DC6.
|
||||
*/
|
||||
function encodeActor(directions: number, framesPerDirection: number, size: number): Uint8Array {
|
||||
return encodeDc6(directions, framesPerDirection, (direction, frame) => {
|
||||
const rows: number[][] = []
|
||||
const blockWidth = 16 + direction * 3
|
||||
const inset = (frame * 4) % (size - blockWidth)
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
const row: number[] = []
|
||||
const inBlock = y >= 16 && y < 16 + blockWidth
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
const lit = inBlock && x >= inset && x < inset + blockWidth
|
||||
row.push(lit ? 1 + ((direction * 16 + frame * 2 + (y % 8)) % 250) : 0)
|
||||
}
|
||||
rows.push(row)
|
||||
}
|
||||
return { width: size, height: size, rows, offsetX: 0, offsetY: 0 }
|
||||
})
|
||||
}
|
||||
|
||||
const outDir = process.argv[2]
|
||||
if (outDir === undefined) {
|
||||
console.error('usage: node scripts/make-fixtures.ts <output-directory>')
|
||||
process.exit(2)
|
||||
}
|
||||
await mkdir(outDir, { recursive: true })
|
||||
|
||||
const directions = 2
|
||||
const framesPerDirection = 2
|
||||
const file = encodeDc6(directions, framesPerDirection, (direction, frame) => patternFrame(direction * 4 + frame * 22 + 1))
|
||||
await writeFile(join(outDir, 'fixture.dc6'), file)
|
||||
await writeFile(join(outDir, 'palette.pal'), makePalette())
|
||||
|
||||
/** The grid each frame is expected to decode to, top-down. */
|
||||
const expected = {
|
||||
directions,
|
||||
framesPerDirection,
|
||||
palette: 'palette.pal',
|
||||
frames: Array.from({ length: directions }, (_, direction) =>
|
||||
Array.from({ length: framesPerDirection }, (_, frame) => {
|
||||
const spec = patternFrame(direction * 4 + frame * 22 + 1)
|
||||
return { width: spec.width, height: spec.height, rows: spec.rows }
|
||||
})),
|
||||
}
|
||||
await writeFile(join(outDir, 'expected.json'), JSON.stringify(expected, null, 2))
|
||||
|
||||
// Data tables in the shape Diablo II ships them: tab-separated with a header
|
||||
// row, under the same member path the game uses.
|
||||
const monstats = [
|
||||
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
|
||||
'fallen\tFallen\t12\t3\t24\t36\t220\t80\t8',
|
||||
'zombie\tZombie\t30\t6\t32\t40\t170\t50\t15',
|
||||
'skeleton\tSkeleton\t18\t4\t28\t38\t260\t70\t12',
|
||||
].join('\r\n')
|
||||
const experience = ['Level\tXP', '1\t0', '2\t20', '3\t60', '4\t140', '5\t280'].join('\r\n')
|
||||
await mkdir(join(outDir, 'data', 'global', 'excel'), { recursive: true })
|
||||
await writeFile(join(outDir, 'data', 'global', 'excel', 'monstats.txt'), monstats)
|
||||
await writeFile(join(outDir, 'data', 'global', 'excel', 'experience.txt'), experience)
|
||||
console.log(`wrote ${join(outDir, 'data', 'global', 'excel')}/{monstats,experience}.txt`)
|
||||
|
||||
// Item tables, in the shape Diablo II ships them (a leading unnamed column is
|
||||
// common in the real files, so one is included here to keep that path exercised).
|
||||
const tables: Record<string, string> = {
|
||||
'weapons.txt': [
|
||||
'\tId\tName\tType\tInvWidth\tInvHeight\tDamage\tValue\tLevel\tMaxStack',
|
||||
'\tswd\tShort Sword\tweap\t1\t3\t5\t30\t1\t1',
|
||||
'\taxe\tHand Axe\tweap\t2\t3\t8\t60\t3\t1',
|
||||
'\tgsw\tGreat Sword\tweap\t2\t4\t18\t200\t10\t1',
|
||||
].join('\r\n'),
|
||||
'armor.txt': [
|
||||
'Id\tName\tType\tInvWidth\tInvHeight\tDefense\tValue\tLevel\tMaxStack',
|
||||
'buc\tBuckler\tarmo\t2\t2\t4\t25\t1\t1',
|
||||
'cap\tCap\tarmo\t2\t2\t6\t40\t2\t1',
|
||||
'plt\tPlate Mail\tarmo\t2\t3\t30\t400\t12\t1',
|
||||
].join('\r\n'),
|
||||
'misc.txt': [
|
||||
'Id\tName\tType\tInvWidth\tInvHeight\tMaxStack\tValue',
|
||||
'hp1\tMinor Healing Potion\tmisc\t1\t1\t5\t20',
|
||||
'mp1\tMinor Mana Potion\tmisc\t1\t1\t5\t25',
|
||||
'key\tKey\tmisc\t1\t1\t12\t10',
|
||||
].join('\r\n'),
|
||||
'magicprefix.txt': [
|
||||
'Id\tName\tLevel\titype1\titype2\tmod1code\tmod1min\tmod1max\tmod2code\tmod2min\tmod2max',
|
||||
'cruel\tCruel\t12\tweap\t\tmaxdamage\t30\t40\t\t\t',
|
||||
'sturdy\tSturdy\t3\tarmo\t\tdefense\t5\t9\t\t\t',
|
||||
'fine\tFine\t5\tweap\tarmo\tmaxdamage\t2\t4\tdefense\t1\t3',
|
||||
].join('\r\n'),
|
||||
'magicsuffix.txt': [
|
||||
'Id\tName\tLevel\titype1\tmod1code\tmod1min\tmod1max',
|
||||
'of_might\tof Might\t5\tweap\tstrength\t2\t5',
|
||||
'of_the_fox\tof the Fox\t3\t\tdexterity\t1\t3',
|
||||
'of_health\tof Health\t4\t\tmaxhp\t10\t20',
|
||||
].join('\r\n'),
|
||||
}
|
||||
for (const [name, body] of Object.entries(tables)) {
|
||||
await writeFile(join(outDir, 'data', 'global', 'excel', name), body)
|
||||
}
|
||||
console.log(`wrote ${String(Object.keys(tables).length)} item tables under data/global/excel/`)
|
||||
|
||||
// Skills, NPCs and quests, plus the string table their numeric name cells point
|
||||
// into — so the `.tbl` decoder is exercised by the game path, not only by its
|
||||
// own test.
|
||||
const m4Tables: Record<string, string> = {
|
||||
'skills.txt': [
|
||||
'Id\tName\tManaCost\tCooldownTicks\tRange\tSpeed\tMinDam\tMaxDam\tPerLevel\tRadius',
|
||||
'attack\tBasic Attack\t0\t10\t48\t0\t4\t6\t1\t20',
|
||||
'firebolt\t1\t6\t20\t260\t320\t7\t10\t3\t20',
|
||||
'frostnova\t2\t14\t50\t70\t0\t12\t16\t4\t90',
|
||||
].join('\r\n'),
|
||||
'npcs.txt': [
|
||||
'Id\tName\tQuest\tOffer\tProgress\tDone',
|
||||
'cain\t3\tden\t6\t7\t8',
|
||||
].join('\r\n'),
|
||||
'quests.txt': [
|
||||
'Id\tName\tDescription\tMonsterId\tKillCount\tRewardXP\tRewardGold',
|
||||
// A wildcard objective: the fixture has three monster types, so a quest that
|
||||
// names one of them could not be completed deterministically in a short run.
|
||||
'den\t4\t5\t*\t3\t60\t120',
|
||||
].join('\r\n'),
|
||||
}
|
||||
for (const [name, body] of Object.entries(m4Tables)) {
|
||||
await writeFile(join(outDir, 'data', 'global', 'excel', name), body)
|
||||
}
|
||||
// Index order matters: the tables above address these by number.
|
||||
const stringTable = encodeTbl([
|
||||
'', 'Fire Bolt', 'Frost Nova', 'Deckard Cain',
|
||||
'Kill the Fallen', 'Slay five of the fallen in the moor',
|
||||
'The fallen plague us.|Will you help?',
|
||||
'Still working?|The fallen remain.',
|
||||
'Well done, hero.',
|
||||
])
|
||||
await writeFile(join(outDir, 'data', 'local', 'string.tbl'), stringTable).catch(async () => {
|
||||
await mkdir(join(outDir, 'data', 'local'), { recursive: true })
|
||||
await writeFile(join(outDir, 'data', 'local', 'string.tbl'), stringTable)
|
||||
})
|
||||
console.log(`wrote 3 M4 tables and data/local/string.tbl (${String(stringTable.byteLength)} bytes)`)
|
||||
|
||||
const actor = encodeActor(8, 8, 64)
|
||||
await writeFile(join(outDir, 'actor.dc6'), actor)
|
||||
console.log(`wrote ${join(outDir, 'actor.dc6')} (${String(actor.byteLength)} bytes, 8 directions x 8 frames of 64x64)`)
|
||||
console.log(`wrote ${join(outDir, 'fixture.dc6')} (${String(file.byteLength)} bytes)`)
|
||||
console.log(`wrote ${join(outDir, 'palette.pal')} and expected.json`)
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
/**
|
||||
* Map fixture generator: encode a DT1 tile library and a DS1 map that
|
||||
* references it.
|
||||
*
|
||||
* Written strictly to the published layouts so that an *independent* decoder
|
||||
* (the Go reference packages) can read them: if it agrees with this project's
|
||||
* TypeScript decoder field by field, the format understanding is confirmed, and
|
||||
* if it disagrees the fixtures say exactly which field drifted.
|
||||
*
|
||||
* Usage: node scripts/make-map-fixtures.ts <output-directory>
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/** Bytes per DT1 tile record. */
|
||||
const DT1_TILE_RECORD = 96
|
||||
/** Bytes per DT1 block header. */
|
||||
const DT1_BLOCK_HEADER = 20
|
||||
/** Tile edge in pixels: a Diablo II tile is 5x5 sub-tiles of 32x32. */
|
||||
const TILE = 160
|
||||
/** Bytes of fixed preamble before the DT1 tile count. */
|
||||
const DT1_HEADER_PREFIX = 8 + 260
|
||||
|
||||
/**
|
||||
* Encode an RLE block body: `(skip, count)` pairs then literals, `(0, 0)`
|
||||
* ending a row.
|
||||
*
|
||||
* @param width - tile width.
|
||||
* @param height - tile height.
|
||||
* @param base - first palette index used.
|
||||
* @returns the run stream.
|
||||
*/
|
||||
function encodeBlockRle(width: number, height: number, base: number): Uint8Array {
|
||||
const bytes: number[] = []
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const start = y % 4
|
||||
const count = width - start - (y % 3)
|
||||
bytes.push(start, count)
|
||||
for (let i = 0; i < count; i += 1) bytes.push(((base + y * 7 + i) & 0xff) || 1)
|
||||
bytes.push(0, 0)
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an isometric block body: exactly 256 bytes for the fixed diamond.
|
||||
*
|
||||
* @param base - first palette index used.
|
||||
* @returns the block bytes.
|
||||
*/
|
||||
function encodeBlockIsometric(base: number): Uint8Array {
|
||||
const bytes = new Uint8Array(256)
|
||||
for (let i = 0; i < bytes.byteLength; i += 1) bytes[i] = ((base + i) & 0xff) || 1
|
||||
return bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DT1 file with one RLE tile and one isometric tile.
|
||||
*
|
||||
* @returns the encoded library.
|
||||
*/
|
||||
function encodeDt1(): Uint8Array {
|
||||
const tiles = [
|
||||
{ rle: true, type: 0, style: 1, sequence: 0, material: 0x0020, width: TILE, height: -TILE },
|
||||
{ rle: true, type: 0, style: 1, sequence: 1, material: 0x0008, width: TILE, height: -TILE },
|
||||
{ rle: false, type: 0, style: 2, sequence: 0, material: 0x0040, width: TILE, height: -TILE },
|
||||
{ rle: false, type: 0, style: 2, sequence: 1, material: 0x0002, width: TILE, height: -TILE },
|
||||
]
|
||||
const tileDataStart = DT1_HEADER_PREFIX + 8
|
||||
// Wall tiles store a *negative* height (they grow upward from their anchor), so
|
||||
// the encoder has to work from the magnitude.
|
||||
const bodies = tiles.map((tile, index) => (tile.rle
|
||||
? encodeBlockRle(tile.width, Math.abs(tile.height), 9 + index * 40)
|
||||
: encodeBlockIsometric(70 + index * 40)))
|
||||
|
||||
let bodyOffset = tileDataStart + tiles.length * DT1_TILE_RECORD
|
||||
const blockHeaderStart = bodyOffset
|
||||
bodyOffset += tiles.length * DT1_BLOCK_HEADER
|
||||
const blockDataOffsets: number[] = []
|
||||
for (const body of bodies) {
|
||||
blockDataOffsets.push(bodyOffset)
|
||||
bodyOffset += body.byteLength
|
||||
}
|
||||
|
||||
const out = new Uint8Array(bodyOffset)
|
||||
const view = new DataView(out.buffer)
|
||||
view.setInt32(0, 7, true)
|
||||
view.setInt32(4, 6, true)
|
||||
view.setInt32(DT1_HEADER_PREFIX, tiles.length, true)
|
||||
view.setInt32(DT1_HEADER_PREFIX + 4, tileDataStart, true)
|
||||
|
||||
tiles.forEach((tile, index) => {
|
||||
const at = tileDataStart + index * DT1_TILE_RECORD
|
||||
view.setInt32(at + 0, 0, true) // direction
|
||||
view.setInt16(at + 4, 0, true) // roof height
|
||||
view.setUint16(at + 6, tile.material, true) // material flags
|
||||
view.setInt32(at + 8, tile.height, true)
|
||||
view.setInt32(at + 12, tile.width, true)
|
||||
view.setInt32(at + 20, tile.type, true)
|
||||
view.setInt32(at + 24, tile.style, true)
|
||||
view.setInt32(at + 28, tile.sequence, true)
|
||||
view.setInt32(at + 32, 0, true) // rarity frame index
|
||||
// 25 sub-tile flags: a deterministic blocking pattern.
|
||||
for (let sub = 0; sub < 25; sub += 1) out[at + 40 + sub] = sub % 3 === 0 ? 0x01 : sub % 5 === 0 ? 0x23 : 0
|
||||
view.setInt32(at + 72, blockHeaderStart + index * DT1_BLOCK_HEADER, true)
|
||||
view.setInt32(at + 76, DT1_BLOCK_HEADER, true)
|
||||
view.setInt32(at + 80, 1, true) // one block
|
||||
const headerAt = blockHeaderStart + index * DT1_BLOCK_HEADER
|
||||
// Block header (20 bytes): X, Y, 2 unused, GridX, GridY, Format, Length,
|
||||
// 2 unused, FileOffset — and the offset is relative to this header.
|
||||
view.setInt16(headerAt + 0, 0, true) // block x
|
||||
view.setInt16(headerAt + 2, 0, true) // block y
|
||||
out[headerAt + 6] = 0 // grid x
|
||||
out[headerAt + 7] = 0 // grid y
|
||||
view.setInt16(headerAt + 8, tile.rle ? 0 : 1, true)
|
||||
view.setInt32(headerAt + 10, bodies[index]!.byteLength, true)
|
||||
// The offset is relative to this tile's own block header pointer (each tile
|
||||
// has its own), not to the start of the whole block header section.
|
||||
view.setInt32(headerAt + 16, blockDataOffsets[index]! - headerAt, true)
|
||||
out.set(bodies[index]!, blockDataOffsets[index]!)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DS1 map referencing the fixture tiles.
|
||||
*
|
||||
* Uses version 16 so the file exercises act, wall *and* floor layer counts, the
|
||||
* interleaved wall/orientation streams and the object list — and, unlike
|
||||
* versions 9..13, carries no unknown byte block.
|
||||
*
|
||||
* @returns the encoded map.
|
||||
*/
|
||||
function encodeDs1(): Uint8Array {
|
||||
const version = 16
|
||||
const width = 3
|
||||
const height = 3
|
||||
const wallLayers = 1
|
||||
const floorLayers = 1
|
||||
const layerOrder: { kind: 'wall' | 'orientation' | 'floor' | 'shadow' }[] = [
|
||||
{ kind: 'wall' }, { kind: 'orientation' }, { kind: 'floor' }, { kind: 'shadow' },
|
||||
]
|
||||
const cellCount = width * height
|
||||
const objects = [
|
||||
{ type: 2, id: 1, x: 1, y: 1, flags: 0 },
|
||||
{ type: 5, id: 0, x: 2, y: 0, flags: 0x8 },
|
||||
]
|
||||
const total = 4 * 3 // version, width-1, height-1
|
||||
+ 4 // act
|
||||
+ 4 // substitution type
|
||||
+ 4 // embedded file count
|
||||
+ 4 + 4 // wall layers, floor layers
|
||||
+ layerOrder.length * cellCount * 4
|
||||
+ 4 + objects.length * 20
|
||||
+ 4 // NPC count (version 14+ carries NPC paths after the objects)
|
||||
const out = new Uint8Array(total)
|
||||
const view = new DataView(out.buffer)
|
||||
let at = 0
|
||||
view.setInt32(at, version, true); at += 4
|
||||
view.setInt32(at, width - 1, true); at += 4
|
||||
view.setInt32(at, height - 1, true); at += 4
|
||||
view.setInt32(at, 2, true); at += 4 // act
|
||||
view.setInt32(at, 0, true); at += 4 // substitution type
|
||||
view.setInt32(at, 0, true); at += 4 // embedded file count
|
||||
view.setInt32(at, wallLayers, true); at += 4
|
||||
view.setInt32(at, floorLayers, true); at += 4
|
||||
|
||||
for (const layer of layerOrder) {
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const seed = (y * width + x) & 0xff
|
||||
let bits: number
|
||||
switch (layer.kind) {
|
||||
case 'wall':
|
||||
// Style 2 holds the two wall tiles; sequence picks between them.
|
||||
bits = (seed | (((x + y) % 2) << 8) | (2 << 20)) >>> 0
|
||||
break
|
||||
case 'orientation':
|
||||
bits = 0
|
||||
break
|
||||
case 'floor':
|
||||
// Style 1 holds the two floor tiles.
|
||||
bits = ((seed + 1) | (((x * 3 + y) % 2) << 8) | (1 << 20)) >>> 0
|
||||
break
|
||||
case 'shadow':
|
||||
bits = (seed % 8) >>> 0
|
||||
break
|
||||
}
|
||||
view.setUint32(at, bits >>> 0, true); at += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view.setInt32(at, objects.length, true); at += 4
|
||||
for (const object of objects) {
|
||||
view.setInt32(at, object.type, true); at += 4
|
||||
view.setInt32(at, object.id, true); at += 4
|
||||
view.setInt32(at, object.x, true); at += 4
|
||||
view.setInt32(at, object.y, true); at += 4
|
||||
view.setInt32(at, object.flags, true); at += 4
|
||||
}
|
||||
// No NPC paths in the fixture: the section's count is zero, which is what
|
||||
// version 14+ readers look for after the objects.
|
||||
view.setInt32(at, 0, true); at += 4
|
||||
return out
|
||||
}
|
||||
|
||||
const outDir = process.argv[2]
|
||||
if (outDir === undefined) {
|
||||
console.error('usage: node scripts/make-map-fixtures.ts <output-directory>')
|
||||
process.exit(2)
|
||||
}
|
||||
await mkdir(outDir, { recursive: true })
|
||||
const dt1 = encodeDt1()
|
||||
const ds1 = encodeDs1()
|
||||
await writeFile(join(outDir, 'fixture.dt1'), dt1)
|
||||
await writeFile(join(outDir, 'fixture.ds1'), ds1)
|
||||
|
||||
/**
|
||||
* The pixel grid each fixture tile is expected to decode to, computed from the
|
||||
* same parameters the encoder used. Independent of the decoder under test —
|
||||
* which is the point: it turns "the decoder did not throw" into "the decoder put
|
||||
* every pixel where the encoder put it".
|
||||
*/
|
||||
const tileWidth = TILE
|
||||
const tileHeight = TILE
|
||||
const expectedTiles: { format: number; base: number; pixels: number[] }[] = []
|
||||
for (const [index, rle] of [true, true, false, false].entries()) {
|
||||
const base = rle ? 9 + index * 40 : 70 + index * 40
|
||||
const pixels = new Array<number>(tileWidth * tileHeight).fill(0)
|
||||
if (rle) {
|
||||
for (let y = 0; y < tileHeight; y += 1) {
|
||||
const start = y % 4
|
||||
const count = tileWidth - start - (y % 3)
|
||||
for (let i = 0; i < count; i += 1) pixels[y * tileWidth + start + i] = ((base + y * 7 + i) & 0xff) || 1
|
||||
}
|
||||
} else {
|
||||
const jump = [14, 12, 10, 8, 6, 4, 2, 0, 2, 4, 6, 8, 10, 12, 14]
|
||||
const run = [4, 8, 12, 16, 20, 24, 28, 32, 28, 24, 20, 16, 12, 8, 4]
|
||||
let at = 0
|
||||
for (let row = 0; row < jump.length; row += 1) {
|
||||
for (let i = 0; i < run[row]!; i += 1) {
|
||||
pixels[row * tileWidth + jump[row]! + i] = ((base + at) & 0xff) || 1
|
||||
at += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
expectedTiles.push({ format: rle ? 0 : 1, base, pixels })
|
||||
}
|
||||
await writeFile(join(outDir, 'expected-dt1.json'), JSON.stringify({ width: tileWidth, height: tileHeight, tiles: expectedTiles }))
|
||||
|
||||
console.log(`wrote ${join(outDir, 'fixture.dt1')} (${String(dt1.byteLength)} bytes)`)
|
||||
console.log(`wrote ${join(outDir, 'fixture.ds1')} (${String(ds1.byteLength)} bytes)`)
|
||||
console.log(`wrote ${join(outDir, 'expected-dt1.json')}`)
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* MPQ fixture packer: write a real MPQ v1 archive containing the generated
|
||||
* fixtures.
|
||||
*
|
||||
* This exists so the *whole* user path can be exercised before any Blizzard data
|
||||
* is available: drop an archive in, have the engine open it, list it, decode map
|
||||
* members from it, and render a map. It is also the only way to test the
|
||||
* reader's stored-member path and its sector logic against something whose
|
||||
* contents are known byte for byte.
|
||||
*
|
||||
* The archive is written to the v1 rules the reader implements: both tables
|
||||
* encrypted with their fixed keys, members stored either raw or as zlib sectors
|
||||
* with a compression-mask byte per sector, and — deliberately — a mix of both so
|
||||
* one archive covers both code paths.
|
||||
*
|
||||
* This is tooling, not engine code: it is not shipped and not imported by the app.
|
||||
*
|
||||
* Usage: node scripts/make-mpq-fixture.ts <fixture-directory> [output.mpq]
|
||||
*/
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
BLOCK_TABLE_KEY, HASH_NAME_A, HASH_NAME_B, HASH_TABLE_KEY, HASH_TABLE_OFFSET,
|
||||
encryptBlock, hashString, normalizeName,
|
||||
} from '../src/mpq/crypt.ts'
|
||||
|
||||
/** v1 header size in bytes. */
|
||||
const HEADER_SIZE = 32
|
||||
/** Archive signature ('MPQ\x1A'). */
|
||||
const MAGIC = 0x1a51504d
|
||||
/** Sector size shift: sectors are `512 << shift` bytes. */
|
||||
const SECTOR_SHIFT = 3
|
||||
/** Resulting sector size. */
|
||||
const SECTOR_SIZE = 512 << SECTOR_SHIFT
|
||||
/** Flag: entry exists. */
|
||||
const FILE_EXISTS = 0x80000000
|
||||
/** Flag: member uses the multi-codec compression path (sector table present). */
|
||||
const FILE_COMPRESS = 0x00000200
|
||||
/**
|
||||
* Flag: member is stored as one unit, with no sector offset table.
|
||||
*
|
||||
* This is not optional for stored members: a reader that sees neither this flag
|
||||
* nor `FILE_COMPRESS` takes the sector path and reads a sector table out of the
|
||||
* file's own data. Omitting it produces an archive only a lenient reader can
|
||||
* open — which is exactly how this was found, by an independent reader failing
|
||||
* on the stored members while the compressed ones read fine.
|
||||
*/
|
||||
const FILE_SINGLE_UNIT = 0x01000000
|
||||
/** Compression mask byte for zlib. */
|
||||
const MASK_ZLIB = 0x02
|
||||
|
||||
/** One member to pack. */
|
||||
interface Member {
|
||||
/** Archive name (either separator). */
|
||||
readonly name: string
|
||||
/** Contents. */
|
||||
readonly data: Uint8Array
|
||||
/** Whether to store it through the compressed sector path. */
|
||||
readonly compress: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Deflate one buffer with the platform compressor.
|
||||
*
|
||||
* @param data - input bytes.
|
||||
* @returns the zlib stream.
|
||||
*/
|
||||
async function deflate(data: Uint8Array): Promise<Uint8Array> {
|
||||
const stream = new Blob([data as BlobPart]).stream().pipeThrough(new CompressionStream('deflate'))
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer())
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode one member's stored bytes and flags.
|
||||
*
|
||||
* @param member - the member to encode.
|
||||
* @returns the stored bytes, stored size, flags and (when compressed) layout.
|
||||
*/
|
||||
async function encodeMember(member: Member): Promise<{ stored: Uint8Array; flags: number }> {
|
||||
if (!member.compress) {
|
||||
// Stored members have no sector offset table at all, which is what
|
||||
// SINGLE_UNIT declares.
|
||||
return { stored: member.data, flags: (FILE_EXISTS | FILE_SINGLE_UNIT) >>> 0 }
|
||||
}
|
||||
const sectorCount = Math.max(1, Math.ceil(member.data.byteLength / SECTOR_SIZE))
|
||||
const tableSize = (sectorCount + 1) * 4
|
||||
const payloads: Uint8Array[] = []
|
||||
for (let sector = 0; sector < sectorCount; sector += 1) {
|
||||
const from = sector * SECTOR_SIZE
|
||||
const raw = member.data.subarray(from, Math.min(from + SECTOR_SIZE, member.data.byteLength))
|
||||
const deflated = await deflate(raw)
|
||||
// A sector that did not shrink is stored raw, which is what the reader
|
||||
// detects by comparing the stored size to the expected size.
|
||||
payloads.push(deflated.byteLength < raw.byteLength ? concat([new Uint8Array([MASK_ZLIB]), deflated]) : raw)
|
||||
}
|
||||
const total = tableSize + payloads.reduce((sum, payload) => sum + payload.byteLength, 0)
|
||||
const stored = new Uint8Array(total)
|
||||
const view = new DataView(stored.buffer)
|
||||
let at = tableSize
|
||||
payloads.forEach((payload, sector) => {
|
||||
// Offsets are relative to the block start and include the table itself,
|
||||
// which is why the first entry equals the table size.
|
||||
view.setUint32(sector * 4, at, true)
|
||||
stored.set(payload, at)
|
||||
at += payload.byteLength
|
||||
})
|
||||
view.setUint32(sectorCount * 4, total, true)
|
||||
return { stored, flags: (FILE_EXISTS | FILE_COMPRESS) >>> 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate buffers.
|
||||
*
|
||||
* @param parts - the buffers.
|
||||
* @returns their concatenation.
|
||||
*/
|
||||
function concat(parts: readonly Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((sum, part) => sum + part.byteLength, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let at = 0
|
||||
for (const part of parts) { out.set(part, at); at += part.byteLength }
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an MPQ v1 archive.
|
||||
*
|
||||
* @param members - members to pack.
|
||||
* @returns the archive bytes.
|
||||
*/
|
||||
async function writeMpq(members: readonly Member[]): Promise<Uint8Array> {
|
||||
const encoded = await Promise.all(members.map(async member => ({
|
||||
name: normalizeName(member.name),
|
||||
...await encodeMember(member),
|
||||
fileSize: member.data.byteLength,
|
||||
})))
|
||||
|
||||
// Lay out the file data first, then the two tables.
|
||||
let cursor = HEADER_SIZE
|
||||
const placed = encoded.map(entry => {
|
||||
const at = cursor
|
||||
cursor += entry.stored.byteLength
|
||||
return { ...entry, filePos: at }
|
||||
})
|
||||
const hashTableSize = Math.max(4, nextPowerOfTwo(placed.length * 2))
|
||||
const hashTablePos = cursor
|
||||
cursor += hashTableSize * 16
|
||||
const blockTablePos = cursor
|
||||
cursor += placed.length * 16
|
||||
const archiveSize = cursor
|
||||
|
||||
const out = new Uint8Array(archiveSize)
|
||||
const view = new DataView(out.buffer)
|
||||
placed.forEach(entry => { out.set(entry.stored, entry.filePos) })
|
||||
|
||||
// Block table: position, stored size, file size, flags.
|
||||
placed.forEach((entry, index) => {
|
||||
const at = blockTablePos + index * 16
|
||||
view.setUint32(at, entry.filePos, true)
|
||||
view.setUint32(at + 4, entry.stored.byteLength, true)
|
||||
view.setUint32(at + 8, entry.fileSize, true)
|
||||
view.setUint32(at + 12, entry.flags, true)
|
||||
})
|
||||
|
||||
// Hash table: one entry per member at its probe position, free slots marked
|
||||
// with the 0xFFFFFFFF sentinel the reader stops on.
|
||||
const hashTable = new Uint8Array(hashTableSize * 16)
|
||||
const hashView = new DataView(hashTable.buffer)
|
||||
for (let slot = 0; slot < hashTableSize; slot += 1) {
|
||||
hashView.setUint32(slot * 16 + 12, 0xffffffff, true)
|
||||
}
|
||||
placed.forEach((entry, index) => {
|
||||
const hashA = hashString(entry.name, HASH_NAME_A)
|
||||
const hashB = hashString(entry.name, HASH_NAME_B)
|
||||
let slot = hashString(entry.name, HASH_TABLE_OFFSET) % hashTableSize
|
||||
// Linear probing, exactly as the reader walks it.
|
||||
while (hashView.getUint32(slot * 16 + 12, true) !== 0xffffffff) slot = (slot + 1) % hashTableSize
|
||||
const at = slot * 16
|
||||
hashView.setUint32(at, hashA, true)
|
||||
hashView.setUint32(at + 4, hashB, true)
|
||||
hashView.setUint16(at + 8, 0, true) // locale
|
||||
hashView.setUint16(at + 10, 0, true) // platform
|
||||
hashView.setUint32(at + 12, index, true)
|
||||
})
|
||||
encryptBlock(hashTable, HASH_TABLE_KEY)
|
||||
|
||||
const blockTable = new Uint8Array(out.subarray(blockTablePos, blockTablePos + placed.length * 16))
|
||||
encryptBlock(blockTable, BLOCK_TABLE_KEY)
|
||||
|
||||
out.set(hashTable, hashTablePos)
|
||||
out.set(blockTable, blockTablePos)
|
||||
view.setUint32(0x00, MAGIC, true)
|
||||
view.setUint32(0x04, HEADER_SIZE, true)
|
||||
view.setUint32(0x08, archiveSize, true)
|
||||
view.setUint16(0x0c, 0, true) // format version 1
|
||||
view.setUint16(0x0e, SECTOR_SHIFT, true)
|
||||
view.setUint32(0x10, hashTablePos, true)
|
||||
view.setUint32(0x14, blockTablePos, true)
|
||||
view.setUint32(0x18, hashTableSize, true)
|
||||
view.setUint32(0x1c, placed.length, true)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a value up to a power of two.
|
||||
*
|
||||
* @param value - the value.
|
||||
* @returns the next power of two.
|
||||
*/
|
||||
function nextPowerOfTwo(value: number): number {
|
||||
let result = 1
|
||||
while (result < value) result *= 2
|
||||
return result
|
||||
}
|
||||
|
||||
const dir = process.argv[2]
|
||||
if (dir === undefined) {
|
||||
console.error('usage: node scripts/make-mpq-fixture.ts <fixture-directory> [output.mpq]')
|
||||
process.exit(2)
|
||||
}
|
||||
const output = process.argv[3] ?? join(dir, 'fixture.mpq')
|
||||
|
||||
// The map members are the point; one of them is stored raw so the archive
|
||||
// exercises both the stored and the compressed read paths.
|
||||
const names = [
|
||||
'fixture.ds1', 'fixture.dt1', 'palette.pal', 'fixture.dc6', 'actor.dc6',
|
||||
'data/global/excel/monstats.txt', 'data/global/excel/experience.txt',
|
||||
'data/global/excel/weapons.txt', 'data/global/excel/armor.txt', 'data/global/excel/misc.txt',
|
||||
'data/global/excel/magicprefix.txt', 'data/global/excel/magicsuffix.txt',
|
||||
'data/global/excel/skills.txt', 'data/global/excel/npcs.txt', 'data/global/excel/quests.txt',
|
||||
'data/local/string.tbl',
|
||||
]
|
||||
const members: Member[] = []
|
||||
for (const name of names) {
|
||||
const data = new Uint8Array(await readFile(join(dir, name)))
|
||||
members.push({ name, data, compress: name !== 'palette.pal' })
|
||||
}
|
||||
members.push({
|
||||
name: '(listfile)',
|
||||
data: new TextEncoder().encode(`${names.join('\r\n')}\r\n`),
|
||||
compress: false,
|
||||
})
|
||||
|
||||
const archive = await writeMpq(members)
|
||||
await writeFile(output, archive)
|
||||
console.log(`wrote ${output} (${String(archive.byteLength)} bytes, ${String(members.length)} members)`)
|
||||
console.log(` stored : ${members.filter(member => !member.compress).map(member => member.name).join(', ')}`)
|
||||
console.log(` zlib : ${members.filter(member => member.compress).map(member => member.name).join(', ')}`)
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* PL2 fixture generator.
|
||||
*
|
||||
* PL2 is purely positional — no header, no counts, no directory — so the only
|
||||
* way to test it without a real file is to write the bytes in the documented
|
||||
* order and let an independent decoder read them back. Every table gets a
|
||||
* pattern that depends on its *global index*, so a table read out of order, or a
|
||||
* group with the wrong count, changes the digests the parity harness compares
|
||||
* rather than passing silently.
|
||||
*
|
||||
* Usage: node scripts/make-pl2-fixture.ts <output-directory>
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { pl2ExpectedSize } from '../src/formats/pl2.ts'
|
||||
|
||||
/** Palette entries per transform table. */
|
||||
const PALETTE_COLORS = 256
|
||||
/** Groups in file order: name → table count. */
|
||||
const GROUPS: readonly (readonly [string, number])[] = [
|
||||
['lightLevels', 32],
|
||||
['inverseColors', 16],
|
||||
['selectedUnitShift', 1],
|
||||
['alphaBlend0', 256],
|
||||
['alphaBlend1', 256],
|
||||
['alphaBlend2', 256],
|
||||
['additiveBlend', 256],
|
||||
['multiplyBlend', 256],
|
||||
['hueVariations', 111],
|
||||
['redTones', 1],
|
||||
['greenTones', 1],
|
||||
['blueTones', 1],
|
||||
['unknownVariations', 14],
|
||||
['maxComponentBlend', 256],
|
||||
['darkenedShift', 1],
|
||||
['textShifts', 13],
|
||||
]
|
||||
|
||||
const outDir = process.argv[2]
|
||||
if (outDir === undefined) {
|
||||
console.error('usage: node scripts/make-pl2-fixture.ts <output-directory>')
|
||||
process.exit(2)
|
||||
}
|
||||
await mkdir(outDir, { recursive: true })
|
||||
|
||||
const size = pl2ExpectedSize()
|
||||
const file = new Uint8Array(size)
|
||||
let cursor = 0
|
||||
|
||||
// Base palette: r, g, b plus an unused byte that is deliberately not zero, so a
|
||||
// decoder that reads it as colour data (or as alpha) is caught.
|
||||
for (let index = 0; index < PALETTE_COLORS; index += 1) {
|
||||
file[cursor++] = index
|
||||
file[cursor++] = (index * 3) & 0xff
|
||||
file[cursor++] = (index * 7) & 0xff
|
||||
file[cursor++] = 0xab
|
||||
}
|
||||
|
||||
// Transform tables: table `k` maps entry `i` to `(i + k) mod 256`, which makes
|
||||
// every table distinct and its position observable.
|
||||
let table = 0
|
||||
for (const [, count] of GROUPS) {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
for (let entry = 0; entry < PALETTE_COLORS; entry += 1) {
|
||||
file[cursor++] = (entry + table) & 0xff
|
||||
}
|
||||
table += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Text palette (13 entries, three bytes each).
|
||||
for (let index = 0; index < 13; index += 1) {
|
||||
file[cursor++] = (10 + index) & 0xff
|
||||
file[cursor++] = (20 + index) & 0xff
|
||||
file[cursor++] = (30 + index) & 0xff
|
||||
}
|
||||
|
||||
if (cursor !== size) {
|
||||
throw new Error(`fixture wrote ${String(cursor)} bytes, expected ${String(size)}`)
|
||||
}
|
||||
|
||||
const path = join(outDir, 'fixture.pl2')
|
||||
await writeFile(path, file)
|
||||
await writeFile(join(outDir, 'pl2-groups.json'), JSON.stringify(GROUPS, null, 2))
|
||||
console.log(`wrote ${path} (${String(file.byteLength)} bytes, ${String(table)} transform tables + 13 text shifts)`)
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* A tiny WebSocket relay, for testing the networked session over a real socket.
|
||||
*
|
||||
* The game needs a way for two peers to reach each other, and nothing more: the
|
||||
* relay forwards every binary message to the other connected clients and never
|
||||
* inspects it. That is not a design statement about production hosting — it is
|
||||
* the smallest server that lets the *real* transport code path (real TCP, real
|
||||
* WebSocket framing, real asynchrony) be tested without a browser and without a
|
||||
* dependency.
|
||||
*
|
||||
* Written against Node's `http` and `crypto` only, including the RFC 6455
|
||||
* handshake and framing, because pulling in a WebSocket server library to test a
|
||||
* zero-dependency client would be the wrong trade.
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
||||
import type { Socket } from 'node:net'
|
||||
|
||||
/** RFC 6455 handshake magic value. */
|
||||
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||
|
||||
/** Opcodes used here. */
|
||||
const OP_TEXT = 0x1
|
||||
const OP_BINARY = 0x2
|
||||
const OP_CLOSE = 0x8
|
||||
const OP_PING = 0x9
|
||||
const OP_PONG = 0xa
|
||||
|
||||
/** A running relay. */
|
||||
export interface Relay {
|
||||
/** `ws://` URL clients should connect to. */
|
||||
readonly url: string
|
||||
/** Number of currently connected clients. */
|
||||
readonly clients: number
|
||||
/** Total connections accepted since start. */
|
||||
readonly accepted: number
|
||||
/** Total binary messages forwarded. */
|
||||
readonly forwarded: number
|
||||
/** Stop listening and close every client. */
|
||||
readonly close: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode one unmasked server frame.
|
||||
*
|
||||
* @param opcode - the frame opcode.
|
||||
* @param payload - the payload bytes.
|
||||
* @returns the frame.
|
||||
*/
|
||||
function encodeFrame(opcode: number, payload: Uint8Array): Uint8Array {
|
||||
const length = payload.byteLength
|
||||
const headerBytes = length < 126 ? 2 : length < 65536 ? 4 : 10
|
||||
const out = new Uint8Array(headerBytes + length)
|
||||
out[0] = 0x80 | opcode
|
||||
if (length < 126) {
|
||||
out[1] = length
|
||||
} else if (length < 65536) {
|
||||
out[1] = 126
|
||||
out[2] = (length >>> 8) & 0xff
|
||||
out[3] = length & 0xff
|
||||
} else {
|
||||
out[1] = 127
|
||||
const view = new DataView(out.buffer)
|
||||
view.setUint32(2, 0)
|
||||
view.setUint32(6, length)
|
||||
}
|
||||
out.set(payload, headerBytes)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the client frames in a buffer.
|
||||
*
|
||||
* Returns the frames found plus the bytes that were not a complete frame yet:
|
||||
* TCP gives no message boundaries, so a partial frame is normal and must be kept
|
||||
* for the next chunk rather than parsed as if it were whole.
|
||||
*
|
||||
* @param buffer - accumulated bytes.
|
||||
* @returns the frames and the unconsumed remainder.
|
||||
*/
|
||||
function decodeFrames(buffer: Uint8Array<ArrayBufferLike>): { frames: { opcode: number; payload: Uint8Array }[]; rest: Uint8Array<ArrayBufferLike> } {
|
||||
const frames: { opcode: number; payload: Uint8Array }[] = []
|
||||
let offset = 0
|
||||
while (offset + 2 <= buffer.byteLength) {
|
||||
const first = buffer[offset]!
|
||||
const second = buffer[offset + 1]!
|
||||
const opcode = first & 0x0f
|
||||
const masked = (second & 0x80) !== 0
|
||||
let length = second & 0x7f
|
||||
let cursor = offset + 2
|
||||
if (length === 126) {
|
||||
if (cursor + 2 > buffer.byteLength) break
|
||||
length = (buffer[cursor]! << 8) | buffer[cursor + 1]!
|
||||
cursor += 2
|
||||
} else if (length === 127) {
|
||||
if (cursor + 8 > buffer.byteLength) break
|
||||
const view = new DataView(buffer.buffer, buffer.byteOffset + cursor, 8)
|
||||
const big = view.getBigUint64(0)
|
||||
if (big > 1_000_000n) throw new Error('frame too large')
|
||||
length = Number(big)
|
||||
cursor += 8
|
||||
}
|
||||
let mask: Uint8Array | null = null
|
||||
if (masked) {
|
||||
if (cursor + 4 > buffer.byteLength) break
|
||||
mask = buffer.subarray(cursor, cursor + 4)
|
||||
cursor += 4
|
||||
}
|
||||
if (cursor + length > buffer.byteLength) break
|
||||
const payload = buffer.slice(cursor, cursor + length)
|
||||
if (mask !== null) for (let i = 0; i < payload.byteLength; i += 1) payload[i] = payload[i]! ^ mask[i % 4]!
|
||||
frames.push({ opcode, payload })
|
||||
offset = cursor + length
|
||||
}
|
||||
return { frames, rest: buffer.slice(offset) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a relay on a port.
|
||||
*
|
||||
* @param port - TCP port; 0 picks a free one.
|
||||
* @returns the running relay.
|
||||
*/
|
||||
export async function startRelay(port = 0, options: { readonly verbose?: boolean } = {}): Promise<Relay> {
|
||||
const log = (message: string): void => { if (options.verbose === true) console.log(`[relay] ${message}`) }
|
||||
const sockets = new Set<Socket>()
|
||||
let accepted = 0
|
||||
let forwarded = 0
|
||||
|
||||
const server: Server = createServer((_request: IncomingMessage, response: ServerResponse) => {
|
||||
response.writeHead(426, { 'content-type': 'text/plain' })
|
||||
response.end('this endpoint speaks WebSocket only\n')
|
||||
})
|
||||
|
||||
server.on('upgrade', (request, socket: Socket, head) => {
|
||||
const key = request.headers['sec-websocket-key']
|
||||
if (typeof key !== 'string') { socket.destroy(); return }
|
||||
const accept = createHash('sha1').update(key + WS_GUID).digest('base64')
|
||||
socket.write(
|
||||
'HTTP/1.1 101 Switching Protocols\r\n'
|
||||
+ 'Upgrade: websocket\r\n'
|
||||
+ 'Connection: Upgrade\r\n'
|
||||
+ `Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
|
||||
)
|
||||
sockets.add(socket)
|
||||
accepted += 1
|
||||
log(`upgrade from ${String(request.socket.remoteAddress ?? '?')} (${String(sockets.size)} connected)`)
|
||||
let buffer: Uint8Array<ArrayBufferLike> = head.byteLength > 0 ? new Uint8Array(head) : new Uint8Array(0)
|
||||
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
const merged = new Uint8Array(buffer.byteLength + chunk.byteLength)
|
||||
merged.set(buffer, 0)
|
||||
merged.set(chunk, buffer.byteLength)
|
||||
const { frames, rest } = decodeFrames(merged)
|
||||
buffer = rest
|
||||
log(`chunk ${String(chunk.byteLength)} bytes -> ${String(frames.length)} frames`)
|
||||
for (const frame of frames) {
|
||||
if (frame.opcode === OP_CLOSE) { socket.end(encodeFrame(OP_CLOSE, frame.payload)); continue }
|
||||
if (frame.opcode === OP_PING) { socket.write(encodeFrame(OP_PONG, frame.payload)); continue }
|
||||
if (frame.opcode !== OP_BINARY && frame.opcode !== OP_TEXT) continue
|
||||
const out = encodeFrame(OP_BINARY, frame.payload)
|
||||
let sent = 0
|
||||
for (const peer of sockets) {
|
||||
if (peer === socket || peer.destroyed) continue
|
||||
peer.write(out)
|
||||
forwarded += 1
|
||||
sent += 1
|
||||
}
|
||||
log(`frame opcode ${String(frame.opcode)} ${String(frame.payload.byteLength)} bytes -> ${String(sent)} peer(s)`)
|
||||
}
|
||||
})
|
||||
socket.on('close', () => { sockets.delete(socket); log('client closed') })
|
||||
socket.on('error', () => { sockets.delete(socket) })
|
||||
})
|
||||
|
||||
await new Promise<void>(resolve => { server.listen(port, '127.0.0.1', resolve) })
|
||||
const address = server.address()
|
||||
const boundPort = typeof address === 'object' && address !== null ? address.port : port
|
||||
|
||||
return {
|
||||
url: `ws://127.0.0.1:${String(boundPort)}`,
|
||||
get clients(): number { return sockets.size },
|
||||
get accepted(): number { return accepted },
|
||||
get forwarded(): number { return forwarded },
|
||||
close: async (): Promise<void> => {
|
||||
for (const socket of sockets) socket.destroy()
|
||||
sockets.clear()
|
||||
await new Promise<void>(resolve => { server.close(() => { resolve() }) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Run the co-op relay.
|
||||
*
|
||||
* This is the whole of "the server": it forwards bytes between the connected
|
||||
* peers and knows nothing about the game. Everything that decides who wins a
|
||||
* fight happens in the two browsers.
|
||||
*
|
||||
* Usage: node scripts/net-server.ts [port]
|
||||
*/
|
||||
import { startRelay } from './net-relay.ts'
|
||||
|
||||
const port = Number(process.argv[2] ?? '8787')
|
||||
const relay = await startRelay(Number.isFinite(port) ? port : 8787)
|
||||
console.log(`relay listening on ${relay.url}`)
|
||||
console.log('open two pages with ?ws=' + relay.url + '&peer=0 and &peer=1')
|
||||
|
||||
const stop = async (): Promise<void> => {
|
||||
await relay.close()
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGINT', () => { void stop() })
|
||||
process.on('SIGTERM', () => { void stop() })
|
||||
|
|
@ -0,0 +1,642 @@
|
|||
/**
|
||||
* Bake map assets out of the archives into web-native packs.
|
||||
*
|
||||
* The page can already read the real archives, but doing so costs it 38 MB and
|
||||
* ~11k range requests per visit (measured), because it decodes DS1/DT1/PL2 and
|
||||
* builds an atlas in the browser every time. This script moves all of that
|
||||
* offline: it walks the same code the page walks — `resolveLevel`, `decodeDs1`,
|
||||
* `decodeDt1`, `buildIsoMapScene`, `decodeDc6` — and writes the *result* as
|
||||
* indexed PNG tile pages plus one JSON scene per map. The page then decodes
|
||||
* nothing but PNG.
|
||||
*
|
||||
* Design decisions worth knowing:
|
||||
*
|
||||
* - **Indexed PNG, one palette per act.** Diablo II art is palette-indexed and
|
||||
* each act ships its own `pal.pl2`; PNG colour type 3 carries both, so the
|
||||
* browser's own decoder does the palette expansion on the GPU upload path.
|
||||
* - **Pages of at most 2048², hot frames first.** Frames used near the spawn go
|
||||
* into the first page(s), so the page can paint after loading one or two PNGs
|
||||
* instead of the whole map.
|
||||
* - **The scene JSON is the authority, not a re-derivation.** Draw lists are
|
||||
* already in painter's order and the collision grid is exactly the one the
|
||||
* live path produces; `scripts/verify-packs.ts` re-derives both from the MPQs
|
||||
* and fails if they differ by a byte.
|
||||
*
|
||||
* Usage: node scripts/pack-act-assets.ts [archive-directory] [output-directory]
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { cell, loadActTables, parseTable, resolveLevel } from '../src/game/acts.ts'
|
||||
import type { ActTables, LevelInfo } from '../src/game/acts.ts'
|
||||
import { decodeDs1 } from '../src/formats/ds1.ts'
|
||||
import { decodeDt1 } from '../src/formats/dt1.ts'
|
||||
import type { Dt1 } from '../src/formats/dt1.ts'
|
||||
import { decodePl2 } from '../src/formats/pl2.ts'
|
||||
import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts'
|
||||
import type { IsoMapScene } from '../src/game/d2map.ts'
|
||||
import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.ts'
|
||||
import { encodeIndexedPng } from './png.ts'
|
||||
|
||||
/**
|
||||
* Archive stack, in load order (later overrides earlier).
|
||||
*
|
||||
* `d2char.mpq` is mounted *first* so it sits at the bottom: it holds the object
|
||||
* and character art (`data\global\objects\…`) but no map data, and this order
|
||||
* keeps the map lookups resolving through `d2data` → `d2exp` → `Patch_D2` as the
|
||||
* game does.
|
||||
*/
|
||||
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
||||
/** Atlas page side limit (both dimensions). */
|
||||
const PAGE_SIZE = 2048
|
||||
/** Cells around the spawn whose frames go into the first page. */
|
||||
const HOT_RADIUS_CELLS = 8
|
||||
/** Where object art lives. */
|
||||
const OBJECT_PREFIX = 'data\\global\\objects\\'
|
||||
/**
|
||||
* Mode directories tried in order when picking an object's art.
|
||||
*
|
||||
* Note what the object art actually *is*: under `data\global\objects\` the
|
||||
* archives hold **1748 DCC and 1461 COF files against just 13 DC6**. Diablo II
|
||||
* draws objects with the same composite pipeline as characters — a COF that
|
||||
* lists layers and frames plus the DCC files behind them — so this packer
|
||||
* records each object's placement and its exact art members, and leaves the
|
||||
* pixels to the `COF`/`DCC` decoder that the character art also needs. Baking a
|
||||
* wrong file as if it were a DC6 would have produced silent garbage instead.
|
||||
*/
|
||||
const OBJECT_MODES = ['tr', 'nu', 's1', 's2', 's3'] as const
|
||||
|
||||
/**
|
||||
* The maps to bake, derived from `Levels.txt` rather than listed by hand.
|
||||
*
|
||||
* `DrlgType` picks the generator, exactly as the game does (documented in the
|
||||
* knowledge base and mirrored by OpenDiablo2):
|
||||
*
|
||||
* - **2 = preset area**: `LvlPrest` rows name fixed DS1 files, so the map is
|
||||
* baked byte-for-byte — what `verify-packs.ts` diffs against the live decoder.
|
||||
* - **1 = random maze** / **3 = wilderness**: no fixed layout exists. Those come
|
||||
* from the generators (`src/game/maze.ts`, `src/game/wilderness.ts`) driven by
|
||||
* `LvlMaze`, `LvlSub` and the `LvlPrest` room pieces, and their packs are
|
||||
* marked `approximation` because the engine's own placement is hardcoded.
|
||||
*/
|
||||
type LevelKind = 'preset' | 'maze' | 'wilderness'
|
||||
|
||||
/** One map to bake. */
|
||||
interface LevelJob {
|
||||
readonly act: number
|
||||
readonly levelId: number
|
||||
readonly name: string
|
||||
readonly kind: LevelKind
|
||||
readonly slug: string
|
||||
}
|
||||
|
||||
/** Filled in from `Levels.txt` once the tables are loaded. */
|
||||
let LEVELS: readonly LevelJob[] = []
|
||||
|
||||
/**
|
||||
* Turn a level name into a stable, file-friendly slug.
|
||||
*
|
||||
* @param name - `Levels.txt` name, e.g. `Act 1 - Tristram`.
|
||||
* @returns the slug.
|
||||
*/
|
||||
function slugify(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
/** One frame's placement inside a page. */
|
||||
interface Placement {
|
||||
readonly page: number
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
}
|
||||
|
||||
/** One atlas page being packed. */
|
||||
interface PackedPage {
|
||||
readonly pixels: Uint8Array
|
||||
frames: number
|
||||
}
|
||||
|
||||
/** Frames, shelf-packed into pages of at most {@link PAGE_SIZE} squared. */
|
||||
class PageBuilder {
|
||||
private readonly pages: PackedPage[] = []
|
||||
/** Palette the pages are encoded against. */
|
||||
private readonly palette: Uint8Array
|
||||
private cursorX = 0
|
||||
private shelfY = 0
|
||||
private shelfHeight = 0
|
||||
|
||||
constructor(palette: Uint8Array) {
|
||||
this.palette = palette
|
||||
}
|
||||
|
||||
/** Frames placed so far. */
|
||||
get placed(): number {
|
||||
return this.pages.reduce((sum, page) => sum + page.frames, 0)
|
||||
}
|
||||
|
||||
/** The pages, with their used height. */
|
||||
get pageCount(): number {
|
||||
return this.pages.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Place one indexed frame, opening pages as needed.
|
||||
*
|
||||
* Shelves keep placement trivial and predictable; frames wider than a page are
|
||||
* clamped rather than rejected, because a tile bigger than 2048 px would be a
|
||||
* broken library, not a reason to lose the whole map.
|
||||
*
|
||||
* @param frame - width, height and palette indices.
|
||||
* @returns where the frame landed.
|
||||
*/
|
||||
add(frame: { width: number; height: number; indices: Uint8Array }): Placement {
|
||||
const width = Math.max(1, Math.min(frame.width, PAGE_SIZE))
|
||||
const height = Math.max(1, Math.min(frame.height, PAGE_SIZE))
|
||||
if (this.cursorX + width > PAGE_SIZE) {
|
||||
this.shelfY += this.shelfHeight
|
||||
this.shelfHeight = 0
|
||||
this.cursorX = 0
|
||||
}
|
||||
if (this.pages.length === 0 || this.shelfY + height > PAGE_SIZE) {
|
||||
this.pages.push({ pixels: new Uint8Array(PAGE_SIZE * PAGE_SIZE), frames: 0 })
|
||||
this.shelfY = 0
|
||||
this.shelfHeight = 0
|
||||
this.cursorX = 0
|
||||
}
|
||||
const page = this.pages[this.pages.length - 1]!
|
||||
const placement: Placement = {
|
||||
page: this.pages.length - 1, x: this.cursorX, y: this.shelfY, width, height,
|
||||
}
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
const from = row * frame.width
|
||||
const to = (this.shelfY + row) * PAGE_SIZE + this.cursorX
|
||||
page.pixels.set(frame.indices.subarray(from, from + width), to)
|
||||
}
|
||||
this.cursorX += width
|
||||
this.shelfHeight = Math.max(this.shelfHeight, height)
|
||||
page.frames += 1
|
||||
return placement
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode every page, trimming each to the rows it actually used.
|
||||
*
|
||||
* @param transparent - palette index to mark transparent.
|
||||
* @returns file-ready PNGs, in page order.
|
||||
*/
|
||||
encode(transparent: number): { name: string; png: Uint8Array; width: number; height: number; frames: number }[] {
|
||||
return this.pages.map((page, index) => {
|
||||
const used = Math.max(1, this.usedHeight(page))
|
||||
const trimmed = new Uint8Array(PAGE_SIZE * used)
|
||||
for (let row = 0; row < used; row += 1) {
|
||||
trimmed.set(page.pixels.subarray(row * PAGE_SIZE, (row + 1) * PAGE_SIZE), row * PAGE_SIZE)
|
||||
}
|
||||
return {
|
||||
name: `tiles-${String(index)}.png`,
|
||||
png: encodeIndexedPng({
|
||||
width: PAGE_SIZE,
|
||||
height: used,
|
||||
pixels: trimmed,
|
||||
palette: this.palette,
|
||||
transparentIndex: transparent,
|
||||
}),
|
||||
width: PAGE_SIZE,
|
||||
height: used,
|
||||
frames: page.frames,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Height of the last written row, for trimming.
|
||||
*
|
||||
* @param page - the page.
|
||||
* @returns the used height in rows.
|
||||
*/
|
||||
private usedHeight(page: PackedPage): number {
|
||||
for (let row = PAGE_SIZE - 1; row >= 0; row -= 1) {
|
||||
const from = row * PAGE_SIZE
|
||||
for (let x = 0; x < PAGE_SIZE; x += 1) if (page.pixels[from + x] !== 0) return row + 1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FNV-1a over a frame's indexed pixels, so verification can compare content
|
||||
* without shipping the pixels twice.
|
||||
*
|
||||
* @param indices - palette indices.
|
||||
* @returns an 8-character hex digest.
|
||||
*/
|
||||
function frameHash(indices: Uint8Array): string {
|
||||
let hash = 0x811c9dc5
|
||||
for (const byte of indices) {
|
||||
hash ^= byte
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a DS1 member name into a file-friendly slug.
|
||||
*
|
||||
* @param member - full member path.
|
||||
* @returns the slug.
|
||||
*/
|
||||
function slugOf(member: string): string {
|
||||
const base = member.split('\\').pop() ?? member
|
||||
return base.replace(/\.ds1$/i, '').toLowerCase()
|
||||
}
|
||||
|
||||
const [archiveDir = 'samples/d2', outDir = 'samples/d2-packs'] = process.argv.slice(2)
|
||||
|
||||
const archives = new MountedArchives()
|
||||
for (const name of MOUNTS) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(join(archiveDir, name))))
|
||||
}
|
||||
const tables: ActTables = await loadActTables(archives)
|
||||
const objectsTable = parseTable(await archives.read('data\\global\\excel\\objects.txt'))
|
||||
/** Same table, through the typed loader the object resolution expects. */
|
||||
const objectsTableTyped = await loadObjectsTable(archives)
|
||||
const allNames = await archives.listFiles()
|
||||
|
||||
{
|
||||
const jobs: LevelJob[] = []
|
||||
for (const row of tables.levels.rows) {
|
||||
const levelId = Number(cell(tables.levels, row, 'Id'))
|
||||
if (!Number.isFinite(levelId) || levelId === 0) continue
|
||||
const name = cell(tables.levels, row, 'Name')
|
||||
const drlg = Number(cell(tables.levels, row, 'DrlgType'))
|
||||
const act = Number(cell(tables.levels, row, 'Act')) + 1
|
||||
let kind: LevelKind | null = null
|
||||
if (drlg === 2) kind = 'preset'
|
||||
else if (drlg === 1) kind = 'maze'
|
||||
else if (drlg === 3) kind = 'wilderness'
|
||||
if (kind === null) continue
|
||||
jobs.push({ act, levelId, name, kind, slug: `${String(levelId)}-${slugify(name)}` })
|
||||
}
|
||||
LEVELS = jobs
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoded DT1 libraries, kept across levels.
|
||||
*
|
||||
* A hundred-plus levels share a few dozen level types, so decoding each library
|
||||
* once instead of once per level turns the bake from repeated work into one pass.
|
||||
*/
|
||||
/**
|
||||
* Decoded DT1 libraries, keyed by member name.
|
||||
*
|
||||
* Bounded on purpose: every level keeps its DT1 block bitmaps alive (each library
|
||||
* decodes to tens of MB of palette indices), and an unbounded cache held 4+ GB by
|
||||
* the time the bake reached act 5. Sixteen libraries is more than any single
|
||||
* level type uses, so a level never re-decodes mid-level, and the memory ceiling
|
||||
* stays flat across a whole 62-map bake.
|
||||
*/
|
||||
const libraryCache = new Map<string, Dt1>()
|
||||
/** Most DT1 libraries to keep decoded at once. */
|
||||
const LIBRARY_CACHE_LIMIT = 16
|
||||
|
||||
/**
|
||||
* Decode a DT1 library, cached by member name.
|
||||
*
|
||||
* @param member - archive member name.
|
||||
* @returns the decoded library.
|
||||
*/
|
||||
async function libraryOf(member: string): Promise<Dt1> {
|
||||
const cached = libraryCache.get(member)
|
||||
if (cached !== undefined) return cached
|
||||
const decoded = decodeDt1(await archives.read(member))
|
||||
libraryCache.set(member, decoded)
|
||||
if (libraryCache.size > LIBRARY_CACHE_LIMIT) {
|
||||
// `Map` iterates in insertion order, so the first key is the oldest entry.
|
||||
const oldest = libraryCache.keys().next().value
|
||||
if (oldest !== undefined && oldest !== member) libraryCache.delete(oldest)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
/** Objects.txt lookup by Id, built once. */
|
||||
const objectById = new Map<number, { name: string; token: string; hp: number }>()
|
||||
for (const row of objectsTable.rows) {
|
||||
const id = Number(cell(objectsTable, row, 'Id'))
|
||||
if (!Number.isFinite(id)) continue
|
||||
objectById.set(id, {
|
||||
name: cell(objectsTable, row, 'Name'),
|
||||
token: cell(objectsTable, row, 'Token').toUpperCase(),
|
||||
hp: Number(cell(objectsTable, row, 'HitPoints') || '0'),
|
||||
})
|
||||
}
|
||||
|
||||
/** Object members grouped by token and mode, built once. */
|
||||
const objectMembers = new Map<string, Map<string, string[]>>()
|
||||
for (const name of allNames) {
|
||||
if (!name.toLowerCase().startsWith(OBJECT_PREFIX.toLowerCase())) continue
|
||||
const rest = name.slice(OBJECT_PREFIX.length).split('\\')
|
||||
if (rest.length < 3) continue
|
||||
const token = (rest[0] ?? '').toUpperCase()
|
||||
const mode = (rest[1] ?? '').toLowerCase()
|
||||
if (!objectMembers.has(token)) objectMembers.set(token, new Map())
|
||||
const modes = objectMembers.get(token)!
|
||||
if (!modes.has(mode)) modes.set(mode, [])
|
||||
modes.get(mode)!.push(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the member that stands in for an object's art.
|
||||
*
|
||||
* A token ships many variants (lit/unlit, per-mode, per-weapon-class); the pack
|
||||
* is static art, so one deterministic pick is recorded — mode order first, then
|
||||
* file name — and the number of alternatives goes in the manifest so the choice
|
||||
* is visible rather than implied.
|
||||
*
|
||||
* The mode token lives in the *file name* (`<token><component>lit<mode>hth.dcc`), not in
|
||||
* the directory, so the mode the lookup table gives is matched against file names.
|
||||
*
|
||||
* @param token - object token from the lookup table.
|
||||
* @param modeToken - animation mode token the engine places the object in (`NU`/`OP`/…).
|
||||
* @returns the member name and how many candidates there were.
|
||||
*/
|
||||
function pickObjectMember(token: string, modeToken: string): { member: string; candidates: number } | null {
|
||||
const dirs = objectMembers.get(token)
|
||||
if (dirs === undefined) return null
|
||||
const all = [...dirs.values()].flat()
|
||||
const candidates = all.length
|
||||
const wanted = modeToken.trim().toLowerCase() === '' ? 'nu' : modeToken.trim().toLowerCase()
|
||||
const order = [wanted, ...OBJECT_MODES.map(mode => mode.toLowerCase()).filter(mode => mode !== wanted)]
|
||||
for (const mode of order) {
|
||||
const files = all
|
||||
.filter(name => {
|
||||
const base = (name.split('\\').pop() ?? '').toLowerCase()
|
||||
return base.includes(mode) || (mode !== 'nu' && base.includes(`lit${mode}`))
|
||||
})
|
||||
.sort((a, b) => (a.toLowerCase().endsWith('.dcc') ? 0 : 1) - (b.toLowerCase().endsWith('.dcc') ? 0 : 1) || a.localeCompare(b))
|
||||
if (files.length > 0) return { member: files[0]!, candidates }
|
||||
}
|
||||
// No file carries the mode token: fall back to any member, deterministically.
|
||||
const fallback = [...all].sort()[0]
|
||||
return fallback === undefined ? null : { member: fallback, candidates }
|
||||
}
|
||||
|
||||
const index: Record<string, unknown> = {
|
||||
version: 1,
|
||||
generated: new Date().toISOString(),
|
||||
archiveDir,
|
||||
pageSize: PAGE_SIZE,
|
||||
palettes: {} as Record<string, number[]>,
|
||||
levels: [] as unknown[],
|
||||
}
|
||||
|
||||
let totalPngBytes = 0
|
||||
let totalLevels = 0
|
||||
|
||||
const pending: string[] = []
|
||||
for (const entry of LEVELS) {
|
||||
if (entry.kind !== 'preset') {
|
||||
// Maze and wilderness levels come from the generators; until those land they
|
||||
// are reported rather than silently missing from the pack.
|
||||
pending.push(`${entry.kind} ${String(entry.levelId)} ${entry.name}`)
|
||||
continue
|
||||
}
|
||||
const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act)
|
||||
const pl2 = decodePl2(await archives.read(info.paletteName))
|
||||
const palette = pl2.rgb
|
||||
;(index.palettes as Record<string, number[]>)[`act${String(entry.act)}`] = [...palette]
|
||||
|
||||
const libraries = []
|
||||
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
|
||||
|
||||
for (const ds1Name of info.ds1Names) {
|
||||
const level = decodeDs1(await archives.read(ds1Name))
|
||||
const scene: IsoMapScene = buildIsoMapScene(level, libraries, levelSeed(ds1Name))
|
||||
const spawn = findIsoSpawn(scene)
|
||||
const label = `${entry.slug}-${slugOf(ds1Name)}`
|
||||
const dir = join(outDir, `act${String(entry.act)}`, label)
|
||||
|
||||
// Frames used near the spawn load first; everything else follows.
|
||||
const hot = new Set<number>()
|
||||
if (spawn !== null) {
|
||||
const spawnCell = cellAt(scene, spawn.x, spawn.y)
|
||||
for (const draw of scene.floors) {
|
||||
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
||||
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
|
||||
}
|
||||
for (const draw of scene.walls) {
|
||||
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
||||
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
|
||||
}
|
||||
}
|
||||
// Page order decides how much a first paint costs. Hot frames (the spawn
|
||||
// area) come first, then frames are ordered by *where on the map they are
|
||||
// first drawn*, in coarse screen bands (roughly a viewport each): a viewport then needs one band's page
|
||||
// instead of an arbitrary handful. Sorting by size instead — the obvious
|
||||
// choice for packing efficiency — scatters a viewport's frames across every
|
||||
// page, which is exactly what lazy loading cannot afford.
|
||||
const firstDrawAt = new Map<number, { x: number; y: number }>()
|
||||
for (const draw of [...scene.floors, ...scene.walls]) {
|
||||
if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y })
|
||||
}
|
||||
const bandOf = (frameIndex: number): number => {
|
||||
const at = firstDrawAt.get(frameIndex)
|
||||
if (at === undefined) return Number.MAX_SAFE_INTEGER
|
||||
return Math.floor(at.y / 512) * 4096 + Math.floor(at.x / 768)
|
||||
}
|
||||
const order = scene.frames.map((_, at) => at).sort((a, b) => {
|
||||
const ha = hot.has(a) ? 0 : 1
|
||||
const hb = hot.has(b) ? 0 : 1
|
||||
if (ha !== hb) return ha - hb
|
||||
const ba = bandOf(a)
|
||||
const bb = bandOf(b)
|
||||
if (ba !== bb) return ba - bb
|
||||
const fa = scene.frames[a]!
|
||||
const fb = scene.frames[b]!
|
||||
return fb.width * fb.height - fa.width * fa.height
|
||||
})
|
||||
const pages = new PageBuilder(palette)
|
||||
const placementOf = new Map<number, Placement>()
|
||||
for (const frameIndex of order) {
|
||||
const frame = scene.frames[frameIndex]!
|
||||
placementOf.set(frameIndex, pages.add(frame))
|
||||
}
|
||||
const pageFiles = pages.encode(0)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const sha = createHash('sha256')
|
||||
for (const page of pageFiles) {
|
||||
await writeFile(join(dir, page.name), page.png)
|
||||
sha.update(page.png)
|
||||
totalPngBytes += page.png.byteLength
|
||||
}
|
||||
|
||||
// Object placements and their art members. The art itself is COF+DCC (see
|
||||
// OBJECT_MODES), so this records what each object *is* and exactly which
|
||||
// members would draw it, and reports how many are waiting on that decoder.
|
||||
const objectPages = new PageBuilder(palette)
|
||||
const objects: unknown[] = []
|
||||
const missingObjects: string[] = []
|
||||
let objectsWithArt = 0
|
||||
for (const object of level.objects) {
|
||||
// The DS1 `id` is an index into the hardcoded per-act object table, not an
|
||||
// `Objects.txt` row: act 1 id 0 is the rogue fountain (`Objects.txt` 12), not
|
||||
// row 0 ("Expansion"). `resolveDs1Object` walks that table and returns the
|
||||
// token/mode the engine actually uses.
|
||||
let resolved
|
||||
try {
|
||||
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id)
|
||||
} catch (err) {
|
||||
missingObjects.push((err as Error).message)
|
||||
continue
|
||||
}
|
||||
if (resolved.kind === 'monster') continue
|
||||
const row = resolved.row
|
||||
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode)
|
||||
const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX
|
||||
const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY
|
||||
objects.push({
|
||||
id: object.id,
|
||||
type: object.type,
|
||||
name: row?.name ?? resolved.token,
|
||||
token: resolved.token,
|
||||
mode: resolved.mode === '' ? 'NU' : resolved.mode,
|
||||
// `objectsTxtId` is the Objects.txt row the table points at; -1 means the
|
||||
// engine picks the art straight from the table's token, with no row.
|
||||
objectsTxtId: resolved.entry?.objectsTxtId ?? -1,
|
||||
hp: row === null ? 0 : (objectById.get(row.id)?.hp ?? 0),
|
||||
member: pick === null ? null : pick.member,
|
||||
alternatives: pick === null ? 0 : pick.candidates,
|
||||
// Screen point of the object's sub-tile; the sprite anchor is applied
|
||||
// when a frame exists.
|
||||
x: Math.round(orthoX),
|
||||
y: Math.round(orthoY),
|
||||
depth: (object.x + object.y) / 5,
|
||||
frame: null,
|
||||
})
|
||||
if (pick !== null) objectsWithArt += 1
|
||||
else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(entry.act)}) has no art members`)
|
||||
}
|
||||
const objectFiles = objectPages.encode(0)
|
||||
const shaObjects = createHash('sha256')
|
||||
for (const page of objectFiles) {
|
||||
await writeFile(join(dir, page.name.replace('tiles-', 'objects-')), page.png)
|
||||
shaObjects.update(page.png)
|
||||
totalPngBytes += page.png.byteLength
|
||||
}
|
||||
|
||||
// Collision grid as runs, so the JSON stays small without a second format.
|
||||
const runs: number[][] = []
|
||||
let current = scene.blocked[0] ?? 0
|
||||
let count = 0
|
||||
for (const value of scene.blocked) {
|
||||
if (value === current) { count += 1; continue }
|
||||
runs.push([current, count])
|
||||
current = value
|
||||
count = 1
|
||||
}
|
||||
runs.push([current, count])
|
||||
|
||||
const sceneJson = {
|
||||
version: 1,
|
||||
fidelity: 'exact',
|
||||
act: entry.act,
|
||||
levelId: entry.levelId,
|
||||
levelName: info.levelName,
|
||||
ds1: ds1Name,
|
||||
cellsX: scene.cellsX,
|
||||
cellsY: scene.cellsY,
|
||||
originX: scene.originX,
|
||||
originY: scene.originY,
|
||||
widthPx: scene.widthPx,
|
||||
heightPx: scene.heightPx,
|
||||
pageSize: PAGE_SIZE,
|
||||
pages: pageFiles.map(page => ({ file: page.name, width: page.width, height: page.height })),
|
||||
objectPages: objectFiles.map(page => ({ file: page.name.replace('tiles-', 'objects-'), width: page.width, height: page.height })),
|
||||
frames: scene.frames.map(frame => [frame.width, frame.height]),
|
||||
frameHash: scene.frames.map(frame => frameHash(frame.indices)),
|
||||
framePlacement: scene.frames.map((_, at) => {
|
||||
const place = placementOf.get(at)!
|
||||
return [place.page, place.x, place.y, place.width, place.height]
|
||||
}),
|
||||
floors: scene.floors.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
||||
walls: scene.walls.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
||||
// Roofs stay in their own list because the engine paints them last of all.
|
||||
roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
||||
objects,
|
||||
collision: { width: scene.gridWidth, height: scene.gridHeight, runs },
|
||||
spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)],
|
||||
stats: {
|
||||
floors: scene.floors.length,
|
||||
walls: scene.walls.length,
|
||||
roofs: scene.roofs.length,
|
||||
frames: scene.frames.length,
|
||||
missingTiles: scene.missingTiles,
|
||||
missingRefs: scene.missingRefs,
|
||||
walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length,
|
||||
objects: objects.length,
|
||||
objectsWithArt,
|
||||
objectsUnresolved: missingObjects,
|
||||
objectsArtPending: objects.length,
|
||||
dt1Libraries: info.dt1Names.length,
|
||||
},
|
||||
}
|
||||
const sceneBytes = new TextEncoder().encode(JSON.stringify(sceneJson))
|
||||
await writeFile(join(dir, 'scene.json'), sceneBytes)
|
||||
const manifest = {
|
||||
level: info.levelName,
|
||||
act: entry.act,
|
||||
levelId: entry.levelId,
|
||||
ds1: ds1Name,
|
||||
dt1: info.dt1Names,
|
||||
palette: info.paletteName,
|
||||
sceneBytes: sceneBytes.byteLength,
|
||||
sceneSha256: createHash('sha256').update(sceneBytes).digest('hex'),
|
||||
tilePagesSha256: sha.digest('hex'),
|
||||
objectPagesSha256: shaObjects.digest('hex'),
|
||||
pngBytes: pageFiles.reduce((sum, page) => sum + page.png.byteLength, 0)
|
||||
+ objectFiles.reduce((sum, page) => sum + page.png.byteLength, 0),
|
||||
sourceArchive: MOUNTS,
|
||||
}
|
||||
await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 1))
|
||||
;(index.levels as unknown[]).push({
|
||||
act: entry.act,
|
||||
levelId: entry.levelId,
|
||||
kind: entry.kind,
|
||||
slug: entry.slug,
|
||||
label,
|
||||
levelName: info.levelName,
|
||||
ds1: ds1Name,
|
||||
path: `act${String(entry.act)}/${label}`,
|
||||
cells: `${String(scene.cellsX)}x${String(scene.cellsY)}`,
|
||||
frames: scene.frames.length,
|
||||
objects: objects.length,
|
||||
pages: pageFiles.length,
|
||||
objectPages: objectFiles.length,
|
||||
missingTiles: scene.missingTiles,
|
||||
bytes: manifest.pngBytes + sceneBytes.byteLength,
|
||||
})
|
||||
totalLevels += 1
|
||||
console.log(
|
||||
`act${String(entry.act)}/${label.padEnd(22)} ${String(scene.cellsX)}x${String(scene.cellsY)} `
|
||||
+ `${String(scene.frames.length).padStart(3)} 图块 → ${String(pageFiles.length)} 页 PNG `
|
||||
+ `(${(manifest.pngBytes / 1024).toFixed(0)} KB) + scene.json ${(sceneBytes.byteLength / 1024).toFixed(0)} KB `
|
||||
+ `· 对象 ${String(objects.length)}/${String(level.objects.length)}(美术待 DCC/COF:${String(objectsWithArt)})`
|
||||
+ ` · 缺失瓦片 ${String(scene.missingTiles)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(outDir, { recursive: true })
|
||||
await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
|
||||
if (pending.length > 0) {
|
||||
const byKind = new Map<string, number>()
|
||||
for (const item of pending) {
|
||||
const kind = item.split(' ')[0] ?? '?'
|
||||
byKind.set(kind, (byKind.get(kind) ?? 0) + 1)
|
||||
}
|
||||
console.log(`尚未生成(需要生成器):${[...byKind].map(([k, v]) => `${k} ${String(v)} 个`).join(',')}`)
|
||||
}
|
||||
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Indexed-colour PNG writer for the offline asset packs.
|
||||
*
|
||||
* Diablo II's art is palette-indexed, so PNG's indexed colour type is the right
|
||||
* target rather than RGBA: the browser decodes it natively, the file is roughly
|
||||
* a quarter of the size, and the palette travels in the same file. Index 0 is
|
||||
* made transparent through `tRNS`, which is how the decoders already mark
|
||||
* "no pixel".
|
||||
*
|
||||
* Only what the packer needs is implemented — no interlacing, no 16-bit, no
|
||||
* colour types other than 3 — because a half-implemented encoder that silently
|
||||
* produces a file another tool misreads is worse than a small one.
|
||||
*
|
||||
* Node's `zlib` does the deflate; everything else is written here.
|
||||
*/
|
||||
import { deflateSync } from 'node:zlib'
|
||||
|
||||
/** PNG's 8-byte signature. */
|
||||
const SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
|
||||
/** CRC-32 table, built once. */
|
||||
const CRC_TABLE: Uint32Array = (() => {
|
||||
const table = new Uint32Array(256)
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n
|
||||
for (let k = 0; k < 8; k += 1) c = (c & 1) !== 0 ? (0xedb88320 ^ (c >>> 1)) >>> 0 : c >>> 1
|
||||
table[n] = c >>> 0
|
||||
}
|
||||
return table
|
||||
})()
|
||||
|
||||
/**
|
||||
* CRC-32 of a byte range, as PNG chunks require.
|
||||
*
|
||||
* @param bytes - the bytes.
|
||||
* @returns the checksum.
|
||||
*/
|
||||
function crc32(bytes: Uint8Array): number {
|
||||
let c = 0xffffffff
|
||||
for (const byte of bytes) c = (CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8)) >>> 0
|
||||
return (c ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One PNG chunk: length, type, payload, CRC.
|
||||
*
|
||||
* @param type - four-character chunk type.
|
||||
* @param payload - chunk body.
|
||||
* @returns the serialised chunk.
|
||||
*/
|
||||
function chunk(type: string, payload: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(12 + payload.byteLength)
|
||||
const view = new DataView(out.buffer)
|
||||
view.setUint32(0, payload.byteLength)
|
||||
for (let i = 0; i < 4; i += 1) out[4 + i] = type.charCodeAt(i)
|
||||
out.set(payload, 8)
|
||||
view.setUint32(8 + payload.byteLength, crc32(out.subarray(4, 8 + payload.byteLength)))
|
||||
return out
|
||||
}
|
||||
|
||||
/** An indexed image ready to encode. */
|
||||
export interface IndexedImage {
|
||||
/** Width in pixels. */
|
||||
readonly width: number
|
||||
/** Height in pixels. */
|
||||
readonly height: number
|
||||
/** Palette indices, row-major, `width * height` bytes. */
|
||||
readonly pixels: Uint8Array
|
||||
/** Palette as 768 RGB bytes. */
|
||||
readonly palette: Uint8Array
|
||||
/** Index treated as fully transparent, if any (usually 0). */
|
||||
readonly transparentIndex?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a PNG row filter in place into a destination buffer.
|
||||
*
|
||||
* @param type - filter type (0 none, 1 sub, 2 up).
|
||||
* @param row - the row's bytes.
|
||||
* @param previous - the row above, or null for the first row.
|
||||
* @param stride - bytes per pixel (1 for indexed).
|
||||
* @param out - destination, `row.byteLength` bytes.
|
||||
*/
|
||||
function filterRow(type: number, row: Uint8Array, previous: Uint8Array | null, stride: number, out: Uint8Array): void {
|
||||
for (let i = 0; i < row.byteLength; i += 1) {
|
||||
const raw = row[i]!
|
||||
const left = i >= stride ? row[i - stride]! : 0
|
||||
const up = previous === null ? 0 : previous[i]!
|
||||
const value = type === 0 ? raw : type === 1 ? raw - left : raw - up
|
||||
out[i] = value & 0xff
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum of absolute differences after treating bytes as signed, the standard
|
||||
* filter-choice heuristic: the filter that leaves the flattest rows compresses
|
||||
* best.
|
||||
*
|
||||
* @param bytes - filtered row.
|
||||
* @returns the score.
|
||||
*/
|
||||
function score(bytes: Uint8Array): number {
|
||||
let total = 0
|
||||
for (const byte of bytes) total += byte < 128 ? byte : 256 - byte
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an indexed image as a PNG.
|
||||
*
|
||||
* Each row picks its own filter (none / sub / up) by the usual minimum-sum-of-
|
||||
* absolute-differences rule. For tile art — large flat regions with sharp edges
|
||||
* — that is worth a solid fraction of the file size over "no filter everywhere",
|
||||
* and it costs one pass over the data.
|
||||
*
|
||||
* @param image - the image.
|
||||
* @returns the PNG bytes.
|
||||
*/
|
||||
export function encodeIndexedPng(image: IndexedImage): Uint8Array {
|
||||
const { width, height, pixels, palette } = image
|
||||
if (pixels.byteLength !== width * height) {
|
||||
throw new Error(`pixel buffer is ${String(pixels.byteLength)} bytes for ${String(width)}x${String(height)}`)
|
||||
}
|
||||
if (palette.byteLength % 3 !== 0 || palette.byteLength === 0 || palette.byteLength > 768) {
|
||||
throw new Error(`palette must be 3..768 bytes of RGB triples, got ${String(palette.byteLength)}`)
|
||||
}
|
||||
|
||||
const ihdr = new Uint8Array(13)
|
||||
const ihdrView = new DataView(ihdr.buffer)
|
||||
ihdrView.setUint32(0, width)
|
||||
ihdrView.setUint32(4, height)
|
||||
ihdr[8] = 8 // bit depth
|
||||
ihdr[9] = 3 // colour type: indexed
|
||||
ihdr[10] = 0 // deflate
|
||||
ihdr[11] = 0 // adaptive filtering
|
||||
ihdr[12] = 0 // no interlace
|
||||
|
||||
const plte = Uint8Array.from(palette)
|
||||
const trns = new Uint8Array((image.transparentIndex ?? 0) + 1)
|
||||
trns.fill(255)
|
||||
if (image.transparentIndex !== undefined) trns[image.transparentIndex] = 0
|
||||
|
||||
const stride = 1
|
||||
const raw = new Uint8Array((width * stride + 1) * height)
|
||||
const candidate = new Uint8Array(width)
|
||||
let at = 0
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row = pixels.subarray(y * width, (y + 1) * width)
|
||||
const previous = y === 0 ? null : pixels.subarray((y - 1) * width, y * width)
|
||||
let bestType = 0
|
||||
let bestScore = Number.POSITIVE_INFINITY
|
||||
for (const type of [0, 1, 2]) {
|
||||
filterRow(type, row, previous, stride, candidate)
|
||||
const value = score(candidate)
|
||||
if (value < bestScore) { bestScore = value; bestType = type }
|
||||
}
|
||||
filterRow(bestType, row, previous, stride, candidate)
|
||||
raw[at] = bestType
|
||||
raw.set(candidate, at + 1)
|
||||
at += width + 1
|
||||
}
|
||||
|
||||
const idat = new Uint8Array(deflateSync(raw, { level: 9 }))
|
||||
const parts = [SIGNATURE, chunk('IHDR', ihdr), chunk('PLTE', plte)]
|
||||
if (image.transparentIndex !== undefined) parts.push(chunk('tRNS', trns))
|
||||
parts.push(chunk('IDAT', idat), chunk('IEND', new Uint8Array(0)))
|
||||
const size = parts.reduce((sum, part) => sum + part.byteLength, 0)
|
||||
const out = new Uint8Array(size)
|
||||
let offset = 0
|
||||
for (const part of parts) { out.set(part, offset); offset += part.byteLength }
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
// port-object-lookup.ts — 把 OpenDiablo2 的"DS1 对象 id → Objects.txt"硬编码表转成本项目的紧凑数据模块
|
||||
//
|
||||
// 背景(为什么需要它):DS1 里对象的 `id` **不是** `Objects.txt` 的行号,而是"该 act 的对象表
|
||||
// 索引",这张表硬编码在游戏里。OpenDiablo2 把它整理成了
|
||||
// `d2core/d2records/object_lookup_record_data.go`(7,891 行,来源是一份社区整理的表格),
|
||||
// 并在 `d2mapstamp/stamp.go` 里用它做查找:
|
||||
//
|
||||
// lookup := records.LookupObject(act, object.Type, object.ID)
|
||||
// objectRecord := records.Object.Details[lookup.ObjectsTxtId]
|
||||
//
|
||||
// 本脚本只做机械转换:读那份 Go 数据表 → 生成 `src/game/object-lookup-data.ts`
|
||||
// (每个 act 一串 `id:obj:token:mode` 记录),运行时有解析器。
|
||||
//
|
||||
// node scripts/port-object-lookup.ts [--source=samples/od2/object_lookup_record_data.go]
|
||||
//
|
||||
// 退出码非 0 表示源文件与预期不符(换了版本就必须重新核对,而不是默默接受)。
|
||||
|
||||
export {}
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
/** OpenDiablo2 的数据表(已保存在 samples/od2 下,见 samples/od2/MANIFEST.md)。 */
|
||||
const SOURCE = process.argv.find(argument => argument.startsWith('--source='))?.slice('--source='.length)
|
||||
?? 'samples/od2/object_lookup_record_data.go'
|
||||
/** 生成物。 */
|
||||
const TARGET = 'src/game/object-lookup-data.ts'
|
||||
/** 源文件里应有的记录行数;不符就说明源换了版本。 */
|
||||
const EXPECTED_ROWS = 7891
|
||||
/** 只移植 `ObjectTypeItem`(物体);`ObjectTypeCharacter` 是怪物/NPC,本项目无怪物。 */
|
||||
const WANTED_TYPE = 'Item'
|
||||
|
||||
/** 一条解析出来的记录。 */
|
||||
interface LookupRow {
|
||||
act: number
|
||||
id: number
|
||||
objectsTxtId: number
|
||||
token: string
|
||||
mode: string
|
||||
direction: number
|
||||
base: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一行 Go 结构体字面量里取一个字符串字段。
|
||||
*
|
||||
* @param line - the source line.
|
||||
* @param name - field name.
|
||||
* @returns the value without quotes, or null when absent.
|
||||
*/
|
||||
function stringField(line: string, name: string): string | null {
|
||||
const match = new RegExp(`\\b${name}: "([^"]*)"`).exec(line)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一行 Go 结构体字面量里取一个数字字段。
|
||||
*
|
||||
* @param line - the source line.
|
||||
* @param name - field name.
|
||||
* @returns the value, or null when absent.
|
||||
*/
|
||||
function numberField(line: string, name: string): number | null {
|
||||
const match = new RegExp(`\\b${name}: (-?\\d+)`).exec(line)
|
||||
return match?.[1] === undefined ? null : Number(match[1])
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析整个 Go 数据表。
|
||||
*
|
||||
* @param text - file contents.
|
||||
* @returns the rows worth porting, in source order.
|
||||
*/
|
||||
function parse(text: string): { rows: LookupRow[]; total: number } {
|
||||
const rows: LookupRow[] = []
|
||||
let total = 0
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.trim().startsWith('{Act:')) continue
|
||||
total += 1
|
||||
const type = /Type: d2enum\.ObjectType(\w+)/.exec(line)?.[1]
|
||||
if (type !== WANTED_TYPE) continue
|
||||
const act = numberField(line, 'Act')
|
||||
const id = numberField(line, 'Id')
|
||||
const objectsTxtId = numberField(line, 'ObjectsTxtId')
|
||||
if (act === null || id === null || objectsTxtId === null) continue
|
||||
rows.push({
|
||||
act,
|
||||
id,
|
||||
objectsTxtId,
|
||||
token: stringField(line, 'Token') ?? '',
|
||||
mode: stringField(line, 'Mode') ?? '',
|
||||
direction: numberField(line, 'Direction') ?? -1,
|
||||
base: stringField(line, 'Base') ?? '',
|
||||
})
|
||||
}
|
||||
return { rows, total }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条记录压成 `id:obj:token:mode` 串,非默认字段追加在后面。
|
||||
*
|
||||
* 编码规则(解析器在 `src/game/object-lookup.ts`):
|
||||
* `id:obj:token:mode[:d⟨direction⟩][:m]`,其中 `m` 表示 Base 指向 Monsters 而不是 Objects。
|
||||
*
|
||||
* @param row - the record.
|
||||
* @returns the packed record.
|
||||
*/
|
||||
function pack(row: LookupRow): string {
|
||||
const parts = [String(row.id), String(row.objectsTxtId), row.token, row.mode]
|
||||
if (row.direction !== -1) parts.push(`d${String(row.direction)}`)
|
||||
if (/monsters/i.test(row.base)) parts.push('m')
|
||||
return parts.join(':')
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程。
|
||||
*/
|
||||
function main(): void {
|
||||
const text = readFileSync(SOURCE, 'utf8')
|
||||
const sha256 = createHash('sha256').update(text).digest('hex')
|
||||
const { rows, total } = parse(text)
|
||||
if (total !== EXPECTED_ROWS) {
|
||||
throw new Error(`${SOURCE}: parsed ${String(total)} rows, expected ${String(EXPECTED_ROWS)} — the table changed, re-verify before porting`)
|
||||
}
|
||||
|
||||
// 运行时按 (act, ds1Id) 建 Map,所以重复的 id 会互相覆盖;这里就按同样的规则去重,
|
||||
// 让"生成物里的统计量"与"运行时解析出来的记录数"严格相等(实测有 1 条重复)。
|
||||
const byAct = new Map<number, Map<number, string>>()
|
||||
for (const row of rows) {
|
||||
let bucket = byAct.get(row.act)
|
||||
if (bucket === undefined) { bucket = new Map<number, string>(); byAct.set(row.act, bucket) }
|
||||
bucket.set(row.id, pack(row))
|
||||
}
|
||||
const deduped = [...byAct.values()].reduce((sum, bucket) => sum + bucket.size, 0)
|
||||
// 有 token 才有美术:token 空的行是不可见/占位 id,Objects.txt 行号有没有都不影响这一条。
|
||||
const unique: LookupRow[] = []
|
||||
for (const bucket of byAct.values()) {
|
||||
for (const packedRecord of bucket.values()) {
|
||||
const found = rows.find(row => pack(row) === packedRecord)
|
||||
if (found !== undefined) unique.push(found)
|
||||
}
|
||||
}
|
||||
const withArt = unique.filter(row => row.token !== '').length
|
||||
const artless = unique.length - withArt
|
||||
const withRow = unique.filter(row => row.objectsTxtId >= 0).length
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('// object-lookup-data.ts — 由 scripts/port-object-lookup.ts 生成,请勿手改。')
|
||||
lines.push('//')
|
||||
lines.push('// 数据来源:OpenDiablo2,d2core/d2records/object_lookup_record_data.go')
|
||||
lines.push(`// sha256 ${sha256}`)
|
||||
lines.push(`// 共 ${String(total)} 行,其中 ObjectTypeItem ${String(rows.length)} 行、去重后 ${String(deduped)} 条`)
|
||||
lines.push('// (怪物/NPC 行未移植:本项目没有怪物)')
|
||||
lines.push('//')
|
||||
lines.push('// 含义:DS1 里 `(act, type=Object, id)` → `Objects.txt` 行号 + 引擎用的 token/mode。')
|
||||
lines.push('// DS1 的 id 是"该 act 的对象表索引",不是 Objects.txt 的行号,这张表就是那层映射。')
|
||||
lines.push('//')
|
||||
lines.push('// 编码:`<ds1Id>:<objectsTxtId>:<token>:<mode>[:d<direction>][:m]`,多条用 `;` 连接;')
|
||||
lines.push('// `objectsTxtId` 为 -1 表示该 id 在 Objects.txt 里没有行(不可见/占位对象);')
|
||||
lines.push('// `m` 表示 Base 指向 /Data/Global/Monsters 而不是 /Data/Global/Objects。')
|
||||
lines.push('')
|
||||
lines.push('/** 每个 act 的物体查找表,值是打包后的记录串。 */')
|
||||
lines.push('export const OBJECT_LOOKUP_ROWS: Readonly<Record<number, string>> = {')
|
||||
for (const [act, bucket] of [...byAct].sort((left, right) => left[0] - right[0])) {
|
||||
lines.push(` ${String(act)}: '${[...bucket.values()].join(';')}',`)
|
||||
}
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
lines.push('/** 表里带 token(= 有美术可查)的记录数。 */')
|
||||
lines.push(`export const OBJECT_LOOKUP_WITH_ART = ${String(withArt)}`)
|
||||
lines.push('')
|
||||
lines.push('/** 表里 token 为空的记录数:这些是不可见/占位对象,画不出来是对的。 */')
|
||||
lines.push(`export const OBJECT_LOOKUP_WITHOUT_ART = ${String(artless)}`)
|
||||
lines.push('')
|
||||
lines.push('/** 表里能对到 `Objects.txt` 行的记录数(其余只有 token/mode,没有名称与尺寸等元数据)。 */')
|
||||
lines.push(`export const OBJECT_LOOKUP_WITH_ROW = ${String(withRow)}`)
|
||||
lines.push('')
|
||||
|
||||
writeFileSync(TARGET, lines.join('\n'))
|
||||
console.log(`${TARGET}: ${String(rows.length)} 行原始记录 → 去重后 ${String(deduped)} 条(有 token ${String(withArt)},无 token ${String(artless)},有 Objects.txt 行 ${String(withRow)})`)
|
||||
console.log(`acts: ${[...byAct.keys()].sort((a, b) => a - b).join(', ')}`)
|
||||
}
|
||||
|
||||
main()
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Resolve and build all five act towns from the real archives.
|
||||
*
|
||||
* This runs the exact data chain the browser page uses — mount, tables, DS1,
|
||||
* DT1, palette, isometric scene — and reports numbers instead of impressions:
|
||||
* which DS1 quadrants each town has, whether every tile reference resolved,
|
||||
* how much of the map is walkable, and whether a spawn point exists. It is the
|
||||
* check that says the geometry is self-consistent before anything is drawn.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-acts.ts [directory]
|
||||
*/
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { loadActTables, loadActTown } from '../src/game/acts.ts'
|
||||
import { levelSeed, buildIsoMapScene, findIsoSpawn, isBlockedAt } from '../src/game/d2map.ts'
|
||||
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
|
||||
|
||||
const dir = process.argv[2] ?? 'samples/d2'
|
||||
/** Mount order: later archives override earlier ones, exactly as the game loads them. */
|
||||
const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']
|
||||
const CHARACTER_ARCHIVE = 'd2char.mpq'
|
||||
|
||||
let checks = 0
|
||||
let failures = 0
|
||||
|
||||
/**
|
||||
* Assert one expectation.
|
||||
*
|
||||
* @param ok - whether it held.
|
||||
* @param message - what was checked.
|
||||
*/
|
||||
function check(ok: boolean, message: string): void {
|
||||
checks += 1
|
||||
if (!ok) failures += 1
|
||||
console.log(` ${ok ? 'ok ' : 'FAIL'} ${message}`)
|
||||
}
|
||||
|
||||
const archives = new MountedArchives()
|
||||
for (const name of [...MOUNTS, CHARACTER_ARCHIVE]) {
|
||||
try {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`${dir}/${name}`)))
|
||||
} catch (err) {
|
||||
console.log(`skip ${name}: ${String(err)}`)
|
||||
}
|
||||
}
|
||||
if (archives.size === 0) {
|
||||
console.log(`no archives found in ${dir}`)
|
||||
process.exit(2)
|
||||
}
|
||||
console.log(`== mounted ${String(archives.size)} archives ==`)
|
||||
for (const line of archives.describe()) console.log(` ${line}`)
|
||||
|
||||
const tables = await loadActTables(archives)
|
||||
console.log(`\n== tables ==`)
|
||||
console.log(` levels.txt ${String(tables.levels.rows.length)} rows, ${String(tables.levels.header.length)} columns`)
|
||||
console.log(` lvltypes.txt ${String(tables.lvltypes.rows.length)} rows`)
|
||||
console.log(` lvlprest.txt ${String(tables.lvlprest.rows.length)} rows`)
|
||||
|
||||
for (let act = 1; act <= 5; act += 1) {
|
||||
console.log(`\n-- Act ${String(act)}`)
|
||||
const loaded = await loadActTown(archives, tables, act)
|
||||
const { town } = loaded
|
||||
console.log(` level ${town.levelName} (Id ${String(town.levelId)}, ${String(town.sizeX)}x${String(town.sizeY)} cells, level type "${town.levelTypeName}")`)
|
||||
console.log(` palette ${town.paletteName} (${String(loaded.palette.rgb.length / 3)} colours, ${String(loaded.palette.lightLevels.length)} light tables)`)
|
||||
console.log(` ds1 ${town.ds1Names.map(name => name.split('\\').pop()).join(', ')}`)
|
||||
console.log(` dt1 ${String(town.dt1Names.length)} libraries, mask 0x${town.dt1Mask.toString(16)}`)
|
||||
check(town.ds1Names.length > 0, `Act ${String(act)}: has at least one DS1`)
|
||||
check(town.dt1Names.length > 0, `Act ${String(act)}: has at least one DT1 library`)
|
||||
check(loaded.levels.every(level => level.version >= 8), `Act ${String(act)}: DS1 versions carry the act field`)
|
||||
|
||||
let scene = null as ReturnType<typeof buildIsoMapScene> | null
|
||||
let quadrant = ''
|
||||
for (const level of loaded.levels) {
|
||||
// The largest quadrant is the one the page opens; smaller ones are corridors.
|
||||
const built = buildIsoMapScene(level, loaded.libraries)
|
||||
if (scene === null || built.frames.length > scene.frames.length) {
|
||||
scene = built
|
||||
quadrant = `${String(level.width)}x${String(level.height)}`
|
||||
}
|
||||
}
|
||||
if (scene === null) { check(false, `Act ${String(act)}: built a scene`); continue }
|
||||
|
||||
let blocked = 0
|
||||
for (const value of scene.blocked) if (value === 1) blocked += 1
|
||||
const walkable = 1 - blocked / scene.blocked.length
|
||||
const spawn = findIsoSpawn(scene)
|
||||
console.log(` scene ${String(scene.widthPx)}x${String(scene.heightPx)} px, ${String(scene.floors.length)} floors, ${String(scene.walls.length)} walls, ${String(scene.frames.length)} distinct frames`)
|
||||
console.log(` collision ${String(scene.gridWidth)}x${String(scene.gridHeight)} sub-tiles, walkable ${(walkable * 100).toFixed(1)}%`)
|
||||
console.log(` warnings missing=${String(scene.missingTiles)} clipped=${String(scene.clippedTiles)} duplicate-refs=${String(scene.duplicateRefs)}`)
|
||||
// A handful of unresolved references is a quirk of the shipped DS1 data (the
|
||||
// game draws nothing there); a broken chain produces hundreds, so the bound is
|
||||
// tight in relative terms and the offenders are always printed.
|
||||
const references = scene.floors.length + scene.walls.length + scene.missingTiles
|
||||
const missingShare = scene.missingTiles / Math.max(1, references)
|
||||
check(
|
||||
missingShare <= 0.002,
|
||||
`Act ${String(act)}: ${String(scene.missingTiles)}/${String(references)} references unresolved (${(missingShare * 100).toFixed(2)}%)`,
|
||||
)
|
||||
for (const ref of scene.missingRefs.slice(0, 3)) console.log(` note unresolved: ${ref}`)
|
||||
// A town is mostly open ground; Act 4's fortress courtyard is 92% walkable, so
|
||||
// the bound only rejects maps that are entirely one or the other.
|
||||
check(walkable > 0.1 && walkable < 0.99, `Act ${String(act)}: walkable share ${(walkable * 100).toFixed(1)}% is a map, not a wall`)
|
||||
check(spawn !== null, `Act ${String(act)}: a walkable spawn exists`)
|
||||
if (spawn !== null) {
|
||||
check(!isBlockedAt(scene, spawn.x, spawn.y), `Act ${String(act)}: spawn is on a walkable sub-tile`)
|
||||
// Walk a short straight line and confirm the collision grid answers.
|
||||
let reached = 0
|
||||
for (let step = 0; step < 40; step += 1) {
|
||||
if (!isBlockedAt(scene, spawn.x + step * 4, spawn.y)) reached += 1
|
||||
}
|
||||
console.log(` probe ${String(reached)}/40 four-pixel steps east of spawn are walkable`)
|
||||
}
|
||||
check(scene.frames.length > 0, `Act ${String(act)}: scene references at least one frame`)
|
||||
console.log(` note ${String(scene.clippedTiles)} tiles needed a bitmap taller than their declared height`)
|
||||
void SUB_TILES_PER_TILE
|
||||
}
|
||||
|
||||
console.log(`\n${String(checks - failures)}/${String(checks)} checks passed`)
|
||||
if (failures > 0) process.exit(1)
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Full-archive verification: decode every named member and check the magic
|
||||
* bytes of the formats whose signature is known.
|
||||
*
|
||||
* A decode that merely returns without throwing proves little, so the checks
|
||||
* below assert real structure (RIFF headers, PCX manufacturer bytes, CEL frame
|
||||
* counts, palette sizes). Run:
|
||||
* node scripts/verify-archive.ts <archive> [--write <dir>]
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
|
||||
const [path, ...flags] = process.argv.slice(2)
|
||||
if (path === undefined) {
|
||||
console.error('usage: node scripts/verify-archive.ts <archive> [--write <dir>]')
|
||||
process.exit(2)
|
||||
}
|
||||
const writeIndex = flags.indexOf('--write')
|
||||
const outDir = writeIndex === -1 ? undefined : flags[writeIndex + 1]
|
||||
|
||||
const archive = await MpqArchive.open(await fileSource(path))
|
||||
const names = await archive.listFiles()
|
||||
const byExt = new Map<string, number>()
|
||||
let decoded = 0
|
||||
let sizeMismatch = 0
|
||||
const failures: string[] = []
|
||||
const magicFails: string[] = []
|
||||
|
||||
const ext = (name: string): string => {
|
||||
const cut = name.lastIndexOf('.')
|
||||
return cut === -1 ? '(none)' : name.slice(cut).toLowerCase()
|
||||
}
|
||||
|
||||
/** Check a decoded member against its format's signature. */
|
||||
function checkMagic(name: string, data: Uint8Array): void {
|
||||
const ascii = (at: number, length: number): string =>
|
||||
String.fromCharCode(...data.subarray(at, at + length))
|
||||
switch (ext(name)) {
|
||||
case '.wav':
|
||||
if (data.byteLength < 12 || ascii(0, 4) !== 'RIFF' || ascii(8, 4) !== 'WAVE') {
|
||||
magicFails.push(`${name}: not RIFF/WAVE`)
|
||||
}
|
||||
break
|
||||
case '.pcx':
|
||||
if (data.byteLength < 4 || data[0] !== 0x0a || data[2] !== 1) {
|
||||
magicFails.push(`${name}: not a PCX (enc=${String(data[2])})`)
|
||||
}
|
||||
break
|
||||
case '.cel': {
|
||||
const view = new DataView(data.buffer, data.byteOffset, Math.min(4, data.byteLength))
|
||||
const frames = view.getUint32(0, true)
|
||||
if (data.byteLength < 8 || frames === 0 || frames > 512) {
|
||||
magicFails.push(`${name}: implausible CEL frame count ${String(frames)}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
case '.trn':
|
||||
if (data.byteLength !== 256) magicFails.push(`${name}: .trn is ${String(data.byteLength)} bytes, expected 256`)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
byExt.set(ext(name), (byExt.get(ext(name)) ?? 0) + 1)
|
||||
const file = archive.find(name)
|
||||
if (file === undefined) { failures.push(`${name}: hash lookup failed`); continue }
|
||||
try {
|
||||
const data = await archive.read(file)
|
||||
if (data.byteLength !== file.fileSize) {
|
||||
sizeMismatch += 1
|
||||
failures.push(`${name}: ${String(data.byteLength)} != ${String(file.fileSize)}`)
|
||||
continue
|
||||
}
|
||||
checkMagic(name, data)
|
||||
decoded += 1
|
||||
if (outDir !== undefined) {
|
||||
const target = join(outDir, name.replace(/\\/g, '/'))
|
||||
await mkdir(join(target, '..'), { recursive: true })
|
||||
await writeFile(target, data)
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`${name}: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`archive ${path}`)
|
||||
console.log(`named ${String(names.length)}`)
|
||||
console.log(`decoded ${String(decoded)}`)
|
||||
console.log(`mismatch ${String(sizeMismatch)}`)
|
||||
console.log(`failures ${String(failures.length)}`)
|
||||
console.log(`magic ${magicFails.length === 0 ? 'all known signatures OK' : `${String(magicFails.length)} bad`}`)
|
||||
for (const line of magicFails.slice(0, 10)) console.log(` ! ${line}`)
|
||||
for (const line of failures.slice(0, 10)) console.log(` - ${line}`)
|
||||
const exts = [...byExt].sort((a, b) => b[1] - a[1]).slice(0, 12)
|
||||
console.log(`by ext ${exts.map(([e, n]) => `${e}:${String(n)}`).join(' ')}`)
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* M2 combat checks: the simulation, headless.
|
||||
*
|
||||
* The combat model is written to be driven one tick at a time from a small input
|
||||
* record precisely so it can be tested like this — without a canvas, without
|
||||
* input events, and without a map. Each check below states a behaviour the
|
||||
* sandbox must have (aggro, reach, cooldowns, resource costs, experience,
|
||||
* death and respawn) and asserts it against a simulated run, so a regression
|
||||
* shows up as a failing number rather than as something subtly wrong on screen.
|
||||
*
|
||||
* Usage: node scripts/verify-combat.ts
|
||||
*/
|
||||
import {
|
||||
createWorld, experienceTable, monsterStatsFromRow, monsterStatsFromTable,
|
||||
spawnMonsters, tickCombat,
|
||||
} from '../src/game/combat.ts'
|
||||
import type { CombatOptions, CombatWorld, MonsterStats } from '../src/game/combat.ts'
|
||||
import { parseTable, numberCell, findRow } from '../src/game/tables.ts'
|
||||
|
||||
const problems: string[] = []
|
||||
let checks = 0
|
||||
|
||||
/**
|
||||
* Assert one condition.
|
||||
*
|
||||
* @param condition - the condition to hold.
|
||||
* @param description - what it means.
|
||||
*/
|
||||
function expect(condition: boolean, description: string): void {
|
||||
checks += 1
|
||||
if (!condition) problems.push(description)
|
||||
}
|
||||
|
||||
/** A table shaped like the real `MonStats.txt`, including its rough edges. */
|
||||
const MONSTATS_TEXT = [
|
||||
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
|
||||
'fallen\tFallen\t12\t3\t24\t36\t200\t80\t8',
|
||||
'zombie\tZombie\t30\t6\t32\t40\t160\t50\t15\t\t',
|
||||
'skeleton\tSkeleton\t18\t4\t28\t38\t240\t70\t12',
|
||||
'empty\tSmall Rat\t\t2\t\t\t\t\t4', // missing cells: defaults must apply
|
||||
'nulled\tNull Beast\t(null)\t5\t30\t40\t180\t60\t9',
|
||||
].join('\r\n')
|
||||
|
||||
const options: CombatOptions = {
|
||||
playerSpeed: 200,
|
||||
playerReach: 48,
|
||||
playerCooldownTicks: 25,
|
||||
playerDamage: 6,
|
||||
playerManaPerAttack: 2,
|
||||
respawnTicks: 50,
|
||||
}
|
||||
|
||||
/** Flat ground: nothing is ever overlapped. */
|
||||
const openTerrain = { overlap: () => 0 }
|
||||
|
||||
// --- tables -----------------------------------------------------------------
|
||||
|
||||
const monstats = parseTable(MONSTATS_TEXT)
|
||||
expect(monstats.columns[0] === 'Id', 'header columns are read in order')
|
||||
expect(monstats.rows.length === 5, 'every non-empty line becomes a record')
|
||||
expect(monstats.rows[2]?.Id === 'skeleton', 'records are keyed by column name')
|
||||
expect(monstats.rows[3]?.HP === undefined, 'an empty cell is absent, not empty-string')
|
||||
expect(monstats.rows[4]?.HP === undefined, 'the (null) marker means absent')
|
||||
expect(numberCell(monstats.rows[3] ?? {}, 'HP', 20) === 20, 'a missing numeric cell falls back to the default')
|
||||
expect(findRow(monstats, 'Id', 'ZOMBIE')?.Name === 'Zombie', 'row lookup is case-insensitive')
|
||||
|
||||
const stats = monsterStatsFromTable(monstats)
|
||||
expect(stats.length === 5, 'every record becomes a definition')
|
||||
expect(stats[3]?.hp === 20, 'a definition with no HP column uses the default')
|
||||
expect(stats[4]?.hp === 20, 'a definition with a null HP uses the default')
|
||||
expect(stats[0]?.name === 'Fallen' && stats[0]?.xp === 8, 'names and experience come from the table')
|
||||
|
||||
const xpTable = experienceTable(parseTable([
|
||||
'Level\tXP',
|
||||
'1\t0',
|
||||
'2\t500',
|
||||
'3\t1500',
|
||||
'4\t400',
|
||||
].join('\n')))
|
||||
expect(xpTable[2] === 500 && xpTable[3] === 1500, 'experience thresholds are read per level')
|
||||
expect(xpTable[4] === 1500, 'a non-monotonic table is clamped upward')
|
||||
|
||||
// --- spawning ---------------------------------------------------------------
|
||||
|
||||
const world: CombatWorld = createWorld(0, 0)
|
||||
const blockedHalf = { overlap: (x: number) => (x < 0 ? 1 : 0) }
|
||||
const placed = spawnMonsters(world, stats, 6, { x: 0, y: 0 }, 200, blockedHalf)
|
||||
expect(placed === 6 && world.monsters.length === 6, 'spawns fill up to the requested count')
|
||||
expect(world.monsters.every(monster => monster.x >= 0), 'no monster is dropped into blocked ground')
|
||||
const deterministic: CombatWorld = createWorld(0, 0)
|
||||
spawnMonsters(deterministic, stats, 6, { x: 0, y: 0 }, 200, blockedHalf)
|
||||
expect(
|
||||
JSON.stringify(world.monsters.map(monster => [Math.round(monster.x), Math.round(monster.y)]))
|
||||
=== JSON.stringify(deterministic.monsters.map(monster => [Math.round(monster.x), Math.round(monster.y)])),
|
||||
'spawn positions are reproducible',
|
||||
)
|
||||
|
||||
// --- aggro, chase, reach ----------------------------------------------------
|
||||
|
||||
const idleWorld = createWorld(1000, 1000)
|
||||
const idleStats: MonsterStats = { ...stats[0]!, aggroRadius: 100, speed: 100, reach: 30 }
|
||||
idleWorld.monsters.push({
|
||||
index: 0, stats: idleStats, x: 0, y: 0, hp: idleStats.hp, cooldown: 0,
|
||||
state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
||||
})
|
||||
tickCombat(idleWorld, { movement: { x: 0, y: 0 }, attack: false }, options, openTerrain, xpTable)
|
||||
expect(idleWorld.monsters[0]?.state === 'idle', 'a monster outside its aggro radius stays idle')
|
||||
|
||||
// Inside the monster's aggro radius: a monster that never notices the player is
|
||||
// tested by the idle case above, so this one must be within range to chase.
|
||||
const chaseWorld = createWorld(80, 0)
|
||||
chaseWorld.monsters.push({
|
||||
index: 0, stats: idleStats, x: 0, y: 0, hp: idleStats.hp, cooldown: 0,
|
||||
state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
||||
})
|
||||
const startDistance = Math.hypot(80, 0)
|
||||
for (let i = 0; i < 10; i += 1) tickCombat(chaseWorld, { movement: { x: 0, y: 0 }, attack: false }, options, openTerrain, xpTable)
|
||||
const chaseDistance = Math.hypot(chaseWorld.monsters[0]!.x - 80, chaseWorld.monsters[0]!.y)
|
||||
expect(chaseWorld.monsters[0]?.state === 'chase' || chaseWorld.monsters[0]?.state === 'attack', 'a monster inside aggro closes in')
|
||||
expect(chaseDistance < startDistance, 'closing in actually reduces the distance')
|
||||
|
||||
const reachWorld = createWorld(20, 0)
|
||||
reachWorld.monsters.push({
|
||||
index: 0, stats: idleStats, x: 0, y: 0, hp: 1000, cooldown: 0,
|
||||
state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
||||
})
|
||||
const hpBefore = reachWorld.player.hp
|
||||
for (let i = 0; i < 100; i += 1) tickCombat(reachWorld, { movement: { x: 0, y: 0 }, attack: false }, options, openTerrain, xpTable)
|
||||
const taken = hpBefore - reachWorld.player.hp
|
||||
// Attacks land on ticks 1, 25, 49, 73 and 97 for a 24-tick cooldown: five hits
|
||||
// in a hundred ticks, which is what "respects the cooldown" has to mean.
|
||||
expect(taken === 5 * idleStats.damage, `monster damage respects its cooldown (took ${String(taken)}, expected ${String(5 * idleStats.damage)})`)
|
||||
expect(reachWorld.monsters[0]?.state === 'attack', 'a monster in reach switches to attacking')
|
||||
|
||||
// --- player attack, mana, cooldowns -----------------------------------------
|
||||
|
||||
const attackWorld = createWorld(20, 0)
|
||||
attackWorld.monsters.push({
|
||||
index: 0, stats: { ...idleStats, hp: 100, xp: 40 }, x: 0, y: 0, hp: 100, cooldown: 999,
|
||||
state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
||||
})
|
||||
const manaBefore = attackWorld.player.mana
|
||||
for (let i = 0; i < 100; i += 1) tickCombat(attackWorld, { movement: { x: 0, y: 0 }, attack: true }, options, openTerrain, xpTable)
|
||||
const monsterDamage = 100 - (attackWorld.monsters[0]?.hp ?? 0)
|
||||
expect(monsterDamage === 4 * options.playerDamage, `player attacks respect the cooldown (dealt ${String(monsterDamage)})`)
|
||||
expect(manaBefore - attackWorld.player.mana === 4 * options.playerManaPerAttack, 'each attack spends its mana')
|
||||
|
||||
const noManaWorld = createWorld(20, 0)
|
||||
noManaWorld.player.mana = 1
|
||||
noManaWorld.monsters.push({
|
||||
index: 0, stats: idleStats, x: 0, y: 0, hp: 100, cooldown: 999,
|
||||
state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
||||
})
|
||||
tickCombat(noManaWorld, { movement: { x: 0, y: 0 }, attack: true }, options, openTerrain, xpTable)
|
||||
expect(noManaWorld.events.some(event => event.kind === 'noMana'), 'an unaffordable attack reports no-mana instead of landing')
|
||||
expect(noManaWorld.monsters[0]?.hp === 100, 'an unaffordable attack deals no damage')
|
||||
|
||||
const whiffWorld = createWorld(0, 0)
|
||||
for (let i = 0; i < 3; i += 1) tickCombat(whiffWorld, { movement: { x: 0, y: 0 }, attack: true }, options, openTerrain, xpTable)
|
||||
expect(whiffWorld.player.cooldown >= 0, 'attacking with nothing in reach is legal and still costs the cooldown')
|
||||
|
||||
// --- kill, experience, level up ---------------------------------------------
|
||||
|
||||
const killWorld = createWorld(20, 0)
|
||||
const killStats: MonsterStats = { ...idleStats, hp: 12, xp: 600, cooldownTicks: 999 }
|
||||
killWorld.monsters.push({
|
||||
index: 0, stats: killStats, x: 0, y: 0, hp: 12, cooldown: 999,
|
||||
state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
||||
})
|
||||
let sawKill = false
|
||||
let sawLevelUp = false
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
tickCombat(killWorld, { movement: { x: 0, y: 0 }, attack: true }, options, openTerrain, xpTable)
|
||||
if (killWorld.events.some(event => event.kind === 'kill')) sawKill = true
|
||||
if (killWorld.events.some(event => event.kind === 'levelUp')) sawLevelUp = true
|
||||
}
|
||||
expect(sawKill, 'killing a monster emits a kill event')
|
||||
expect(killWorld.kills === 1, 'the kill is counted')
|
||||
expect(killWorld.monsters[0]?.state === 'dead', 'the monster is left dead, not removed mid-frame')
|
||||
expect(killWorld.player.xp === 600, 'experience is awarded from the monster table')
|
||||
expect(sawLevelUp && killWorld.player.level === 2, 'crossing the table threshold levels the player up')
|
||||
expect(killWorld.player.maxHp > 60 && killWorld.player.hp === killWorld.player.maxHp, 'a level up raises and refills resources')
|
||||
|
||||
// --- death and respawn ------------------------------------------------------
|
||||
|
||||
const deathWorld = createWorld(20, 0)
|
||||
deathWorld.player.hp = 5
|
||||
deathWorld.monsters.push({
|
||||
index: 0, stats: { ...idleStats, damage: 50, reach: 60 }, x: 0, y: 0, hp: 100, cooldown: 0,
|
||||
state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
||||
})
|
||||
tickCombat(deathWorld, { movement: { x: 0, y: 0 }, attack: false }, options, openTerrain, xpTable)
|
||||
expect(!deathWorld.player.alive, 'lethal damage kills the player')
|
||||
expect(deathWorld.player.respawnIn === options.respawnTicks, 'death starts the respawn timer')
|
||||
for (let i = 0; i < options.respawnTicks + 2; i += 1) {
|
||||
tickCombat(deathWorld, { movement: { x: 0, y: 0 }, attack: false }, options, openTerrain, xpTable)
|
||||
}
|
||||
expect(deathWorld.player.alive, 'the player comes back after the respawn delay')
|
||||
expect(deathWorld.player.hp === deathWorld.player.maxHp, 'respawn restores health')
|
||||
expect(deathWorld.player.mana === deathWorld.player.maxMana, 'respawn restores mana')
|
||||
|
||||
// --- determinism ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Digest a world's observable state.
|
||||
*
|
||||
* @param target - the world.
|
||||
* @returns a string digest.
|
||||
*/
|
||||
function digest(target: CombatWorld): string {
|
||||
return JSON.stringify([
|
||||
target.tick, target.kills, target.player.hp, target.player.xp,
|
||||
target.monsters.map(monster => [Math.round(monster.x * 100), Math.round(monster.y * 100), monster.hp, monster.state]),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a fixed scenario for a number of ticks.
|
||||
*
|
||||
* @param ticks - how many ticks to run.
|
||||
* @returns the world digest.
|
||||
*/
|
||||
function runScenario(ticks: number): string {
|
||||
const scenario = createWorld(0, 0)
|
||||
spawnMonsters(scenario, stats, 4, { x: 120, y: 0 }, 120, openTerrain)
|
||||
for (let i = 0; i < ticks; i += 1) {
|
||||
const movement = i % 40 < 20 ? { x: 1, y: 0 } : { x: 0, y: 1 }
|
||||
const attack = i % 7 === 0
|
||||
tickCombat(scenario, { movement, attack }, options, openTerrain, xpTable)
|
||||
}
|
||||
return digest(scenario)
|
||||
}
|
||||
expect(runScenario(300) === runScenario(300), 'the same inputs and seed produce the same simulation')
|
||||
|
||||
console.log(`checks ${String(checks)}`)
|
||||
console.log(`problems ${String(problems.length)}`)
|
||||
for (const problem of problems.slice(0, 12)) console.log(` - ${problem}`)
|
||||
console.log(problems.length === 0 ? 'RESULT combat behaviours hold' : 'RESULT FAILED')
|
||||
process.exit(problems.length === 0 ? 0 : 1)
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* Validate the real Diablo II archives dropped into `samples/d2/`.
|
||||
*
|
||||
* User-supplied game data lives outside version control (`samples/` is
|
||||
* ignored), so this script is the only durable record of what is actually on
|
||||
* disk. It opens every archive with the project's own reader — the point is to
|
||||
* prove this decoder copes with real Blizzard containers, not to trust a
|
||||
* third-party tool's opinion.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-d2-data.ts [directory]
|
||||
*
|
||||
* Exits non-zero when a hard expectation fails. A readable `(listfile)` is NOT
|
||||
* one of them: Diablo II 1.13 archives compress it with PKWARE implode
|
||||
* (`mask 0x8`), which `src/mpq/decompress.ts` does not implement yet, so the
|
||||
* failure is reported as a known gap with the exact mask.
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { join, relative } from 'node:path'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { compressionMaskName } from '../src/mpq/decompress.ts'
|
||||
|
||||
/** Archives the rest of the project expects to find, with why they matter. */
|
||||
const REQUIRED = new Map([
|
||||
['d2data.mpq', 'classic base data: tiles, sprites, palettes'],
|
||||
['d2exp.mpq', 'expansion data: Act 5, new monsters, expansion tables'],
|
||||
['d2char.mpq', 'character animations (DCC/COF sources)'],
|
||||
['patch_d2.mpq', 'the 1.13 data delta the engine actually mounts'],
|
||||
])
|
||||
|
||||
const root = process.argv[2] ?? 'samples/d2'
|
||||
|
||||
/** Every `.mpq` under `root`, recursively, case-insensitively. */
|
||||
async function findArchives(dir: string): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) out.push(...(await findArchives(path)))
|
||||
else if (entry.name.toLowerCase().endsWith('.mpq')) out.push(path)
|
||||
}
|
||||
return out.sort()
|
||||
}
|
||||
|
||||
async function sha256(path: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
let failures = 0
|
||||
let checks = 0
|
||||
const note = (ok: boolean, message: string): void => {
|
||||
checks += 1
|
||||
if (!ok) failures += 1
|
||||
console.log(` ${ok ? 'ok ' : 'FAIL'} ${message}`)
|
||||
}
|
||||
|
||||
let archives: string[]
|
||||
try {
|
||||
archives = await findArchives(root)
|
||||
} catch (err) {
|
||||
console.error(`cannot read ${root}: ${String(err)}`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
console.log(`== samples/d2 archives (${String(archives.length)} found) ==`)
|
||||
if (archives.length === 0) {
|
||||
console.log(' nothing to check — put the user-supplied MPQs in samples/d2/ first')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
for (const path of archives) {
|
||||
const name = relative(root, path).replaceAll('\\', '/')
|
||||
const nameLower = name.toLowerCase()
|
||||
const info = await stat(path)
|
||||
const digest = await sha256(path)
|
||||
console.log(`\n-- ${name}`)
|
||||
console.log(` bytes ${String(info.size)}`)
|
||||
console.log(` sha256 ${digest}`)
|
||||
note(/\.mpq$/i.test(name), 'has the .mpq extension')
|
||||
|
||||
let archive: MpqArchive
|
||||
try {
|
||||
archive = await MpqArchive.open(await fileSource(path))
|
||||
} catch (err) {
|
||||
note(false, `opens as an MPQ archive (${String(err)})`)
|
||||
continue
|
||||
}
|
||||
|
||||
const h = archive.header
|
||||
console.log(` header v${String(h.formatVersion + 1)} headerSize=${String(h.headerSize)} sectorSize=${String(h.sectorSize)}`)
|
||||
console.log(` tables hash @${String(h.hashTableOffset)} (${String(h.hashTableEntries)}), block @${String(h.blockTableOffset)} (${String(h.blockTableEntries)})`)
|
||||
console.log(` blocks ${String(archive.files().length)} occupied slots`)
|
||||
console.log(` flags ${[...archive.flagHistogram()].map(([k, v]) => `${k}=${String(v)}`).join(', ')}`)
|
||||
|
||||
note(h.formatVersion === 0, 'is an MPQ v1 header (the only format this reader supports)')
|
||||
note((h.hashTableEntries & (h.hashTableEntries - 1)) === 0 && h.hashTableEntries > 0, 'hash table size is a non-zero power of two')
|
||||
note(h.sectorSize === 4096 || h.sectorSize === 512, `sector size ${String(h.sectorSize)} is plausible`)
|
||||
note(
|
||||
h.archiveSize === info.size,
|
||||
h.archiveSize === info.size
|
||||
? 'header archiveSize matches the file length'
|
||||
: `header archiveSize ${String(h.archiveSize)} != file length ${String(info.size)} (trailing data or a truncated copy)`,
|
||||
)
|
||||
note(archive.files().length > 0, 'at least one occupied block slot')
|
||||
|
||||
if (REQUIRED.has(nameLower)) seen.add(nameLower)
|
||||
|
||||
// Names are the interesting part: does the archive still carry a listfile?
|
||||
try {
|
||||
const names = await archive.listFiles()
|
||||
console.log(` names ${String(names.length)} from (listfile)`)
|
||||
const sample = names.filter((n) => /\.(dt1|dcc|dc6|tbl|bin|txt|pal|cel)$/i.test(n)).slice(0, 6)
|
||||
if (sample.length > 0) console.log(` sample ${sample.join(', ')}`)
|
||||
} catch (err) {
|
||||
const mask = typeof (err as { mask?: unknown }).mask === 'number' ? (err as { mask: number }).mask : undefined
|
||||
const label = mask === undefined ? 'unknown' : `mask 0x${mask.toString(16)} ${compressionMaskName(mask)}`
|
||||
console.log(` names unreadable: ${String(err)}`)
|
||||
console.log(` known gap: (listfile) needs ${label} — not implemented in src/mpq/decompress.ts`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n== required data files ==')
|
||||
for (const [name, why] of REQUIRED) {
|
||||
const present = seen.has(name)
|
||||
note(present, `${name} present${present ? '' : ` (${why})`}`)
|
||||
}
|
||||
|
||||
console.log(`\n${String(checks - failures)}/${String(checks)} checks passed`)
|
||||
if (failures > 0) {
|
||||
console.log('NOTE: real Diablo II archives are only fully readable once PKWARE implode (mask 0x8) lands;')
|
||||
console.log(' listfile gaps above are informational, not counted as failures.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Self-check for the DC6 decoder against the generated fixture.
|
||||
*
|
||||
* This proves the decoder and the encoder agree; it does *not* prove the
|
||||
* format understanding is right. That second claim is what
|
||||
* `scripts/verify-dc6.sh` establishes, by having the independent `dc6png`
|
||||
* decoder read the same fixture and produce the same image.
|
||||
*
|
||||
* Usage: node scripts/verify-dc6.ts <fixture-directory>
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { decodeDc6 } from '../src/formats/dc6.ts'
|
||||
|
||||
interface ExpectedFrame {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly rows: readonly (readonly number[])[]
|
||||
}
|
||||
interface Expected {
|
||||
readonly directions: number
|
||||
readonly framesPerDirection: number
|
||||
readonly frames: readonly (readonly ExpectedFrame[])[]
|
||||
}
|
||||
|
||||
const dir = process.argv[2]
|
||||
if (dir === undefined) {
|
||||
console.error('usage: node scripts/verify-dc6.ts <fixture-directory>')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const expected = JSON.parse(await readFile(join(dir, 'expected.json'), 'utf8')) as Expected
|
||||
const sheet = decodeDc6(new Uint8Array(await readFile(join(dir, 'fixture.dc6'))))
|
||||
|
||||
const problems: string[] = []
|
||||
if (sheet.header.directions !== expected.directions) {
|
||||
problems.push(`directions: ${String(sheet.header.directions)} != ${String(expected.directions)}`)
|
||||
}
|
||||
if (sheet.header.framesPerDirection !== expected.framesPerDirection) {
|
||||
problems.push(`frames per direction: ${String(sheet.header.framesPerDirection)} != ${String(expected.framesPerDirection)}`)
|
||||
}
|
||||
if (sheet.header.version !== 6) problems.push(`version: ${String(sheet.header.version)} != 6`)
|
||||
|
||||
let frames = 0
|
||||
for (let direction = 0; direction < expected.directions; direction += 1) {
|
||||
const group = sheet.groups[direction]
|
||||
if (group === undefined) { problems.push(`direction ${String(direction)} missing`); continue }
|
||||
for (let frameIndex = 0; frameIndex < expected.framesPerDirection; frameIndex += 1) {
|
||||
const want = expected.frames[direction]?.[frameIndex]
|
||||
const got = group.frames[frameIndex]
|
||||
if (want === undefined || got === undefined) { problems.push(`frame ${String(direction)}/${String(frameIndex)} missing`); continue }
|
||||
frames += 1
|
||||
if (got.width !== want.width || got.height !== want.height) {
|
||||
problems.push(`frame ${String(direction)}/${String(frameIndex)} size ${String(got.width)}x${String(got.height)} != ${String(want.width)}x${String(want.height)}`)
|
||||
continue
|
||||
}
|
||||
for (let y = 0; y < want.height; y += 1) {
|
||||
for (let x = 0; x < want.width; x += 1) {
|
||||
const at = y * want.width + x
|
||||
const wantIndex = want.rows[y]?.[x] ?? 0
|
||||
const gotIndex = got.indices[at] ?? 0
|
||||
const wantOpaque = wantIndex === 0 ? 0 : 1
|
||||
if (gotIndex !== wantIndex || (got.mask[at] ?? 0) !== wantOpaque) {
|
||||
problems.push(`frame ${String(direction)}/${String(frameIndex)} px ${String(x)},${String(y)}: index ${String(gotIndex)}/mask ${String(got.mask[at] ?? 0)} != ${String(wantIndex)}/${String(wantOpaque)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Placement fields travel through untouched.
|
||||
if (got.offsetX !== direction * 4 + frameIndex * 22 + 1 || got.offsetY !== -(direction * 4 + frameIndex * 22 + 1)) {
|
||||
problems.push(`frame ${String(direction)}/${String(frameIndex)} anchors ${String(got.offsetX)},${String(got.offsetY)} are wrong`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`fixture ${join(dir, 'fixture.dc6')}`)
|
||||
console.log(`decoded ${String(frames)} frames, ${String(sheet.header.directions)} directions x ${String(sheet.header.framesPerDirection)}`)
|
||||
console.log(`mismatches ${String(problems.length)}`)
|
||||
for (const problem of problems.slice(0, 12)) console.log(` - ${problem}`)
|
||||
console.log(problems.length === 0 ? 'RESULT decoder matches the encoded grid exactly' : 'RESULT FAILED')
|
||||
process.exit(problems.length === 0 ? 0 : 1)
|
||||
|
|
@ -0,0 +1,698 @@
|
|||
/**
|
||||
* Verify the `.cof` and `.dcc` decoders against the real Diablo II archives.
|
||||
*
|
||||
* The Sorceress is the interesting case because her art is COF+DCC only: a COF
|
||||
* names no sprite files, so the run has to reconstruct the DCC path from the
|
||||
* COF's own name plus each layer record's composite type, then decode every
|
||||
* layer of every direction and frame and confirm it produced opaque pixels. A
|
||||
* decoder that silently returned zeroed frames would pass a "does it throw?"
|
||||
* test and fail this one.
|
||||
*
|
||||
* Object art (barrels, chests, urns, doors) goes through the same path from the
|
||||
* data archive, so a regression that only affects non-character art or
|
||||
* single-direction animations is caught too.
|
||||
*
|
||||
* After the gated checks the script sweeps the whole Sorceress DCC set. That
|
||||
* sweep is what puts a real number on the `bottom-up` frame flag: the decoder
|
||||
* flips those frames vertically, the reference implementation panics on them
|
||||
* instead, and nothing else in this project can tell how often that judgement
|
||||
* call is exercised. Sweep failures are reported with their exact reason but do
|
||||
* not fail the run, because the run's exit code is defined by the walk/stand
|
||||
* members the renderer depends on.
|
||||
*
|
||||
* Nothing here grades its own homework: every assertion is about bytes read out
|
||||
* of a Blizzard archive the user supplied.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-dcc.ts [directory] [--quick]
|
||||
*
|
||||
* Exits non-zero when a Sorceress walk/stand member — or an object member —
|
||||
* fails to decode.
|
||||
*/
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { cofLayerOrder, decodeCof } from '../src/formats/cof.ts'
|
||||
import type { CofFile } from '../src/formats/cof.ts'
|
||||
import { decodeDcc } from '../src/formats/dcc.ts'
|
||||
import type { DccFile } from '../src/formats/dcc.ts'
|
||||
import { decodePal } from '../src/formats/pal.ts'
|
||||
import type { Palette } from '../src/formats/pal.ts'
|
||||
import type { SpriteFrame } from '../src/formats/sprite.ts'
|
||||
|
||||
/**
|
||||
* Composite type → the archive directory holding that component's art.
|
||||
*
|
||||
* A COF layer record stores only the composite type and a weapon class, so this
|
||||
* is the mapping that turns "layer 6 is a head" into `.../hd/...`. Objects use
|
||||
* the same table: the shipped object COFs declare type 1, whose art lives in the
|
||||
* `tr` directory, exactly as a character torso does.
|
||||
*/
|
||||
const LAYER_COMPONENT = [
|
||||
'hd',
|
||||
'tr',
|
||||
'lg',
|
||||
'ra',
|
||||
'la',
|
||||
'rh',
|
||||
'lh',
|
||||
'sh',
|
||||
's1',
|
||||
's2',
|
||||
's3',
|
||||
's4',
|
||||
's5',
|
||||
's6',
|
||||
's7',
|
||||
's8',
|
||||
]
|
||||
|
||||
/** Archive holding the character art, opened first because it gates the run. */
|
||||
const CHARACTER_ARCHIVE = 'd2char.mpq'
|
||||
/** Archive holding object art, palettes and object COFs. */
|
||||
const DATA_ARCHIVE = 'd2data.mpq'
|
||||
/** The class COFs this project needs before the Sorceress can be drawn. */
|
||||
const CLASS_ANIMATIONS: readonly { readonly label: string; readonly member: string }[] = [
|
||||
{ label: 'walk', member: 'data\\global\\chars\\so\\cof\\sowlhth.cof' },
|
||||
{ label: 'stand', member: 'data\\global\\chars\\so\\cof\\sonuhth.cof' },
|
||||
]
|
||||
/** Object members decoded directly, with no COF involved. */
|
||||
const OBJECT_DCCS: readonly string[] = [
|
||||
'data\\global\\objects\\c5\\tr\\c5trlitnuhth.dcc',
|
||||
'data\\global\\objects\\l2\\tr\\l2trlitnuhth.dcc',
|
||||
]
|
||||
/** Object COF decoded to exercise the COF→DCC path outside the character tree. */
|
||||
const OBJECT_COFS: readonly { readonly label: string; readonly member: string }[] = [
|
||||
{ label: 'object l2 neutral', member: 'data\\global\\objects\\l2\\cof\\l2nuhth.cof' },
|
||||
]
|
||||
/** Directory whose DCCs the bottom-up sweep covers. */
|
||||
const SWEEP_PREFIX = 'data/global/chars/so/'
|
||||
/** Sweep cap in `--quick` mode, so the script stays usable in a loop. */
|
||||
const QUICK_LIMIT = 150
|
||||
/** Cap on individually printed sweep failures. */
|
||||
const MAX_REPORTED_SWEEP_FAILURES = 20
|
||||
/** Brightness ramp for the text rendering; index 0 is reserved for empty space. */
|
||||
const ASCII_RAMP = ' .:-=+*#%@'
|
||||
/** Cap on the ASCII rendering's width and height, in characters. */
|
||||
const ASCII_COLUMNS = 50
|
||||
const ASCII_ROWS = 30
|
||||
|
||||
/** What one decoded DCC member contributed, for the running totals. */
|
||||
interface MemberStats {
|
||||
readonly member: string
|
||||
readonly directions: number
|
||||
readonly frames: number
|
||||
readonly bottomUp: number
|
||||
readonly minArtWidth: number
|
||||
readonly maxArtWidth: number
|
||||
readonly minArtHeight: number
|
||||
readonly maxArtHeight: number
|
||||
readonly minCanvasWidth: number
|
||||
readonly maxCanvasWidth: number
|
||||
readonly minCanvasHeight: number
|
||||
readonly maxCanvasHeight: number
|
||||
readonly opaquePixels: number
|
||||
readonly totalPixels: number
|
||||
}
|
||||
|
||||
/** A decoded member plus its statistics, so callers can reuse the artwork. */
|
||||
interface DccResult {
|
||||
readonly stats: MemberStats
|
||||
readonly dcc: DccFile
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const dir = args.find((a) => !a.startsWith('--')) ?? 'samples/d2'
|
||||
const quick = args.includes('--quick')
|
||||
|
||||
let membersDecoded = 0
|
||||
let cofsDecoded = 0
|
||||
let directions = 0
|
||||
let frames = 0
|
||||
let bottomUpFrames = 0
|
||||
let opaquePixels = 0
|
||||
let totalPixels = 0
|
||||
let minArtWidth = Number.MAX_SAFE_INTEGER
|
||||
let maxArtWidth = 0
|
||||
let minArtHeight = Number.MAX_SAFE_INTEGER
|
||||
let maxArtHeight = 0
|
||||
let minCanvasWidth = Number.MAX_SAFE_INTEGER
|
||||
let maxCanvasWidth = 0
|
||||
let minCanvasHeight = Number.MAX_SAFE_INTEGER
|
||||
let maxCanvasHeight = 0
|
||||
let assertions = 0
|
||||
let failures = 0
|
||||
/** A frame captured for the text rendering, with the member it came from. */
|
||||
let renderSample: { member: string; frame: SpriteFrame; direction: number; index: number } | undefined
|
||||
|
||||
/**
|
||||
* Map an archive name to the lower-case, forward-slash form used for matching.
|
||||
*
|
||||
* @param name - the name as stored.
|
||||
* @returns the comparison form.
|
||||
*/
|
||||
function normalize(name: string): string {
|
||||
return name.replaceAll('\\', '/').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a message from an unknown thrown value.
|
||||
*
|
||||
* @param err - the thrown value.
|
||||
* @returns the message.
|
||||
*/
|
||||
function messageOf(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one assertion.
|
||||
*
|
||||
* @param ok - whether it held.
|
||||
* @param message - what was checked.
|
||||
* @param required - whether a failure should fail the run.
|
||||
*/
|
||||
function check(ok: boolean, message: string, required = true): void {
|
||||
assertions += 1
|
||||
if (ok) return
|
||||
if (required) failures += 1
|
||||
console.log(` FAIL ${message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the DCC path a COF layer draws.
|
||||
*
|
||||
* A COF layer record names no file: it carries a composite type and a weapon
|
||||
* class, and the COF's own file name carries the animation and weapon codes. The
|
||||
* sprite path is therefore
|
||||
* `<root><component>/<token><component><variant><animation><weapon>.dcc`, where
|
||||
* everything except `<variant>` is known. `<variant>` is the armour or object
|
||||
* variant code, which lives in the item tables rather than the COF, so the
|
||||
* lexicographically first candidate is used: every candidate is the same
|
||||
* animation from a different armour tier, and decoding one of them is what this
|
||||
* check is for.
|
||||
*
|
||||
* @param names - the archive's normalised name list.
|
||||
* @param root - directory prefix shared by the COF and its art.
|
||||
* @param token - the object or class code, e.g. `so`.
|
||||
* @param animation - two-letter animation code from the COF name.
|
||||
* @param weapon - weapon-class code from the COF name.
|
||||
* @param component - component directory for the layer's composite type.
|
||||
* @returns the member name, or undefined when the archive has no candidate.
|
||||
*/
|
||||
function findLayerDcc(
|
||||
names: readonly string[],
|
||||
root: string,
|
||||
token: string,
|
||||
animation: string,
|
||||
weapon: string,
|
||||
component: string,
|
||||
): string | undefined {
|
||||
const prefix = `${root}${component}/${token}${component}`
|
||||
const suffix = `${animation}${weapon}.dcc`
|
||||
const hits = names.filter((n) => n.startsWith(prefix) && n.endsWith(suffix))
|
||||
hits.sort()
|
||||
return hits[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one COF.
|
||||
*
|
||||
* @param archive - the archive holding the member.
|
||||
* @param member - the member name.
|
||||
* @param required - whether a failure should fail the run.
|
||||
* @returns the decoded COF, or undefined when it could not be read.
|
||||
*/
|
||||
async function readCof(archive: MpqArchive, member: string, required: boolean): Promise<CofFile | undefined> {
|
||||
const file = archive.find(member)
|
||||
if (file === undefined) {
|
||||
check(false, `${member}: not present in the archive`, required)
|
||||
return undefined
|
||||
}
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = await archive.read(file)
|
||||
} catch (err) {
|
||||
check(false, `${member}: cannot read member: ${messageOf(err)}`, required)
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const cof = decodeCof(data)
|
||||
check(true, `${member}: decoded`)
|
||||
cofsDecoded += 1
|
||||
return cof
|
||||
} catch (err) {
|
||||
check(false, `${member}: decode failed: ${messageOf(err)}`, required)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one DCC member, assert every frame is usable, and gather its statistics.
|
||||
*
|
||||
* The per-frame contract is deliberately strict — positive canvas, positive art
|
||||
* rectangle, at least one opaque pixel — because those three properties between
|
||||
* them rule out the failure modes that a decoder merely "succeeding" would
|
||||
* otherwise hide: an empty canvas, an off-by-one box, and a frame that decoded
|
||||
* to nothing but transparency.
|
||||
*
|
||||
* @param archive - the archive holding the member.
|
||||
* @param member - the member name.
|
||||
* @param expected - direction and frame counts the COF demands, when known.
|
||||
* @param required - whether a failure should fail the run.
|
||||
* @returns the member's statistics, or undefined when it could not be decoded.
|
||||
*/
|
||||
async function checkDcc(
|
||||
archive: MpqArchive,
|
||||
member: string,
|
||||
expected: { directions: number; frames: number } | undefined,
|
||||
required: boolean,
|
||||
): Promise<DccResult | undefined> {
|
||||
const file = archive.find(member)
|
||||
if (file === undefined) {
|
||||
check(false, `${member}: not present in the archive`, required)
|
||||
return undefined
|
||||
}
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = await archive.read(file)
|
||||
} catch (err) {
|
||||
check(false, `${member}: cannot read member: ${messageOf(err)}`, required)
|
||||
return undefined
|
||||
}
|
||||
let dcc: DccFile
|
||||
try {
|
||||
dcc = decodeDcc(data)
|
||||
} catch (err) {
|
||||
check(false, `${member}: decode failed: ${messageOf(err)}`, required)
|
||||
return undefined
|
||||
}
|
||||
|
||||
check(dcc.directions.length > 0, `${member}: decoded ${String(dcc.directions.length)} directions`, required)
|
||||
if (expected !== undefined) {
|
||||
check(
|
||||
dcc.directions.length === expected.directions,
|
||||
`${member}: ${String(dcc.directions.length)} directions, COF declares ${String(expected.directions)}`,
|
||||
required,
|
||||
)
|
||||
}
|
||||
|
||||
let memberFrames = 0
|
||||
let memberBottomUp = 0
|
||||
let memberOpaque = 0
|
||||
let memberTotal = 0
|
||||
let artMinW = Number.MAX_SAFE_INTEGER
|
||||
let artMaxW = 0
|
||||
let artMinH = Number.MAX_SAFE_INTEGER
|
||||
let artMaxH = 0
|
||||
let canvasMinW = Number.MAX_SAFE_INTEGER
|
||||
let canvasMaxW = 0
|
||||
let canvasMinH = Number.MAX_SAFE_INTEGER
|
||||
let canvasMaxH = 0
|
||||
|
||||
for (let d = 0; d < dcc.directions.length; d += 1) {
|
||||
const directionBox = dcc.directions[d]!.box
|
||||
const list = dcc.directions[d]!.frames
|
||||
check(
|
||||
directionBox.width > 0 && directionBox.height > 0,
|
||||
`${member} direction ${String(d)}: box ${String(directionBox.width)}x${String(directionBox.height)}`,
|
||||
required,
|
||||
)
|
||||
if (expected !== undefined) {
|
||||
check(
|
||||
list.length === expected.frames,
|
||||
`${member} direction ${String(d)}: ${String(list.length)} frames, COF declares ${String(expected.frames)}`,
|
||||
required,
|
||||
)
|
||||
}
|
||||
if (directionBox.width <= 0 || directionBox.height <= 0) continue
|
||||
canvasMinW = Math.min(canvasMinW, directionBox.width)
|
||||
canvasMaxW = Math.max(canvasMaxW, directionBox.width)
|
||||
canvasMinH = Math.min(canvasMinH, directionBox.height)
|
||||
canvasMaxH = Math.max(canvasMaxH, directionBox.height)
|
||||
|
||||
for (let f = 0; f < list.length; f += 1) {
|
||||
const decoded = list[f]!
|
||||
const sprite = decoded.frame
|
||||
memberFrames += 1
|
||||
if (decoded.bottomUp) memberBottomUp += 1
|
||||
check(
|
||||
decoded.width > 0 && decoded.height > 0,
|
||||
`${member} direction ${String(d)} frame ${String(f)}: art ${String(decoded.width)}x${String(decoded.height)}`,
|
||||
required,
|
||||
)
|
||||
check(
|
||||
sprite.width === directionBox.width && sprite.height === directionBox.height,
|
||||
`${member} direction ${String(d)} frame ${String(f)}: canvas ${String(sprite.width)}x${String(sprite.height)} is not the direction box`,
|
||||
required,
|
||||
)
|
||||
if (decoded.width <= 0 || decoded.height <= 0) continue
|
||||
artMinW = Math.min(artMinW, decoded.width)
|
||||
artMaxW = Math.max(artMaxW, decoded.width)
|
||||
artMinH = Math.min(artMinH, decoded.height)
|
||||
artMaxH = Math.max(artMaxH, decoded.height)
|
||||
|
||||
let opaque = 0
|
||||
for (const m of sprite.mask) if (m !== 0) opaque += 1
|
||||
memberOpaque += opaque
|
||||
memberTotal += sprite.mask.length
|
||||
check(
|
||||
opaque > 0,
|
||||
`${member} direction ${String(d)} frame ${String(f)}: no opaque pixels in ${String(sprite.width)}x${String(sprite.height)}`,
|
||||
required,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
membersDecoded += 1
|
||||
directions += dcc.directions.length
|
||||
frames += memberFrames
|
||||
bottomUpFrames += memberBottomUp
|
||||
opaquePixels += memberOpaque
|
||||
totalPixels += memberTotal
|
||||
minArtWidth = Math.min(minArtWidth, artMinW)
|
||||
maxArtWidth = Math.max(maxArtWidth, artMaxW)
|
||||
minArtHeight = Math.min(minArtHeight, artMinH)
|
||||
maxArtHeight = Math.max(maxArtHeight, artMaxH)
|
||||
minCanvasWidth = Math.min(minCanvasWidth, canvasMinW)
|
||||
maxCanvasWidth = Math.max(maxCanvasWidth, canvasMaxW)
|
||||
minCanvasHeight = Math.min(minCanvasHeight, canvasMinH)
|
||||
maxCanvasHeight = Math.max(maxCanvasHeight, canvasMaxH)
|
||||
|
||||
return {
|
||||
stats: {
|
||||
member,
|
||||
directions: dcc.directions.length,
|
||||
frames: memberFrames,
|
||||
bottomUp: memberBottomUp,
|
||||
minArtWidth: artMinW,
|
||||
maxArtWidth: artMaxW,
|
||||
minArtHeight: artMinH,
|
||||
maxArtHeight: artMaxH,
|
||||
minCanvasWidth: canvasMinW,
|
||||
maxCanvasWidth: canvasMaxW,
|
||||
minCanvasHeight: canvasMinH,
|
||||
maxCanvasHeight: canvasMaxH,
|
||||
opaquePixels: memberOpaque,
|
||||
totalPixels: memberTotal,
|
||||
},
|
||||
dcc,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print one decoded member's measurements on a fixed-width line.
|
||||
*
|
||||
* @param label - the layer description.
|
||||
* @param stats - the member's statistics.
|
||||
*/
|
||||
function reportMember(label: string, stats: MemberStats): void {
|
||||
const ratio = stats.totalPixels === 0 ? 0 : (100 * stats.opaquePixels) / stats.totalPixels
|
||||
const dim = (w: number, h: number): string => `${String(w)}x${String(h)}`
|
||||
console.log(
|
||||
` ${label.padEnd(16)} ${`${String(stats.directions)}x${String(stats.frames)}`.padStart(7)}` +
|
||||
` art ${`${dim(stats.minArtWidth, stats.minArtHeight)}`.padStart(8)}..${dim(stats.maxArtWidth, stats.maxArtHeight).padEnd(8)}` +
|
||||
` canvas ${`${dim(stats.minCanvasWidth, stats.minCanvasHeight)}`.padStart(8)}..${dim(stats.maxCanvasWidth, stats.maxCanvasHeight).padEnd(8)}` +
|
||||
` opaque ${ratio.toFixed(1).padStart(5)}% bottom-up ${String(stats.bottomUp)}`,
|
||||
)
|
||||
console.log(` ${stats.member}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a COF and every DCC its layers name.
|
||||
*
|
||||
* @param archive - the archive holding the member.
|
||||
* @param names - the archive's normalised name list.
|
||||
* @param label - the animation's label.
|
||||
* @param member - the COF member name.
|
||||
* @param root - the normalised directory prefix shared by the COF and its art.
|
||||
* @param token - the class or object code, e.g. `so`.
|
||||
* @param required - whether failures should fail the run.
|
||||
* @param captureLayer - component whose first frame is kept for the text render.
|
||||
*/
|
||||
async function checkCofAndLayers(
|
||||
archive: MpqArchive,
|
||||
names: readonly string[],
|
||||
label: string,
|
||||
member: string,
|
||||
root: string,
|
||||
token: string,
|
||||
required: boolean,
|
||||
captureLayer?: string,
|
||||
): Promise<void> {
|
||||
const cof = await readCof(archive, member, required)
|
||||
if (cof === undefined) return
|
||||
|
||||
const base = normalize(member).split('/').pop()!.replace('.cof', '')
|
||||
const animation = base.slice(token.length, token.length + 2)
|
||||
const weapon = base.slice(token.length + 2)
|
||||
console.log(`\n[${label}] ${member}`)
|
||||
console.log(
|
||||
` cof: ${String(cof.numberOfDirections)} directions x ${String(cof.framesPerDirection)} frames ` +
|
||||
`x ${String(cof.numberOfLayers)} layers, speed ${String(cof.speed)}, animation code ${animation}${weapon}`,
|
||||
)
|
||||
|
||||
// The priority table must account for every layer: anything else means a
|
||||
// layer's art is never drawn, which a screenshot would not reveal.
|
||||
for (let d = 0; d < cof.numberOfDirections; d += 1) {
|
||||
for (let f = 0; f < cof.framesPerDirection; f += 1) {
|
||||
const order = cofLayerOrder(cof, d, f)
|
||||
check(
|
||||
order.length === cof.numberOfLayers,
|
||||
`${member} direction ${String(d)} frame ${String(f)}: layer order has ${String(order.length)} of ${String(cof.numberOfLayers)} layers`,
|
||||
required,
|
||||
)
|
||||
for (const index of order) {
|
||||
check(
|
||||
index >= 0 && index < cof.numberOfLayers,
|
||||
`${member} direction ${String(d)} frame ${String(f)}: layer order names index ${String(index)}`,
|
||||
required,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` layer order dir 0 frame 0 -> [${cofLayerOrder(cof, 0, 0).join(', ')}]`)
|
||||
|
||||
const expected = { directions: cof.numberOfDirections, frames: cof.framesPerDirection }
|
||||
for (let i = 0; i < cof.layers.length; i += 1) {
|
||||
const layer = cof.layers[i]!
|
||||
const component = LAYER_COMPONENT[layer.type]
|
||||
check(component !== undefined, `${member} layer ${String(i)}: composite type ${String(layer.type)}`, required)
|
||||
if (component === undefined) continue
|
||||
const layerMember = findLayerDcc(names, root, token, animation, weapon, component)
|
||||
check(
|
||||
layerMember !== undefined,
|
||||
`${member} layer ${String(i)} (type ${String(layer.type)} → ${component}): no ${animation}${weapon}.dcc candidate`,
|
||||
required,
|
||||
)
|
||||
if (layerMember === undefined) continue
|
||||
const result = await checkDcc(archive, layerMember, expected, required)
|
||||
if (result === undefined) continue
|
||||
reportMember(`layer ${String(i)} ${component} type ${String(layer.type)}`, result.stats)
|
||||
if (captureLayer === component && renderSample === undefined) {
|
||||
renderSample = {
|
||||
member: layerMember,
|
||||
frame: result.dcc.directions[0]!.frames[0]!.frame,
|
||||
direction: 0,
|
||||
index: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a palette index to a position on the brightness ramp.
|
||||
*
|
||||
* With a real palette the value is the colour's luminance, so the rendering shows
|
||||
* the art's own shading; without one the index itself stands in for brightness,
|
||||
* which is still enough to tell a shape from noise.
|
||||
*
|
||||
* @param index - the palette index.
|
||||
* @param palette - the palette, when one could be read.
|
||||
* @returns a ramp index of 1 or more (0 is reserved for transparent pixels).
|
||||
*/
|
||||
function brightnessLevel(index: number, palette: Palette | undefined): number {
|
||||
let luminance = index
|
||||
if (palette !== undefined) {
|
||||
const at = index * 3
|
||||
luminance = 0.299 * palette.rgb[at]! + 0.587 * palette.rgb[at + 1]! + 0.114 * palette.rgb[at + 2]!
|
||||
}
|
||||
const steps = ASCII_RAMP.length - 1
|
||||
return 1 + Math.min(steps - 1, Math.floor((luminance / 256) * steps))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a frame as text.
|
||||
*
|
||||
* Images cannot be inspected in this environment, so a coarse text rendering is
|
||||
* how a human confirms the decoder produced a shape rather than noise. Sampling
|
||||
* every Nth pixel keeps the output inside the column and row caps.
|
||||
*
|
||||
* @param frame - the decoded frame.
|
||||
* @param palette - the palette, when one could be read.
|
||||
* @returns one string per sampled row.
|
||||
*/
|
||||
function renderAscii(frame: SpriteFrame, palette: Palette | undefined): string[] {
|
||||
const stepX = Math.max(1, Math.ceil(frame.width / ASCII_COLUMNS))
|
||||
const stepY = Math.max(1, Math.ceil(frame.height / ASCII_ROWS))
|
||||
const lines: string[] = []
|
||||
for (let y = 0; y < frame.height; y += stepY) {
|
||||
let line = ''
|
||||
for (let x = 0; x < frame.width; x += stepX) {
|
||||
const at = y * frame.width + x
|
||||
line += frame.mask[at] === 0 ? ' ' : ASCII_RAMP[brightnessLevel(frame.indices[at]!, palette)]!
|
||||
}
|
||||
lines.push(line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep every DCC under the Sorceress tree, counting the frames that take the
|
||||
* bottom-up path and reporting members that fail to decode.
|
||||
*
|
||||
* @param archive - the character archive.
|
||||
* @param names - its normalised name list.
|
||||
* @param palette - the palette, unused by the count but kept for symmetry.
|
||||
* @returns a one-line summary.
|
||||
*/
|
||||
async function sweepSorceress(archive: MpqArchive, names: readonly string[]): Promise<string> {
|
||||
const all = names.filter((n) => n.startsWith(SWEEP_PREFIX) && n.endsWith('.dcc'))
|
||||
const selected = quick ? all.slice(0, QUICK_LIMIT) : all
|
||||
const started = Date.now()
|
||||
let sweptFrames = 0
|
||||
let sweptBottomUp = 0
|
||||
const reasons = new Map<string, number>()
|
||||
let failed = 0
|
||||
|
||||
for (const member of selected) {
|
||||
try {
|
||||
const dcc = decodeDcc(await archive.read(archive.find(member)!))
|
||||
for (const direction of dcc.directions) {
|
||||
for (const frame of direction.frames) {
|
||||
sweptFrames += 1
|
||||
if (frame.bottomUp) sweptBottomUp += 1
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
failed += 1
|
||||
// Collapse the varying numbers so identical failures group together.
|
||||
const reason = `${member}: ${messageOf(err)}`.replace(/\d+/g, 'N')
|
||||
reasons.set(reason, (reasons.get(reason) ?? 0) + 1)
|
||||
if (reasons.size <= MAX_REPORTED_SWEEP_FAILURES) {
|
||||
console.log(` FAIL ${member}: ${messageOf(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seconds = ((Date.now() - started) / 1000).toFixed(1)
|
||||
let line = ` ${String(selected.length)} members, ${String(sweptFrames)} frames, ${String(sweptBottomUp)} bottom-up, ${String(failed)} failed (${seconds}s)`
|
||||
if (failed > reasons.size) line += `, ${String(failed - reasons.size)} more of the same`
|
||||
if (quick && all.length > selected.length) line += ` [--quick: ${String(all.length - selected.length)} members not swept]`
|
||||
return line
|
||||
}
|
||||
|
||||
console.log(`== cof + dcc verification over ${dir}${quick ? ' (quick)' : ''} ==`)
|
||||
|
||||
const characterPath = `${dir}/${CHARACTER_ARCHIVE}`
|
||||
let characters: MpqArchive
|
||||
try {
|
||||
characters = await MpqArchive.open(await fileSource(characterPath))
|
||||
} catch (err) {
|
||||
console.log(`cannot open ${characterPath}: ${messageOf(err)}`)
|
||||
process.exit(2)
|
||||
}
|
||||
const characterNames = (await characters.listFiles()).map(normalize)
|
||||
console.log(`${CHARACTER_ARCHIVE}: ${String(characterNames.length)} members listed`)
|
||||
|
||||
for (const animation of CLASS_ANIMATIONS) {
|
||||
await checkCofAndLayers(
|
||||
characters,
|
||||
characterNames,
|
||||
`sorceress ${animation.label}`,
|
||||
animation.member,
|
||||
'data/global/chars/so/',
|
||||
'so',
|
||||
true,
|
||||
animation.label === 'stand' ? 'tr' : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
let data: MpqArchive | undefined
|
||||
try {
|
||||
data = await MpqArchive.open(await fileSource(`${dir}/${DATA_ARCHIVE}`))
|
||||
} catch (err) {
|
||||
console.log(`\nnote: ${DATA_ARCHIVE} unavailable, object members skipped (${messageOf(err)})`)
|
||||
}
|
||||
|
||||
if (data !== undefined) {
|
||||
const dataNames = (await data.listFiles()).map(normalize)
|
||||
console.log(`\n${DATA_ARCHIVE}: ${String(dataNames.length)} members listed`)
|
||||
for (const animation of OBJECT_COFS) {
|
||||
await checkCofAndLayers(
|
||||
data,
|
||||
dataNames,
|
||||
animation.label,
|
||||
animation.member,
|
||||
'data/global/objects/l2/',
|
||||
'l2',
|
||||
false,
|
||||
)
|
||||
}
|
||||
console.log('\n[object sprites]')
|
||||
for (const member of OBJECT_DCCS) {
|
||||
const result = await checkDcc(data, member, undefined, false)
|
||||
if (result !== undefined) reportMember('object', result.stats)
|
||||
}
|
||||
}
|
||||
|
||||
let palette: Palette | undefined
|
||||
if (data !== undefined) {
|
||||
const palFile = data.find('data\\global\\palette\\act1\\pal.dat')
|
||||
if (palFile !== undefined) {
|
||||
try {
|
||||
palette = decodePal(await data.read(palFile))
|
||||
} catch (err) {
|
||||
console.log(`note: act 1 palette unreadable: ${messageOf(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n[sweep: sorceress art, every direction of every member]')
|
||||
console.log(await sweepSorceress(characters, characterNames))
|
||||
|
||||
console.log('\n== summary ==')
|
||||
console.log(` cofs decoded: ${String(cofsDecoded)}`)
|
||||
console.log(` dcc members: ${String(membersDecoded)}`)
|
||||
console.log(` directions: ${String(directions)}`)
|
||||
console.log(` frames: ${String(frames)}`)
|
||||
console.log(
|
||||
` art rectangles: ${`${String(minArtWidth)}x${String(minArtHeight)}`} .. ${`${String(maxArtWidth)}x${String(maxArtHeight)}`} px`,
|
||||
)
|
||||
console.log(
|
||||
` direction canvases: ${`${String(minCanvasWidth)}x${String(minCanvasHeight)}`} .. ${`${String(maxCanvasWidth)}x${String(maxCanvasHeight)}`} px`,
|
||||
)
|
||||
console.log(` bottom-up frames: ${String(bottomUpFrames)} of ${String(frames)} checked`)
|
||||
console.log(
|
||||
` opaque coverage: ${((100 * opaquePixels) / Math.max(1, totalPixels)).toFixed(1)}% of ${String(totalPixels)} canvas pixels`,
|
||||
)
|
||||
console.log(` assertions: ${String(assertions - failures)}/${String(assertions)} passed`)
|
||||
|
||||
if (renderSample !== undefined) {
|
||||
const sample = renderSample
|
||||
const stepX = Math.max(1, Math.ceil(sample.frame.width / ASCII_COLUMNS))
|
||||
const stepY = Math.max(1, Math.ceil(sample.frame.height / ASCII_ROWS))
|
||||
let opaque = 0
|
||||
for (const m of sample.frame.mask) if (m !== 0) opaque += 1
|
||||
console.log(`\n== text rendering: ${sample.member} direction ${String(sample.direction)} frame ${String(sample.index)} ==`)
|
||||
console.log(
|
||||
` ${String(sample.frame.width)}x${String(sample.frame.height)} px, sampling every ${String(stepX)}x${String(stepY)} px, ` +
|
||||
`${((100 * opaque) / sample.frame.mask.length).toFixed(1)}% opaque, ' ' = transparent, '@' = brightest`,
|
||||
)
|
||||
for (const line of renderAscii(sample.frame, palette)) console.log(` |${line}|`)
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.log(`\n${String(failures)} failures: the COF+DCC path is not usable yet`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nall required checks passed')
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
// verify-deploy.ts — 线上部署自检(不需要浏览器)
|
||||
//
|
||||
// 检查 https://www.laiseek.xyz/diablo2/ 这一套入口是否真的可用:页面、资源包索引、
|
||||
// 图集分页、以及按 HTTP range 读 .mpq 的 206;同时确认旧入口 /acts* 已经下线(410)。
|
||||
// 默认打公网域名,`--host=http://127.0.0.1` 可以改成打本机(跳过 TLS)。
|
||||
//
|
||||
// node scripts/verify-deploy.ts
|
||||
// node scripts/verify-deploy.ts --host=https://www.laiseek.xyz
|
||||
//
|
||||
// 退出码非 0 表示有断言没过。
|
||||
|
||||
export {}
|
||||
|
||||
/** 公网入口,可用 `--host=` 覆盖。 */
|
||||
const HOST = process.argv.find(argument => argument.startsWith('--host='))?.slice('--host='.length)
|
||||
?? 'https://www.laiseek.xyz'
|
||||
|
||||
/** 一个断言的结果。 */
|
||||
interface Check {
|
||||
readonly name: string
|
||||
readonly ok: boolean
|
||||
readonly detail: string
|
||||
}
|
||||
|
||||
/** 已跑过的断言。 */
|
||||
const checks: Check[] = []
|
||||
|
||||
/**
|
||||
* 记录一条断言。
|
||||
*
|
||||
* @param name - what was checked.
|
||||
* @param ok - whether it held.
|
||||
* @param detail - human-readable evidence.
|
||||
*/
|
||||
function check(name: string, ok: boolean, detail: string): void {
|
||||
checks.push({ name, ok, detail })
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${name} — ${detail}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一个 URL,返回状态码、头与正文长度;网络错误也算作结果,不抛出。
|
||||
*
|
||||
* @param path - path on the host, starting with `/`.
|
||||
* @param init - extra fetch options.
|
||||
* @returns the response facts.
|
||||
*/
|
||||
async function probe(path: string, init?: RequestInit): Promise<{ status: number; headers: Headers; text: string }> {
|
||||
try {
|
||||
const response = await fetch(`${HOST}${path}`, { redirect: 'manual', ...init })
|
||||
const text = init?.method === 'HEAD' ? '' : await response.text()
|
||||
return { status: response.status, headers: response.headers, text }
|
||||
} catch (err) {
|
||||
return { status: 0, headers: new Headers(), text: (err as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 线上自检主流程。
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
console.log(`host: ${HOST}\n`)
|
||||
|
||||
// 页面:入口要 200,且引用的资源都带 /diablo2/ 前缀(base 配错会立刻在这里暴露)
|
||||
const page = await probe('/diablo2/')
|
||||
check('/diablo2/ 200', page.status === 200, `HTTP ${String(page.status)}`)
|
||||
const assetPaths = [...page.text.matchAll(/(?:src|href)="(\/diablo2\/assets\/[^"]+)"/g)].map(match => match[1]!)
|
||||
check('页面引用 /diablo2/assets/*', assetPaths.length > 0, `${String(assetPaths.length)} 个资源引用`)
|
||||
// 三级选择器:章节 / 场景 / 细分场景。断言三个 id 都在,多了会漏、少了会错位。
|
||||
const selectors = ['id="act"', 'id="scene"', 'id="variant"']
|
||||
check('页面是地图页(含三级选择器)', selectors.every(id => page.text.includes(id)),
|
||||
`acts.html 已被入口 rewrite 命中;选择器 ${selectors.filter(id => page.text.includes(id)).length}/3`)
|
||||
check('header 上没有多余站内链接', !/<header[^>]*>[\s\S]*?<a\s/.test(page.text), 'header 只留标题与 HUD')
|
||||
if (assetPaths[0] !== undefined) {
|
||||
const asset = await probe(assetPaths[0], { method: 'HEAD' })
|
||||
check('首个资源可达', asset.status === 200, `${assetPaths[0]} → HTTP ${String(asset.status)}`)
|
||||
}
|
||||
|
||||
// 资源包索引:条数、调色板、每条 path 都有 scene.json
|
||||
const index = await probe('/diablo2/packs/index.json')
|
||||
check('资源包索引 200', index.status === 200, `HTTP ${String(index.status)}`)
|
||||
let levels: { act: number; path: string; label: string }[] = []
|
||||
let palettes: Record<string, number[]> = {}
|
||||
try {
|
||||
const parsed = JSON.parse(index.text) as { levels?: typeof levels; palettes?: typeof palettes }
|
||||
levels = parsed.levels ?? []
|
||||
palettes = parsed.palettes ?? {}
|
||||
} catch (err) {
|
||||
check('索引是 JSON', false, (err as Error).message)
|
||||
}
|
||||
check('索引是 JSON', levels.length > 0, `${String(levels.length)} 个地图块`)
|
||||
check('索引带 act 调色板', Object.keys(palettes).length >= 5,
|
||||
`${String(Object.keys(palettes).length)} 组,每组 ${String(palettes.act1?.length ?? 0)} 字节`)
|
||||
const actCounts = new Map<number, number>()
|
||||
for (const level of levels) actCounts.set(level.act, (actCounts.get(level.act) ?? 0) + 1)
|
||||
check('五个 act 都有地图', actCounts.size === 5,
|
||||
[...actCounts].sort((left, right) => left[0] - right[0]).map(([act, count]) => `act${String(act)}:${String(count)}`).join(' '))
|
||||
|
||||
// 抽第一个和最后一个地图块,确认 scene.json 与第一页 PNG 真的在
|
||||
for (const entry of [levels[0], levels[levels.length - 1]]) {
|
||||
if (entry === undefined) continue
|
||||
const scene = await probe(`/diablo2/packs/${entry.path}/scene.json`)
|
||||
check(`scene.json 200(${entry.label})`, scene.status === 200, `HTTP ${String(scene.status)}`)
|
||||
let page0: string | null = null
|
||||
try {
|
||||
const parsed = JSON.parse(scene.text) as { pages?: { file: string }[] }
|
||||
page0 = parsed.pages?.[0]?.file ?? null
|
||||
} catch { /* 上面那条断言已经失败,这里只补详情 */ }
|
||||
if (page0 !== null) {
|
||||
const png = await probe(`/diablo2/packs/${entry.path}/${page0}`, { method: 'HEAD' })
|
||||
check(`首屏图集 200(${entry.label})`, png.status === 200, `${page0} → HTTP ${String(png.status)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 归档:只暴露 .mpq,且必须支持 Range(否则页面回退到整文件下载)
|
||||
const range = await probe('/diablo2/data/d2char.mpq', { headers: { Range: 'bytes=0-1023' } })
|
||||
check('d2char.mpq Range 206', range.status === 206, `HTTP ${String(range.status)} ${range.headers.get('content-range') ?? ''}`)
|
||||
const traversal = await probe('/diablo2/data/..%2f..%2fetc%2fpasswd')
|
||||
check('归档路径穿越被拒', traversal.status !== 200, `HTTP ${String(traversal.status)}`)
|
||||
|
||||
// 旧入口下线:页面、资源包、归档三条都不该再服务内容
|
||||
for (const path of ['/acts/', '/acts-packs/index.json', '/acts-data/d2char.mpq']) {
|
||||
const gone = await probe(path)
|
||||
check(`旧入口下线 ${path}`, gone.status === 410, `HTTP ${String(gone.status)}`)
|
||||
}
|
||||
|
||||
const passed = checks.filter(entry => entry.ok).length
|
||||
console.log(`\n${String(passed)}/${String(checks.length)} passed`)
|
||||
if (passed !== checks.length) process.exitCode = 1
|
||||
}
|
||||
|
||||
await main()
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Check for the actor depth-insertion rule.
|
||||
*
|
||||
* Painter's order without a depth buffer is easy to get subtly wrong and hard to
|
||||
* eyeball — on a small map the actor's insertion point may not move at all while
|
||||
* an actor walks around. So the rule is exercised directly instead: for a wall
|
||||
* grid, an actor in each cell must land between the walls that are behind it and
|
||||
* the walls that are in front of it.
|
||||
*
|
||||
* Usage: node scripts/verify-depth-order.ts
|
||||
*/
|
||||
import { depthInsertIndex } from '../src/game/map.ts'
|
||||
|
||||
const problems: string[] = []
|
||||
const size = 4
|
||||
const walls = [] as { cellX: number; cellY: number }[]
|
||||
for (let cellY = 0; cellY < size; cellY += 1) {
|
||||
for (let cellX = 0; cellX < size; cellX += 1) walls.push({ cellX, cellY })
|
||||
}
|
||||
// Painter's order: far (small x+y) first.
|
||||
walls.sort((a, b) => (a.cellY + a.cellX) - (b.cellY + b.cellX) || a.cellY - b.cellY)
|
||||
|
||||
let checked = 0
|
||||
for (let cellY = 0; cellY < size; cellY += 1) {
|
||||
for (let cellX = 0; cellX < size; cellX += 1) {
|
||||
const at = depthInsertIndex(walls, cellX, cellY)
|
||||
const depth = cellX + cellY
|
||||
// Everything before the insertion point must be strictly nearer the camera
|
||||
// (smaller depth), everything after strictly further — ties are allowed to
|
||||
// fall either way, which is why the check is "never further before".
|
||||
for (let index = 0; index < at; index += 1) {
|
||||
const wall = walls[index]!
|
||||
if (wall.cellX + wall.cellY > depth) problems.push(`cell ${String(cellX)},${String(cellY)}: wall at index ${String(index)} is nearer but was drawn before the actor`)
|
||||
}
|
||||
for (let index = at; index < walls.length; index += 1) {
|
||||
const wall = walls[index]!
|
||||
if (wall.cellX + wall.cellY < depth) problems.push(`cell ${String(cellX)},${String(cellY)}: wall at index ${String(index)} is further but was drawn after the actor`)
|
||||
}
|
||||
checked += 1
|
||||
}
|
||||
}
|
||||
if (depthInsertIndex([], 0, 0) !== 0) problems.push('empty wall list must insert at 0')
|
||||
if (depthInsertIndex(walls, size - 1, size - 1) !== walls.length) problems.push('nearest actor must be drawn last')
|
||||
|
||||
console.log(`walls ${String(walls.length)} in painter's order`)
|
||||
console.log(`positions ${String(checked)} checked`)
|
||||
console.log(`problems ${String(problems.length)}`)
|
||||
for (const problem of problems.slice(0, 8)) console.log(` - ${problem}`)
|
||||
console.log(problems.length === 0 ? 'RESULT the actor is always inserted between the walls behind and in front of it' : 'RESULT FAILED')
|
||||
process.exit(problems.length === 0 ? 0 : 1)
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Pixel-level check for the DT1 decoder.
|
||||
*
|
||||
* The Go reference package parses DT1 headers correctly but never calls its own
|
||||
* graphics decoder, so it cannot serve as a pixel oracle. This closes that gap
|
||||
* from the other side: the fixture generator writes down the grid it encoded,
|
||||
* and the decoder must reproduce it pixel for pixel — which validates the
|
||||
* placement logic (skip runs, row advance, isometric tables, y-offset) rather
|
||||
* than merely "did not throw".
|
||||
*
|
||||
* Usage: node scripts/verify-dt1-pixels.ts <fixture-directory>
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { decodeDt1 } from '../src/formats/dt1.ts'
|
||||
|
||||
interface Expected {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly tiles: readonly { readonly format: number; readonly base: number; readonly pixels: readonly number[] }[]
|
||||
}
|
||||
|
||||
const dir = process.argv[2]
|
||||
if (dir === undefined) {
|
||||
console.error('usage: node scripts/verify-dt1-pixels.ts <fixture-directory>')
|
||||
process.exit(2)
|
||||
}
|
||||
const expected = JSON.parse(await readFile(join(dir, 'expected-dt1.json'), 'utf8')) as Expected
|
||||
const library = decodeDt1(new Uint8Array(await readFile(join(dir, 'fixture.dt1'))))
|
||||
|
||||
const problems: string[] = []
|
||||
expected.tiles.forEach((want, index) => {
|
||||
const tile = library.tiles[index]
|
||||
if (tile === undefined) { problems.push(`tile ${String(index)} missing`); return }
|
||||
const block = tile.blocks[0]
|
||||
if (block === undefined) { problems.push(`tile ${String(index)} has no blocks`); return }
|
||||
if (block.format !== want.format) problems.push(`tile ${String(index)} format ${String(block.format)} != ${String(want.format)}`)
|
||||
let mismatched = 0
|
||||
for (let at = 0; at < want.pixels.length; at += 1) {
|
||||
if ((block.pixels[at] ?? 0) !== want.pixels[at]) mismatched += 1
|
||||
}
|
||||
if (mismatched > 0) problems.push(`tile ${String(index)}: ${String(mismatched)} of ${String(want.pixels.length)} pixels differ`)
|
||||
})
|
||||
|
||||
console.log(`fixture ${join(dir, 'fixture.dt1')}`)
|
||||
console.log(`tiles ${String(library.tiles.length)}`)
|
||||
console.log(`pixels ${String(expected.tiles.reduce((n, t) => n + t.pixels.length, 0))} compared`)
|
||||
console.log(`mismatches ${String(problems.length)}`)
|
||||
for (const problem of problems.slice(0, 8)) console.log(` - ${problem}`)
|
||||
console.log(problems.length === 0 ? 'RESULT every pixel matches the encoded pattern' : 'RESULT FAILED')
|
||||
process.exit(problems.length === 0 ? 0 : 1)
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env bash
|
||||
# Format-parity harness: compare this project's TypeScript decoders with
|
||||
# independent implementations on generated fixtures.
|
||||
#
|
||||
# dc6 vs dc6png (npm) -> PNG pixels compared
|
||||
# dc6 vs OpenDiablo2/dc6 (Go) -> full JSON compared
|
||||
# ds1 vs OpenDiablo2/ds1 (Go) -> full JSON compared
|
||||
# pl2 vs OpenDiablo2/pl2 (Go) -> base/text palettes + per-table digests
|
||||
# dt1 vs OpenDiablo2/dt1 (Go) -> metadata JSON compared
|
||||
# dt1 pixel placement -> compared against the encoded grid
|
||||
#
|
||||
# The Go oracle is optional: point REFDUMP at a built reference dumper, or let
|
||||
# this script build one from a checkout. Without it, the dc6px and dt1 pixel
|
||||
# checks still run.
|
||||
#
|
||||
# Usage: bash scripts/verify-format-parity.sh [work-directory]
|
||||
set -uo pipefail
|
||||
|
||||
WORK="${1:-/tmp/d2fix}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REFDUMP="${REFDUMP:-}"
|
||||
DC6PNG="${DC6PNG:-}"
|
||||
|
||||
cd "$ROOT"
|
||||
echo "== fixtures =="
|
||||
node scripts/make-fixtures.ts "$WORK" | sed 's/^/ /'
|
||||
node scripts/make-map-fixtures.ts "$WORK" | sed 's/^/ /'
|
||||
node scripts/make-pl2-fixture.ts "$WORK" | sed 's/^/ /'
|
||||
|
||||
failures=0
|
||||
report() {
|
||||
if [ "$1" -eq 0 ]; then echo " PASS"; else echo " FAIL"; failures=$((failures + 1)); fi
|
||||
}
|
||||
|
||||
echo
|
||||
echo "== dc6: independent JS decoder (dc6png) =="
|
||||
if [ -n "$DC6PNG" ] && [ -f "$DC6PNG" ]; then
|
||||
node "$DC6PNG" -p "$WORK/palette.pal" -f "$WORK/fixture.dc6" -d "$WORK" -m png >/dev/null 2>&1
|
||||
python3 - "$WORK" <<'PY'
|
||||
import json, sys
|
||||
from PIL import Image
|
||||
work = sys.argv[1]
|
||||
exp = json.load(open(f'{work}/expected.json'))
|
||||
pal = open(f'{work}/palette.pal','rb').read()
|
||||
# dc6png reads palette bytes as BGR.
|
||||
rgb = lambda i: (pal[i*3+2], pal[i*3+1], pal[i*3])
|
||||
bad = 0
|
||||
for d in range(exp['directions']):
|
||||
for f in range(exp['framesPerDirection']):
|
||||
want = exp['frames'][d][f]
|
||||
im = Image.open(f'{work}/fixture_{d}_{f}.png').convert('RGBA')
|
||||
px = im.load()
|
||||
for y, row in enumerate(want['rows']):
|
||||
for x, idx in enumerate(row):
|
||||
r, g, b, a = px[x, y]
|
||||
if idx == 0:
|
||||
bad += 0 if a == 0 else 1
|
||||
else:
|
||||
bad += 0 if ((r, g, b) == rgb(idx) and a == 255) else 1
|
||||
print(f' pixels compared: {exp["directions"]*exp["framesPerDirection"]*8*4}, mismatches: {bad}')
|
||||
sys.exit(1 if bad else 0)
|
||||
PY
|
||||
report $?
|
||||
else
|
||||
echo " SKIP (set DC6PNG=/path/to/dc6png/src/index.js)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== dc6 / dt1 / ds1 / pl2: independent Go decoders =="
|
||||
if [ -n "$REFDUMP" ] && [ -x "$REFDUMP" ]; then
|
||||
for fmt in dc6 ds1 dt1 pl2; do
|
||||
node scripts/dump-format.ts "$fmt" "$WORK/fixture.$fmt" > "$WORK/ts.$fmt.json" 2>/dev/null
|
||||
"$REFDUMP" "$fmt" "$WORK/fixture.$fmt" > "$WORK/go.$fmt.json" 2>/dev/null
|
||||
echo " $fmt:"
|
||||
python3 - "$WORK" "$fmt" <<'PY'
|
||||
import json, sys
|
||||
work, fmt = sys.argv[1], sys.argv[2]
|
||||
a = json.load(open(f'{work}/ts.{fmt}.json'))
|
||||
b = json.load(open(f'{work}/go.{fmt}.json'))
|
||||
# The Go dt1 package never runs its graphics decoder, so its pixel arrays are
|
||||
# empty by construction; compare structure and let the pixel check cover pixels.
|
||||
if fmt == 'dt1':
|
||||
for ta, tb in zip(a['tiles'], b['tiles']):
|
||||
for ba in ta['blocks']: ba['pixels'] = []
|
||||
for bb in tb['blocks']: bb['pixels'] = []
|
||||
print(' identical:', a == b)
|
||||
sys.exit(0 if a == b else 1)
|
||||
PY
|
||||
report $?
|
||||
done
|
||||
else
|
||||
echo " SKIP (set REFDUMP=/path/to/refdump)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== dt1: pixel placement against the encoded grid =="
|
||||
node scripts/verify-dt1-pixels.ts "$WORK" | sed 's/^/ /'
|
||||
report $?
|
||||
|
||||
echo
|
||||
if [ "$failures" -eq 0 ]; then
|
||||
echo "ALL PARITY CHECKS PASSED"
|
||||
else
|
||||
echo "$failures PARITY CHECK(S) FAILED"
|
||||
fi
|
||||
exit "$failures"
|
||||
|
|
@ -0,0 +1,645 @@
|
|||
/**
|
||||
* Verify the generated levels for every non-preset level in the game.
|
||||
*
|
||||
* 70 levels are randomly generated mazes (`Levels.txt.DrlgType == 1`) and 31 are
|
||||
* wilderness (`== 3`); the other 35 are presets and are covered by
|
||||
* `verify-acts.ts`. This script builds a generator request for each of those 101
|
||||
* levels straight from the shipped tables, generates it with a fixed seed, and
|
||||
* asserts the four things that would otherwise only show up as a broken map in an
|
||||
* asset pack:
|
||||
*
|
||||
* 1. **Determinism** — two runs of the same request must hash identically. The
|
||||
* generators are seeded, so a mismatch means a stray `Math.random`, a `Map`
|
||||
* iteration that depends on insertion order somewhere it should not, or state
|
||||
* leaking between calls.
|
||||
* 2. **Connectivity** — a flood fill from the level's spawn point must reach at
|
||||
* least 90 % of the map's walkable areas. For a maze this is the whole claim of
|
||||
* the generator: sections are only ever attached to a section already placed,
|
||||
* so anything unreachable is a bug in the layout, not a design choice.
|
||||
* 3. **Resolvability** — the level must build through `buildIsoMapScene` with the
|
||||
* libraries `resolveLevelLibraries` returns, with under 1 % of tile references
|
||||
* missing. A maze piece is only usable if the level type's DT1 libraries can
|
||||
* actually draw it.
|
||||
* 4. **Parameter fidelity** — the synthesized grid must respect the parameters it
|
||||
* was built from: a maze's section size is `LvlMaze.SizeX/SizeY`, it places at
|
||||
* least `LvlMaze.Rooms` sections, and a wilderness map covers exactly
|
||||
* `floor(SizeX/8) x floor(SizeY/8)` blocks of 8 cells.
|
||||
*
|
||||
* Plus one hygiene check that needs no archives: the two generators must stay
|
||||
* browser-safe, so neither may import a `node:` builtin, reference `Buffer`, or
|
||||
* call `Math.random`.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-generators.ts [directory]
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { loadActTables, parseTable, cell, tileMemberPath, resolveLevelLibraries } from '../src/game/acts.ts'
|
||||
import type { D2Table } from '../src/game/acts.ts'
|
||||
import { decodeDs1 } from '../src/formats/ds1.ts'
|
||||
import type { Ds1 } from '../src/formats/ds1.ts'
|
||||
import { decodeDt1 } from '../src/formats/dt1.ts'
|
||||
import type { Dt1 } from '../src/formats/dt1.ts'
|
||||
import { levelSeed, buildIsoMapScene, cellAt } from '../src/game/d2map.ts'
|
||||
import type { IsoMapScene } from '../src/game/d2map.ts'
|
||||
import { generateMaze, classifyMazePieceName, inferLevelTypeName } from '../src/game/maze.ts'
|
||||
import type { MazePiece, MazePieceKind } from '../src/game/maze.ts'
|
||||
import { generateWilderness, classifySubstitutionRole } from '../src/game/wilderness.ts'
|
||||
import type { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
|
||||
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
|
||||
|
||||
/** Where the archives live by default. */
|
||||
const dir = process.argv[2] ?? 'samples/d2'
|
||||
/** Mount order: later archives override earlier ones, exactly as the game loads them. */
|
||||
const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']
|
||||
/** Blocks, in cells, used to slice a generated map into "areas" for the fill. */
|
||||
const AREA_CELLS = 8
|
||||
/** Walking must reach this share of the walkable areas. */
|
||||
const MIN_REACHABLE_SHARE = 0.9
|
||||
/** Missing tile references must stay under this share. */
|
||||
const MAX_MISSING_SHARE = 0.01
|
||||
/** Documented dimension bound for a synthesized map. */
|
||||
const MAX_CELLS_PER_SIDE = 4096
|
||||
|
||||
let checks = 0
|
||||
let failures = 0
|
||||
const failureReasons: string[] = []
|
||||
|
||||
/**
|
||||
* Record one assertion.
|
||||
*
|
||||
* @param ok - whether it held.
|
||||
* @param level - the level label.
|
||||
* @param message - what was checked.
|
||||
*/
|
||||
function check(ok: boolean, level: string, message: string): void {
|
||||
checks += 1
|
||||
if (!ok) {
|
||||
failures += 1
|
||||
failureReasons.push(`${level}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* A stable hash
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* FNV-1a over a stream of numbers.
|
||||
*
|
||||
* Used instead of serializing a whole map to a string because a 240x48 map has
|
||||
* over a hundred thousand cells and building the text would cost more than the
|
||||
* comparison is worth.
|
||||
*/
|
||||
class Hasher {
|
||||
private hash = 2166136261
|
||||
|
||||
/**
|
||||
* Mix one number in.
|
||||
*
|
||||
* @param value - the value.
|
||||
* @returns this, for chaining.
|
||||
*/
|
||||
push(value: number): this {
|
||||
let word = Math.trunc(value) | 0
|
||||
for (let byte = 0; byte < 4; byte += 1) {
|
||||
this.hash ^= (word >>> (byte * 8)) & 0xff
|
||||
this.hash = Math.imul(this.hash, 16777619) >>> 0
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* The digest.
|
||||
*
|
||||
* @returns eight hex digits.
|
||||
*/
|
||||
get digest(): string {
|
||||
return (this.hash >>> 0).toString(16).padStart(8, '0')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a decoded map into a canonical digest.
|
||||
*
|
||||
* Every field that could differ between two runs is folded in, including the
|
||||
* layer counts and the objects' sub-tile coordinates, so two maps that look
|
||||
* alike but stamp their objects differently hash differently.
|
||||
*
|
||||
* @param level - the map.
|
||||
* @returns the digest.
|
||||
*/
|
||||
function canonicalHash(level: Ds1): string {
|
||||
const hasher = new Hasher()
|
||||
hasher.push(level.version).push(level.width).push(level.height).push(level.act)
|
||||
hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers)
|
||||
for (let y = 0; y < level.height; y += 1) {
|
||||
const row = level.cells[y]
|
||||
if (row === undefined) continue
|
||||
for (let x = 0; x < level.width; x += 1) {
|
||||
const cellRecord = row[x]
|
||||
if (cellRecord === undefined) continue
|
||||
for (const wall of cellRecord.walls) {
|
||||
hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0)
|
||||
}
|
||||
for (const floor of cellRecord.floors) {
|
||||
hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0)
|
||||
}
|
||||
for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style)
|
||||
for (const substitution of cellRecord.substitutions) hasher.push(substitution.value)
|
||||
}
|
||||
}
|
||||
for (const object of level.objects) hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags)
|
||||
return hasher.digest
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Connectivity
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** A walkable area, keyed by its top-left cell. */
|
||||
interface Area {
|
||||
readonly key: number
|
||||
readonly subTile: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice a map into areas and find one open sub-tile per area.
|
||||
*
|
||||
* An area is an 8x8-cell block. A block counts only when it contains at least one
|
||||
* cell that carries a floor, and its representative sub-tile is the first
|
||||
* unblocked sub-tile in it — so a block that is entirely wall is neither expected
|
||||
* to be reached nor counted against the generator.
|
||||
*
|
||||
* @param level - the map.
|
||||
* @param scene - the built scene.
|
||||
* @returns the representative sub-tile index of every walkable area.
|
||||
*/
|
||||
function walkableAreas(level: Ds1, scene: IsoMapScene): Area[] {
|
||||
const areas: Area[] = []
|
||||
const blocksX = Math.ceil(level.width / AREA_CELLS)
|
||||
const blocksY = Math.ceil(level.height / AREA_CELLS)
|
||||
for (let by = 0; by < blocksY; by += 1) {
|
||||
for (let bx = 0; bx < blocksX; bx += 1) {
|
||||
let hasFloor = false
|
||||
let representative = -1
|
||||
for (let cy = by * AREA_CELLS; cy < Math.min((by + 1) * AREA_CELLS, level.height) && representative === -1; cy += 1) {
|
||||
const row = level.cells[cy]
|
||||
if (row === undefined) continue
|
||||
for (let cx = bx * AREA_CELLS; cx < Math.min((bx + 1) * AREA_CELLS, level.width) && representative === -1; cx += 1) {
|
||||
const cellRecord = row[cx]
|
||||
if (cellRecord === undefined) continue
|
||||
for (const floor of cellRecord.floors) {
|
||||
if (!floor.hidden && floor.prop1 !== 0) { hasFloor = true; break }
|
||||
}
|
||||
for (let sy = 0; sy < SUB_TILES_PER_TILE && representative === -1; sy += 1) {
|
||||
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
|
||||
const gx = cx * SUB_TILES_PER_TILE + sx
|
||||
const gy = cy * SUB_TILES_PER_TILE + sy
|
||||
if (gx >= scene.gridWidth || gy >= scene.gridHeight) continue
|
||||
if (scene.blocked[gy * scene.gridWidth + gx] === 1) continue
|
||||
representative = gy * scene.gridWidth + gx
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasFloor && representative !== -1) areas.push({ key: by * blocksX + bx, subTile: representative })
|
||||
}
|
||||
}
|
||||
return areas
|
||||
}
|
||||
|
||||
/**
|
||||
* Flood fill the scene's sub-tile grid from a starting sub-tile.
|
||||
*
|
||||
* @param scene - the built scene.
|
||||
* @param start - the starting sub-tile index.
|
||||
* @returns the set of reachable sub-tile indices.
|
||||
*/
|
||||
function floodFill(scene: IsoMapScene, start: number): Set<number> {
|
||||
const seen = new Set<number>([start])
|
||||
const queue = [start]
|
||||
while (queue.length > 0) {
|
||||
const at = queue.pop()!
|
||||
const x = at % scene.gridWidth
|
||||
const y = (at - x) / scene.gridWidth
|
||||
const neighbours: readonly (readonly [number, number])[] = [[1, 0], [-1, 0], [0, 1], [0, -1]]
|
||||
for (const [dx, dy] of neighbours) {
|
||||
const nx = x + dx
|
||||
const ny = y + dy
|
||||
if (nx < 0 || ny < 0 || nx >= scene.gridWidth || ny >= scene.gridHeight) continue
|
||||
const next = ny * scene.gridWidth + nx
|
||||
if (scene.blocked[next] === 1 || seen.has(next)) continue
|
||||
seen.add(next)
|
||||
queue.push(next)
|
||||
}
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the sub-tile nearest the map's centre that is open.
|
||||
*
|
||||
* Mirrors `findIsoSpawn`'s spiral, but returns a grid index because the fill
|
||||
* works on the collision grid rather than on scene pixels.
|
||||
*
|
||||
* @param scene - the built scene.
|
||||
* @returns the sub-tile index, or -1 when nothing is walkable.
|
||||
*/
|
||||
function findOpenSubTile(scene: IsoMapScene): number {
|
||||
const centreX = Math.floor(scene.cellsX / 2) * SUB_TILES_PER_TILE
|
||||
const centreY = Math.floor(scene.cellsY / 2) * SUB_TILES_PER_TILE
|
||||
const limit = Math.max(scene.gridWidth, scene.gridHeight)
|
||||
for (let radius = 0; radius < limit; radius += 1) {
|
||||
for (let dy = -radius; dy <= radius; dy += 1) {
|
||||
for (let dx = -radius; dx <= radius; dx += 1) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
|
||||
const gx = centreX + dx
|
||||
const gy = centreY + dy
|
||||
if (gx < 0 || gy < 0 || gx >= scene.gridWidth || gy >= scene.gridHeight) continue
|
||||
const index = gy * scene.gridWidth + gx
|
||||
if (scene.blocked[index] !== 1) return index
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the connectivity check.
|
||||
*
|
||||
* @param level - the map.
|
||||
* @param scene - the built scene.
|
||||
* @returns the shares and counts, for printing.
|
||||
*/
|
||||
function connectivity(level: Ds1, scene: IsoMapScene): { share: number; reached: number; total: number } {
|
||||
const areas = walkableAreas(level, scene)
|
||||
const start = findOpenSubTile(scene)
|
||||
if (start === -1 || areas.length === 0) return { share: 0, reached: 0, total: areas.length }
|
||||
const reachedTiles = floodFill(scene, start)
|
||||
let reached = 0
|
||||
for (const area of areas) if (reachedTiles.has(area.subTile)) reached += 1
|
||||
return { share: reached / areas.length, reached, total: areas.length }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Table plumbing
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** Decoded DS1s, keyed by member path, so a piece shared by many levels decodes once. */
|
||||
const ds1Cache = new Map<string, Ds1>()
|
||||
|
||||
/**
|
||||
* Decode a member once.
|
||||
*
|
||||
* @param archives - the mounted archives.
|
||||
* @param relative - the tile-relative path from a table cell.
|
||||
* @returns the map.
|
||||
*/
|
||||
async function loadDs1(archives: MountedArchives, relative: string): Promise<Ds1> {
|
||||
const member = tileMemberPath(relative)
|
||||
const cached = ds1Cache.get(member)
|
||||
if (cached !== undefined) return cached
|
||||
const decoded = decodeDs1(await archives.read(member))
|
||||
ds1Cache.set(member, decoded)
|
||||
return decoded
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a level type's DT1 libraries.
|
||||
*
|
||||
* @param archives - the mounted archives.
|
||||
* @param names - full member paths, in file-list order.
|
||||
* @returns the libraries.
|
||||
*/
|
||||
async function decodeLibraries(archives: MountedArchives, names: readonly string[]): Promise<Dt1[]> {
|
||||
const libraries: Dt1[] = []
|
||||
for (const name of names) libraries.push(decodeDt1(await archives.read(name)))
|
||||
return libraries
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a row's decoded `File1..File6` variants.
|
||||
*
|
||||
* @param archives - the mounted archives.
|
||||
* @param table - the table the row belongs to.
|
||||
* @param row - the row.
|
||||
* @returns the decoded maps, in file order.
|
||||
*/
|
||||
async function rowDs1s(archives: MountedArchives, table: D2Table, row: readonly string[]): Promise<Ds1[]> {
|
||||
const levels: Ds1[] = []
|
||||
for (let slot = 1; slot <= 6; slot += 1) {
|
||||
const value = cell(table, row, `File${String(slot)}`)
|
||||
if (value === '' || value === '0') continue
|
||||
levels.push(await loadDs1(archives, value))
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one of the five per-theme parameter groups.
|
||||
*
|
||||
* @param table - the `LvlSub` table.
|
||||
* @param row - the row.
|
||||
* @param prefix - `Prob`, `Trials` or `Max`.
|
||||
* @returns the five values, in theme order.
|
||||
*/
|
||||
function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] {
|
||||
const values: number[] = []
|
||||
for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0)
|
||||
return values
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Request builders
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** Which `LvlPrest` name families belong to which wilderness level type. */
|
||||
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||||
'Act 1 - Wilderness': ['Act 1 - Wild'],
|
||||
'Act 2 - Desert': ['Act 2 - Desert'],
|
||||
'Act 3 - Jungle': ['Act 3 - Jungle'],
|
||||
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal'],
|
||||
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
|
||||
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
|
||||
'Act 5 - Siege': ['Act 5 - Siege'],
|
||||
'Act 5 - Barricade': ['Act 5 - Barricade'],
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the maze pieces for a level type.
|
||||
*
|
||||
* @param archives - the mounted archives.
|
||||
* @param lvlprest - the `LvlPrest` table.
|
||||
* @param levelTypeName - the level type's name.
|
||||
* @param levelTypeId - the level type's id.
|
||||
* @returns the pieces.
|
||||
*/
|
||||
async function mazePieces(
|
||||
archives: MountedArchives,
|
||||
lvlprest: D2Table,
|
||||
levelTypeName: string,
|
||||
levelTypeId: string,
|
||||
): Promise<MazePiece[]> {
|
||||
const pieces: MazePiece[] = []
|
||||
for (const row of lvlprest.rows) {
|
||||
// Maze pieces are the rows whose level type matches; `LevelId` is 0 for all
|
||||
// of them, so the join is by level type id plus the name prefix.
|
||||
if (cell(lvlprest, row, 'LevelId') !== '0' && cell(lvlprest, row, 'LevelId') !== '') continue
|
||||
const name = cell(lvlprest, row, 'Name')
|
||||
const classified = classifyMazePieceName(name, levelTypeName)
|
||||
if (classified === null) continue
|
||||
const levels = await rowDs1s(archives, lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
pieces.push({ name, kind: classified.kind satisfies MazePieceKind, sides: classified.sides, levels })
|
||||
}
|
||||
void levelTypeId
|
||||
return pieces
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the wilderness pieces for a level type.
|
||||
*
|
||||
* @param archives - the mounted archives.
|
||||
* @param lvlprest - the `LvlPrest` table.
|
||||
* @param levelTypeName - the level type's name.
|
||||
* @returns the pieces.
|
||||
*/
|
||||
async function wildernessPieces(
|
||||
archives: MountedArchives,
|
||||
lvlprest: D2Table,
|
||||
levelTypeName: string,
|
||||
): Promise<WildernessPiece[]> {
|
||||
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
|
||||
const pieces: WildernessPiece[] = []
|
||||
for (const row of lvlprest.rows) {
|
||||
const name = cell(lvlprest, row, 'Name')
|
||||
if (!families.some(family => name.startsWith(family))) continue
|
||||
const levels = await rowDs1s(archives, lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
pieces.push({ name, levels, border: /border|cliff/i.test(name) })
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the wilderness substitutions for a level's `LvlSub` type.
|
||||
*
|
||||
* @param archives - the mounted archives.
|
||||
* @param lvlsub - the `LvlSub` table.
|
||||
* @param type - the `LvlSub` `Type` to select.
|
||||
* @returns the rows.
|
||||
*/
|
||||
async function substitutions(archives: MountedArchives, lvlsub: D2Table, type: number): Promise<WildernessSubstitution[]> {
|
||||
if (type < 0) return []
|
||||
const rows: WildernessSubstitution[] = []
|
||||
for (const row of lvlsub.rows) {
|
||||
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
|
||||
const file = cell(lvlsub, row, 'File')
|
||||
if (file === '' || file === '0') continue
|
||||
const levels = [await loadDs1(archives, file)]
|
||||
rows.push({
|
||||
name: cell(lvlsub, row, 'Name'),
|
||||
type,
|
||||
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
|
||||
bordType: Number(cell(lvlsub, row, 'BordType')),
|
||||
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
|
||||
prob: themeValues(lvlsub, row, 'Prob'),
|
||||
trials: themeValues(lvlsub, row, 'Trials'),
|
||||
max: themeValues(lvlsub, row, 'Max'),
|
||||
levels,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Hygiene
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** The generators that must stay browser-safe. */
|
||||
const BROWSER_SAFE_FILES = ['src/game/maze.ts', 'src/game/wilderness.ts']
|
||||
|
||||
/**
|
||||
* Assert the generators import nothing Node-only.
|
||||
*
|
||||
* `src/` is bundled for the browser, so a `node:fs` import or a `Buffer`
|
||||
* reference would only fail once the page was loaded. Catching it here is cheap.
|
||||
*/
|
||||
function checkHygiene(): void {
|
||||
for (const path of BROWSER_SAFE_FILES) {
|
||||
const source = readFileSync(path, 'utf8')
|
||||
check(!/from\s+['"]node:/.test(source), path, 'imports a node: builtin')
|
||||
check(!/\bBuffer\b/.test(source), path, 'references Buffer')
|
||||
check(!/Math\.random/.test(source), path, 'calls Math.random')
|
||||
check(!/\bprocess\.env\b/.test(source), path, 'reads process.env')
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Main
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
checkHygiene()
|
||||
|
||||
const archives = new MountedArchives()
|
||||
for (const name of MOUNTS) {
|
||||
try {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`${dir}/${name}`)))
|
||||
} catch (err) {
|
||||
console.log(`skip ${name}: ${String(err)}`)
|
||||
}
|
||||
}
|
||||
if (archives.size === 0) {
|
||||
console.log(`no archives found in ${dir}`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const tables = await loadActTables(archives)
|
||||
const lvlmaze = parseTable(await archives.read('data\\global\\excel\\LvlMaze.txt'))
|
||||
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
|
||||
|
||||
/** The level type row for a level row. */
|
||||
function levelType(levelRow: readonly string[]): { id: string; name: string } {
|
||||
const id = cell(tables.levels, levelRow, 'LevelType')
|
||||
const row = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === id)
|
||||
return { id, name: row === undefined ? '' : cell(tables.lvltypes, row, 'Name') }
|
||||
}
|
||||
|
||||
/** The `LvlMaze` row for a level, by id then by name. */
|
||||
function mazeRow(levelId: number, levelName: string): readonly string[] | undefined {
|
||||
return lvlmaze.rows.find(row => Number(cell(lvlmaze, row, 'Level')) === levelId)
|
||||
?? lvlmaze.rows.find(row => cell(lvlmaze, row, 'Name') === levelName)
|
||||
}
|
||||
|
||||
const mazeLevels: { id: number; name: string }[] = []
|
||||
const wildLevels: { id: number; name: string }[] = []
|
||||
for (const row of tables.levels.rows) {
|
||||
const drlg = cell(tables.levels, row, 'DrlgType')
|
||||
const id = Number(cell(tables.levels, row, 'Id'))
|
||||
const name = cell(tables.levels, row, 'Name')
|
||||
if (drlg === '1') mazeLevels.push({ id, name })
|
||||
else if (drlg === '3') wildLevels.push({ id, name })
|
||||
}
|
||||
|
||||
console.log(`== generators ==`)
|
||||
console.log(` ${String(mazeLevels.length)} maze levels (DrlgType 1), ${String(wildLevels.length)} wilderness levels (DrlgType 3)`)
|
||||
|
||||
let mazePassed = 0
|
||||
let wildPassed = 0
|
||||
|
||||
console.log(`\n== maze levels (DrlgType 1) ==`)
|
||||
console.log(' id name type sect rooms map hash reach missing')
|
||||
for (const { id, name } of mazeLevels) {
|
||||
const label = `${String(id)} ${name}`
|
||||
try {
|
||||
const row = mazeRow(id, name)
|
||||
if (row === undefined) throw new Error('no LvlMaze.txt row')
|
||||
const type = levelType(tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === id)!)
|
||||
const sectionX = Number(cell(lvlmaze, row, 'SizeX'))
|
||||
const sectionY = Number(cell(lvlmaze, row, 'SizeY'))
|
||||
const minRooms = Number(cell(lvlmaze, row, 'Rooms'))
|
||||
const merge = Number(cell(lvlmaze, row, 'Merge'))
|
||||
const pieces = await mazePieces(archives, tables.lvlprest, type.name, type.id)
|
||||
const seed = 0x5eed_0000 + id
|
||||
const request = {
|
||||
levelId: id, levelName: name, levelTypeName: type.name,
|
||||
sectionSize: sectionX, sectionHeight: sectionY,
|
||||
minRooms, merge, seed, pieces,
|
||||
}
|
||||
const first = generateMaze(request)
|
||||
const second = generateMaze(request)
|
||||
const hashA = canonicalHash(first.level)
|
||||
const hashB = canonicalHash(second.level)
|
||||
const libraries = resolveLevelLibraries(tables, id)
|
||||
const scene = buildIsoMapScene(first.level, await decodeLibraries(archives, libraries.dt1Names), levelSeed(libraries.dt1Names[0] ?? "generated"))
|
||||
const totalRefs = scene.floors.length + scene.walls.length + scene.missingTiles
|
||||
const missingShare = totalRefs === 0 ? 1 : scene.missingTiles / totalRefs
|
||||
const fill = connectivity(first.level, scene)
|
||||
|
||||
check(hashA === hashB, label, `not deterministic (${hashA} vs ${hashB})`)
|
||||
check(pieces.length > 0, label, 'no pieces resolved')
|
||||
check(first.level.width > 0 && first.level.height > 0, label, 'empty map')
|
||||
check(first.level.width <= MAX_CELLS_PER_SIDE && first.level.height <= MAX_CELLS_PER_SIDE, label, `map ${String(first.level.width)}x${String(first.level.height)} exceeds the side bound`)
|
||||
check(Number(first.stats.sectionX) === sectionX && Number(first.stats.sectionY) === sectionY, label, `section size ${String(first.stats.sectionX)}x${String(first.stats.sectionY)} != LvlMaze ${String(sectionX)}x${String(sectionY)}`)
|
||||
check(Number(first.stats.roomsPlaced) >= minRooms, label, `placed ${String(first.stats.roomsPlaced)} sections, LvlMaze.Rooms is ${String(minRooms)}`)
|
||||
check(missingShare <= MAX_MISSING_SHARE, label, `${(missingShare * 100).toFixed(2)}% of tile references are missing`)
|
||||
check(fill.share >= MIN_REACHABLE_SHARE, label, `only ${(fill.share * 100).toFixed(1)}% of walkable areas are reachable (${String(fill.reached)}/${String(fill.total)})`)
|
||||
if (hashA === hashB && fill.share >= MIN_REACHABLE_SHARE && missingShare <= MAX_MISSING_SHARE) mazePassed += 1
|
||||
|
||||
console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} ${type.name.padEnd(20)} ${String(sectionX).padStart(4)} ${String(Number(first.stats.roomsPlaced)).padStart(6)} ${`${String(first.level.width)}x${String(first.level.height)}`.padEnd(10)} ${hashA} ${(fill.share * 100).toFixed(1).padStart(5)}% ${(missingShare * 100).toFixed(2).padStart(6)}%`)
|
||||
} catch (err) {
|
||||
check(false, label, `threw: ${err instanceof Error ? err.message : String(err)}`)
|
||||
console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} FAILED: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n== wilderness levels (DrlgType 3) ==`)
|
||||
console.log(' id name type size blocks hash reach missing')
|
||||
for (const { id, name } of wildLevels) {
|
||||
const label = `${String(id)} ${name}`
|
||||
try {
|
||||
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === id)!
|
||||
const type = levelType(levelRow)
|
||||
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
|
||||
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
|
||||
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
|
||||
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
|
||||
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
|
||||
const pieces = await wildernessPieces(archives, tables.lvlprest, type.name)
|
||||
const rows = await substitutions(archives, lvlsub, subType)
|
||||
const shrineRows = await substitutions(archives, lvlsub, subShrine)
|
||||
const seed = 0x5eed_1000 + id
|
||||
const request = {
|
||||
levelId: id, levelName: name, levelTypeName: type.name,
|
||||
sizeX, sizeY, subType, subTheme: Math.max(0, subTheme), seed,
|
||||
pieces, substitutions: rows, shrineSubstitutions: shrineRows,
|
||||
}
|
||||
const first = generateWilderness(request)
|
||||
const second = generateWilderness(request)
|
||||
const hashA = canonicalHash(first.level)
|
||||
const hashB = canonicalHash(second.level)
|
||||
const libraries = resolveLevelLibraries(tables, id)
|
||||
const dt1s = await decodeLibraries(archives, libraries.dt1Names)
|
||||
const scene = buildIsoMapScene(first.level, dt1s, levelSeed("generated"))
|
||||
const totalRefs = scene.floors.length + scene.walls.length + scene.missingTiles
|
||||
const missingShare = totalRefs === 0 ? 1 : scene.missingTiles / totalRefs
|
||||
const fill = connectivity(first.level, scene)
|
||||
|
||||
const blockGrid = first.stats.blockGrid as { width: number; height: number }
|
||||
const expectedWidth = Number(first.stats.sizeX)
|
||||
const expectedHeight = Number(first.stats.sizeY)
|
||||
const expectedBlocksX = Math.floor(expectedWidth / AREA_CELLS)
|
||||
const expectedBlocksY = Math.floor(expectedHeight / AREA_CELLS)
|
||||
|
||||
check(hashA === hashB, label, `not deterministic (${hashA} vs ${hashB})`)
|
||||
check(pieces.length > 0, label, 'no pieces resolved')
|
||||
check(first.level.width > 0 && first.level.height > 0, label, 'empty map')
|
||||
check(first.level.width <= MAX_CELLS_PER_SIDE && first.level.height <= MAX_CELLS_PER_SIDE, label, `map ${String(first.level.width)}x${String(first.level.height)} exceeds the side bound`)
|
||||
check(blockGrid.width === expectedBlocksX && blockGrid.height === expectedBlocksY, label, `block grid ${String(blockGrid.width)}x${String(blockGrid.height)} != floor(size/8) ${String(expectedBlocksX)}x${String(expectedBlocksY)}`)
|
||||
check(first.level.width === expectedBlocksX * AREA_CELLS && first.level.height === expectedBlocksY * AREA_CELLS, label, 'map extent does not match whole blocks of the declared size')
|
||||
check(missingShare <= MAX_MISSING_SHARE, label, `${(missingShare * 100).toFixed(2)}% of tile references are missing`)
|
||||
check(fill.share >= MIN_REACHABLE_SHARE, label, `only ${(fill.share * 100).toFixed(1)}% of walkable areas are reachable (${String(fill.reached)}/${String(fill.total)})`)
|
||||
if (hashA === hashB && fill.share >= MIN_REACHABLE_SHARE && missingShare <= MAX_MISSING_SHARE) wildPassed += 1
|
||||
|
||||
console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} ${type.name.padEnd(20)} ${`${String(first.stats.sizeX)}x${String(first.stats.sizeY)}`.padEnd(10)} ${`${String(blockGrid.width)}x${String(blockGrid.height)}`.padEnd(8)} ${hashA} ${(fill.share * 100).toFixed(1).padStart(5)}% ${(missingShare * 100).toFixed(2).padStart(6)}%`)
|
||||
} catch (err) {
|
||||
check(false, label, `threw: ${err instanceof Error ? err.message : String(err)}`)
|
||||
console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} FAILED: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n== summary ==`)
|
||||
console.log(` maze ${String(mazePassed)}/${String(mazeLevels.length)} passed`)
|
||||
console.log(` wilderness ${String(wildPassed)}/${String(wildLevels.length)} passed`)
|
||||
console.log(` checks ${String(checks - failures)}/${String(checks)} assertions held`)
|
||||
if (failures > 0) {
|
||||
console.log(`\n ${String(failures)} failure(s):`)
|
||||
for (const reason of failureReasons.slice(0, 60)) console.log(` - ${reason}`)
|
||||
if (failureReasons.length > 60) console.log(` ... and ${String(failureReasons.length - 60)} more`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nall generator checks passed')
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* End-to-end check of the MPQ decoders against the real Diablo II archives.
|
||||
*
|
||||
* The whole point of this script is that it refuses to grade its own homework:
|
||||
* every assertion is about bytes that came out of a Blizzard archive the user
|
||||
* supplied, not about a fixture we also generated.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-implode.ts [directory] [--quick] [--archive <name>]
|
||||
*
|
||||
* Exits non-zero when a codec this project claims to implement fails on real
|
||||
* data. Codecs it does not implement yet (ADPCM/Huffmann audio, bzip2, sparse)
|
||||
* are reported as *unimplemented*, with counts — never silently ignored, and
|
||||
* never counted as a pass.
|
||||
*/
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { COMPRESSION_PKWARE } from '../src/mpq/decompress.ts'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const dir = args.find((a) => !a.startsWith('--')) ?? 'samples/d2'
|
||||
const quick = args.includes('--quick')
|
||||
const only = args.flatMap((a, i) => (a === '--archive' ? [args[i + 1] ?? ''] : []))
|
||||
/** Cap per archive in `--quick` mode, so the script is usable in a loop. */
|
||||
const QUICK_LIMIT = 400
|
||||
|
||||
/** Archives this project cares about, in mount order. */
|
||||
const ARCHIVES = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq', 'd2char.mpq']
|
||||
|
||||
/** Raised classification of one member failure. */
|
||||
type FailureKind = 'implode' | 'unimplemented-codec' | 'missing-key' | 'other'
|
||||
|
||||
/**
|
||||
* Classify a member read failure.
|
||||
*
|
||||
* @param err - the thrown error.
|
||||
* @returns the kind, plus a label for the report.
|
||||
*/
|
||||
function classify(err: unknown): { kind: FailureKind; label: string } {
|
||||
const mask = (err as { mask?: unknown }).mask
|
||||
const message = String((err as { message?: unknown }).message ?? err)
|
||||
if (typeof mask === 'number') {
|
||||
if (mask === COMPRESSION_PKWARE) return { kind: 'implode', label: 'pkware implode' }
|
||||
const names: string[] = []
|
||||
if (mask & 0x01) names.push('huffmann')
|
||||
if (mask & 0x10) names.push('bzip2')
|
||||
if (mask & 0x20) names.push('sparse')
|
||||
if (mask & 0x40) names.push('adpcm-mono')
|
||||
if (mask & 0x80) names.push('adpcm-stereo')
|
||||
return { kind: 'unimplemented-codec', label: `mask 0x${mask.toString(16)} (${names.join('+') || 'unknown'})` }
|
||||
}
|
||||
if (/reversed extent|beyond/.test(message)) return { kind: 'missing-key', label: 'unnamed encrypted member (no name → no key)' }
|
||||
return { kind: 'other', label: message.slice(0, 70) }
|
||||
}
|
||||
|
||||
let checks = 0
|
||||
let hardFailures = 0
|
||||
let totalMembers = 0
|
||||
let totalBytes = 0
|
||||
const unimplemented = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* Assert one expectation.
|
||||
*
|
||||
* @param ok - whether it held.
|
||||
* @param message - what was checked.
|
||||
*/
|
||||
function check(ok: boolean, message: string): void {
|
||||
checks += 1
|
||||
if (!ok) hardFailures += 1
|
||||
console.log(` ${ok ? 'ok ' : 'FAIL'} ${message}`)
|
||||
}
|
||||
|
||||
const wanted = only.length > 0 ? only : ARCHIVES
|
||||
console.log(`== real archives in ${dir} (${String(wanted.length)} selected) ==`)
|
||||
|
||||
for (const name of wanted) {
|
||||
const path = `${dir}/${name}`
|
||||
let archive: MpqArchive
|
||||
try {
|
||||
archive = await MpqArchive.open(await fileSource(path))
|
||||
} catch (err) {
|
||||
console.log(`\n-- ${name}\n skip: ${String(err)}`)
|
||||
continue
|
||||
}
|
||||
const names = await archive.nameIndex()
|
||||
let files = archive.files(names)
|
||||
if (quick && files.length > QUICK_LIMIT) files = files.slice(0, QUICK_LIMIT)
|
||||
|
||||
let ok = 0
|
||||
let bytes = 0
|
||||
const local = new Map<string, number>()
|
||||
for (const file of files) {
|
||||
try {
|
||||
const decoded = await archive.read(file)
|
||||
ok += 1
|
||||
bytes += decoded.byteLength
|
||||
} catch (err) {
|
||||
const { kind, label } = classify(err)
|
||||
if (kind === 'implode' || kind === 'other') hardFailures += 1
|
||||
if (kind === 'unimplemented-codec') unimplemented.set(label, (unimplemented.get(label) ?? 0) + 1)
|
||||
local.set(`${kind === 'missing-key' ? 'no-key' : kind}: ${label}`, (local.get(`${kind === 'missing-key' ? 'no-key' : kind}: ${label}`) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
totalMembers += files.length
|
||||
totalBytes += bytes
|
||||
console.log(`\n-- ${name}`)
|
||||
console.log(` members ${String(ok)}/${String(files.length)} decoded, ${(bytes / 1048576).toFixed(1)} MB out of ${(archive.header.archiveSize / 1048576).toFixed(1)} MB on disk`)
|
||||
for (const [label, count] of [...local].sort((a, b) => b[1] - a[1])) {
|
||||
const kind = label.startsWith('implode') || label.startsWith('other') ? 'FAIL' : 'info'
|
||||
console.log(` ${kind.padEnd(4)} ${String(count).padStart(5)} ${label}`)
|
||||
}
|
||||
console.log(` names ${String(archive.files().length - names.size)} of ${String(archive.files().length)} block slots have no name`)
|
||||
check(ok > 0, `${name}: at least one member decoded`)
|
||||
|
||||
// Content assertions: a decoder that produces the right *length* but the wrong
|
||||
// bytes would still be useless, so look for known strings in known members.
|
||||
const levels = archive.find('data\\global\\excel\\levels.txt')
|
||||
if (levels !== undefined) {
|
||||
const text = Buffer.from(await archive.read(levels)).toString('latin1')
|
||||
check(text.includes('LevelName'), `${name}: levels.txt decodes to a real header row`)
|
||||
check(text.includes('Act 1 - Town'), `${name}: levels.txt names the Act 1 town`)
|
||||
}
|
||||
if (name === 'd2data.mpq') {
|
||||
const count = names.size
|
||||
check(count > 10_000, `d2data.mpq: (listfile) decodes to ${String(count)} names`)
|
||||
}
|
||||
if (name === 'd2char.mpq') {
|
||||
const so = archive.find('data\\global\\chars\\so\\hd\\sohdbhma11hs.dcc')
|
||||
if (so !== undefined) {
|
||||
const head = await archive.read(so)
|
||||
check(
|
||||
head[0] === 0x74 && head[1] === 0x06 && head[2] === 0x10 && head[3] === 0x14,
|
||||
'd2char.mpq: a Sorceress DCC begins with the DCC signature',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n== summary ==')
|
||||
console.log(` ${String(totalMembers)} members read, ${(totalBytes / 1048576).toFixed(1)} MB decoded`)
|
||||
if (unimplemented.size > 0) {
|
||||
console.log(' not implemented yet (reported, not counted as pass):')
|
||||
for (const [label, count] of [...unimplemented].sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${String(count).padStart(5)} ${label}`)
|
||||
}
|
||||
}
|
||||
console.log(` ${String(checks - hardFailures)}/${String(checks)} assertions passed`)
|
||||
if (hardFailures > 0) {
|
||||
console.log(` ${String(hardFailures)} hard failures: a codec that claims to work did not`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* M3 item checks: tables, affix rolling, inventory and drops, headless.
|
||||
*
|
||||
* Item systems fail in ways that are tedious to catch by playing: an affix that
|
||||
* rolls on a type it should not, a stack that silently discards a potion, an
|
||||
* item that occupies cells it never clears, or a drop table that is not
|
||||
* reproducible. Each of those is a plain assertion here instead.
|
||||
*
|
||||
* Usage: node scripts/verify-items.ts
|
||||
*/
|
||||
import {
|
||||
Inventory, affixEligible, affixesFromTable, createItem, goldItem, itemBasesFromTable,
|
||||
rollAffix, rollDrop, totalStat,
|
||||
} from '../src/game/items.ts'
|
||||
import type { Affix, ItemBase } from '../src/game/items.ts'
|
||||
import { Rng } from '../src/game/rng.ts'
|
||||
import { parseTable } from '../src/game/tables.ts'
|
||||
|
||||
const problems: string[] = []
|
||||
let checks = 0
|
||||
|
||||
/**
|
||||
* Assert one condition.
|
||||
*
|
||||
* @param condition - the condition to hold.
|
||||
* @param description - what it means.
|
||||
*/
|
||||
function expect(condition: boolean, description: string): void {
|
||||
checks += 1
|
||||
if (!condition) problems.push(description)
|
||||
}
|
||||
|
||||
// --- tables -----------------------------------------------------------------
|
||||
|
||||
const weapons = itemBasesFromTable(parseTable([
|
||||
'Id\tName\tType\tInvWidth\tInvHeight\tDamage\tValue\tLevel\tMaxStack',
|
||||
'swd\tShort Sword\tweap\t1\t3\t5\t30\t1\t1',
|
||||
'bsc\tBuckler\tarmo\t2\t2\t\t12\t1\t1\t\t',
|
||||
'lsw\tLong Sword\tweap\t1\t3\t12\t120\t8\t1',
|
||||
'axe\tHand Axe\tweap\t1\t3\t8\t60\t\t1',
|
||||
].join('\n')), 'weapon')
|
||||
expect(weapons.length === 4, 'every weapon row becomes a base')
|
||||
expect(weapons[0]?.invWidth === 1 && weapons[0]?.invHeight === 3, 'footprints come from the table')
|
||||
expect(weapons[1]?.damage === 0, 'a missing damage cell becomes zero, not NaN')
|
||||
expect(weapons[2]?.level === 8, 'level gates are read')
|
||||
expect(weapons[3]?.level === 1, 'a missing level falls back to 1')
|
||||
expect(weapons[0]?.tags.includes('weap') === true, 'the type column becomes the tag list')
|
||||
|
||||
const misc = itemBasesFromTable(parseTable([
|
||||
'Id\tName\tType\tInvWidth\tInvHeight\tMaxStack\tValue',
|
||||
'hp1\tMinor Healing Potion\tmisc\t1\t1\t5\t20',
|
||||
].join('\n')), 'misc')
|
||||
expect(misc[0]?.maxStack === 5, 'stack limits come from the table')
|
||||
|
||||
const prefixes = affixesFromTable(parseTable([
|
||||
'Id\tName\tLevel\titype1\titype2\tmod1code\tmod1min\tmod1max\tmod2code\tmod2min\tmod2max',
|
||||
'cruel\tCruel\t12\tweap\t\tmaxdamage\t30\t40\t\t\t',
|
||||
'sturdy\tSturdy\t3\tarmo\t\tdefense\t5\t9\t\t\t',
|
||||
'fine\tFine\t5\tweap\tarmo\tmaxdamage\t2\t4\tdefense\t1\t3',
|
||||
'nomod\tBroken Row\t1\tweap\t\t\t\t\t\t\t', // no modifier: must be skipped
|
||||
].join('\r\n')), 'prefix')
|
||||
expect(prefixes.length === 3, 'rows without a modifier are skipped')
|
||||
expect(prefixes[0]?.modifiers.length === 1, 'a single modifier is read')
|
||||
expect(prefixes[1]?.itemTypes.join(',') === 'armo', 'itype columns build the type list')
|
||||
expect(prefixes[2]?.modifiers.length === 2, 'two modifier slots are read')
|
||||
|
||||
const suffixes = affixesFromTable(parseTable([
|
||||
'Id\tName\tLevel\titype1\tmod1code\tmod1min\tmod1max',
|
||||
'of_might\tof Might\t5\tweap\tstrength\t2\t5',
|
||||
'of_the_fox\tof the Fox\t3\t\tdexterity\t1\t3', // no itype: any item
|
||||
].join('\n')), 'suffix')
|
||||
expect(suffixes[1]?.itemTypes.length === 0, 'an affix with no itype applies to anything')
|
||||
|
||||
// --- eligibility ------------------------------------------------------------
|
||||
|
||||
const sword = weapons[0]!
|
||||
const buckler = weapons[1]!
|
||||
const longSword = weapons[2]!
|
||||
expect(affixEligible(prefixes[0]!, sword, 12), 'a weapon affix fits a weapon at its level')
|
||||
expect(!affixEligible(prefixes[0]!, sword, 11), 'an affix above the item level is refused')
|
||||
expect(!affixEligible(prefixes[0]!, buckler, 20), 'a weapon affix is refused by armor')
|
||||
expect(affixEligible(prefixes[1]!, buckler, 3), 'an armor affix fits armor')
|
||||
expect(affixEligible(suffixes[1]!, buckler, 10), 'an unrestricted affix fits any base')
|
||||
|
||||
// --- rolling ----------------------------------------------------------------
|
||||
|
||||
const rollWith = (seed: number): string => {
|
||||
const rolled = rollAffix(prefixes, sword, 20, new Rng(seed))
|
||||
return JSON.stringify(rolled)
|
||||
}
|
||||
expect(rollWith(7) === rollWith(7), 'the same seed rolls the same affix and the same numbers')
|
||||
const rollVariety = new Set([1, 2, 3, 4, 5, 6, 7, 8].map(rollWith))
|
||||
expect(rollVariety.size > 1, 'different seeds roll differently')
|
||||
const rolled = rollAffix(prefixes, sword, 20, new Rng(3))
|
||||
expect(rolled !== null && rolled.rolls.length === rolled.affix.modifiers.length, 'each modifier gets a roll')
|
||||
expect(rolled !== null && rolled.rolls.every(r => r.max >= r.min && r.max <= (r as { max: number }).max), 'rolls stay inside the affix range')
|
||||
const neverEligible = rollAffix([prefixes[1]!], sword, 20, new Rng(1))
|
||||
expect(neverEligible === null, 'no eligible affix rolls nothing rather than an illegal one')
|
||||
|
||||
// --- item construction ------------------------------------------------------
|
||||
|
||||
// Only one suffix is eligible here, so "the modifier shows up as a stat" is a
|
||||
// statement about the pipeline rather than about which affix the seed picked.
|
||||
const might = suffixes.find(affix => affix.id === 'of_might')!
|
||||
const item = createItem(sword, prefixes, [might], new Rng(11), { level: 20, prefixChance: 1, suffixChance: 1 })
|
||||
expect(item.prefix !== null && item.suffix !== null, 'forced chances produce both affixes')
|
||||
expect(item.name === `${item.prefix!.name} ${sword.name} ${item.suffix!.name}`, 'the name is prefix + base + suffix')
|
||||
expect((item.stats.damage ?? 0) >= sword.damage, 'base damage survives into the stats')
|
||||
expect((item.stats.strength ?? 0) >= 2, 'affix modifiers appear as stats')
|
||||
expect(item.suffix?.id === 'of_might', 'the only eligible suffix is the one rolled')
|
||||
expect(item.value > sword.value, 'an affixed item is worth more than its base')
|
||||
|
||||
const plain = createItem(sword, prefixes, suffixes, new Rng(11), { level: 20, prefixChance: 0, suffixChance: 0 })
|
||||
expect(plain.prefix === null && plain.suffix === null, 'zero chances roll no affixes')
|
||||
expect(plain.name === sword.name, 'a plain item is named after its base')
|
||||
|
||||
const potion = misc[0]!
|
||||
const stacked = createItem(potion, prefixes, suffixes, new Rng(1), { level: 1, prefixChance: 0, suffixChance: 0, stack: 99 })
|
||||
expect(stacked.stack === potion.maxStack, 'a stack cannot exceed the base limit')
|
||||
|
||||
// --- inventory --------------------------------------------------------------
|
||||
|
||||
const bag = new Inventory(4, 3)
|
||||
expect(bag.totalCells === 12 && bag.usedCells === 0, 'a new inventory is empty')
|
||||
expect(bag.canPlace(1, 3, 0, 0), 'a tall item fits where there is room')
|
||||
expect(!bag.canPlace(1, 3, 0, 1), 'an item that would leave the grid is refused')
|
||||
expect(bag.canPlace(4, 1, 0, 0), 'an item exactly as wide as the grid fits')
|
||||
expect(!bag.canPlace(5, 1, 0, 0), 'an item wider than the grid is refused')
|
||||
|
||||
const placedSword = bag.add(item)
|
||||
expect(placedSword !== null, 'an item can be added')
|
||||
expect(bag.usedCells === 3, 'adding occupies the item footprint')
|
||||
expect(!bag.canPlace(1, 1, 0, 0), 'occupied cells refuse other items')
|
||||
expect(bag.add(plain) !== null, 'a second item finds the next free slot')
|
||||
expect(bag.contents.length === 2, 'both items are tracked')
|
||||
|
||||
// stacking onto an existing pile, with the remainder in a new slot
|
||||
const potionBag = new Inventory(4, 2)
|
||||
const firstPotion = potionBag.add(createItem(potion, prefixes, suffixes, new Rng(2), { level: 1, prefixChance: 0, suffixChance: 0, stack: 3 }))
|
||||
expect(firstPotion !== null, 'the first pile lands')
|
||||
const secondPotion = potionBag.add(createItem(potion, prefixes, suffixes, new Rng(2), { level: 1, prefixChance: 0, suffixChance: 0, stack: 4 }))
|
||||
expect(secondPotion !== null, 'the second pile lands somewhere')
|
||||
expect(potionBag.contents.length === 2, 'a stack that overflows opens a second slot instead of discarding')
|
||||
const totalPotions = potionBag.contents.reduce((sum, entry) => sum + entry.item.stack, 0)
|
||||
expect(totalPotions === 7, 'no potion is lost across the overflow')
|
||||
|
||||
const removed = bag.remove(placedSword!)
|
||||
expect(removed && bag.usedCells === 3, `removing an item clears exactly its cells (used ${String(bag.usedCells)})`)
|
||||
expect(bag.canPlace(1, 3, 0, 0), 'the freed cells can be reused')
|
||||
|
||||
const tiny = new Inventory(1, 1)
|
||||
expect(tiny.add(item) === null, 'an item with no room is refused rather than dropped silently')
|
||||
expect(tiny.add(goldItem(100)) !== null, 'gold fits where the sword did not')
|
||||
expect(tiny.gold === 100, 'gold is summed from its piles')
|
||||
|
||||
const goldBag = new Inventory(2, 1)
|
||||
goldBag.add(goldItem(4000))
|
||||
goldBag.add(goldItem(4000))
|
||||
expect(goldBag.gold === 8000, 'gold piles stack up to their limit and spill into another slot')
|
||||
|
||||
// --- drops ------------------------------------------------------------------
|
||||
|
||||
const bases = [...weapons, potion]
|
||||
const dropOptions = { level: 10, dropChance: 1, goldChance: 0, goldRange: [10, 20] as const }
|
||||
const runDrops = (seed: number): string => JSON.stringify(
|
||||
Array.from({ length: 8 }, (_, index) => rollDrop(bases, prefixes, suffixes, new Rng(seed + index), dropOptions)),
|
||||
)
|
||||
expect(runDrops(5) === runDrops(5), 'the same seed produces the same drops')
|
||||
expect(new Set([1, 2, 3, 4, 5, 6].map(runDrops)).size > 1, 'different seeds produce different drops')
|
||||
|
||||
expect(rollDrop(bases, prefixes, suffixes, new Rng(1), { ...dropOptions, dropChance: 0 }).kind === 'nothing', 'a zero drop chance drops nothing')
|
||||
expect(rollDrop(bases, prefixes, suffixes, new Rng(1), { ...dropOptions, dropChance: 1 }).kind === 'item', 'a guaranteed drop is an item')
|
||||
const goldDrop = rollDrop(bases, prefixes, suffixes, new Rng(1), { ...dropOptions, goldChance: 1 })
|
||||
expect(goldDrop.kind === 'gold' && goldDrop.amount >= 10 && goldDrop.amount <= 20, 'gold drops inside its range')
|
||||
|
||||
// An item level below every base's requirement still has to yield something.
|
||||
const lowLevelDrops = Array.from({ length: 12 }, (_, index) =>
|
||||
rollDrop([longSword], prefixes, suffixes, new Rng(100 + index), { ...dropOptions, level: 1 }))
|
||||
expect(lowLevelDrops.every(drop => drop.kind === 'item'), 'a table with no base under the level still drops the base it has')
|
||||
|
||||
// --- derived stats ----------------------------------------------------------
|
||||
|
||||
const sheet = new Inventory(4, 4)
|
||||
const armour: ItemBase = { ...buckler, defense: 12, tags: ['armo'] }
|
||||
sheet.add(createItem(armour, prefixes, suffixes, new Rng(4), { level: 20, prefixChance: 1, suffixChance: 0 }))
|
||||
const defence = totalStat(sheet.contents, 'defense')
|
||||
expect(defence >= 12, 'worn armor contributes its defense to the character sheet')
|
||||
|
||||
console.log(`checks ${String(checks)}`)
|
||||
console.log(`problems ${String(problems.length)}`)
|
||||
for (const problem of problems.slice(0, 12)) console.log(` - ${problem}`)
|
||||
console.log(problems.length === 0 ? 'RESULT item behaviours hold' : 'RESULT FAILED')
|
||||
process.exit(problems.length === 0 ? 0 : 1)
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
// verify-listfile.ts — 用社区 listfile 把"归档里到底有没有这个成员"变成可判定的事实
|
||||
//
|
||||
// 背景:`d2data.mpq`/`Patch_D2.mpq` 都没有 `(listfile)`,Storm 的名字是**加密存的**,所以
|
||||
// 原先 `listFiles()` 只能对能列目录的归档给出名字。1.13c 的社区 listfile
|
||||
// (`samples/d2/listfile_113c.txt`,39551 行)补齐了这一点:拿它的每个名字去归档里做
|
||||
// hash 查找,查到就是真成员。`MpqArchive.open` 的 `listfile` 选项就是为此存在的。
|
||||
//
|
||||
// node scripts/verify-listfile.ts [--dir=samples/d2]
|
||||
//
|
||||
// 退出码非 0 表示有断言没过。
|
||||
|
||||
export {}
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
|
||||
/** 归档目录,`--dir=` 可覆盖。 */
|
||||
const DIR = process.argv.find(argument => argument.startsWith('--dir='))?.slice('--dir='.length) ?? 'samples/d2'
|
||||
/** listfile 文件(社区提供,1.13c)。 */
|
||||
const LISTFILE = `${DIR}/listfile_113c.txt`
|
||||
|
||||
/** 断言结果。 */
|
||||
const checks: { name: string; ok: boolean; detail: string }[] = []
|
||||
|
||||
/**
|
||||
* 记录一条断言。
|
||||
*
|
||||
* @param name - what was checked.
|
||||
* @param ok - whether it held.
|
||||
* @param detail - evidence.
|
||||
*/
|
||||
function check(name: string, ok: boolean, detail: string): void {
|
||||
checks.push({ name, ok, detail })
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${name} — ${detail}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程:读 listfile,逐个归档做名字解析,最后核对物体美术的结论。
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const names = readFileSync(LISTFILE, 'utf8').split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0)
|
||||
const lower = new Set(names.map(name => name.toLowerCase()))
|
||||
check('listfile 非空', names.length > 30000, `${String(names.length)} 行`)
|
||||
check('listfile 去重后无重复', lower.size === names.length, `${String(lower.size)} 个不同名字`)
|
||||
check('listfile 带 (listfile) 自身', lower.has('(listfile)'), '社区 listfile 保留了归档管理成员')
|
||||
|
||||
/** 每个归档期望的最低命中数,来自实测;低于它说明 listfile 或归档换了版本。 */
|
||||
const expectations: Record<string, { min: number; objects: number }> = {
|
||||
'Patch_D2.mpq': { min: 200, objects: 0 },
|
||||
'd2data.mpq': { min: 10700, objects: 2400 },
|
||||
'd2exp.mpq': { min: 9800, objects: 800 },
|
||||
}
|
||||
|
||||
const resolvedByArchive = new Map<string, string[]>()
|
||||
for (const [file, want] of Object.entries(expectations)) {
|
||||
const archive = await MpqArchive.open(await fileSource(`${DIR}/${file}`), { listfile: names })
|
||||
const found = names.filter(name => archive.find(name) !== undefined)
|
||||
resolvedByArchive.set(file, found)
|
||||
const objects = found.filter(name => /objects[\\/]/i.test(name))
|
||||
console.log(`\n=== ${file} ===`)
|
||||
check(`${file} 名字命中数 ≥ ${String(want.min)}`, found.length >= want.min,
|
||||
`${String(found.length)} / ${String(names.length)}`)
|
||||
check(`${file} objects 成员数 ≈ ${String(want.objects)}`, Math.abs(objects.length - want.objects) <= 20,
|
||||
`${String(objects.length)} 个 objects 成员`)
|
||||
}
|
||||
|
||||
// 1.13c 的关卡表就在 Patch_D2 里:解析器读的就是这些名字,名字错一个字节就全盘皆错。
|
||||
const patch = await MpqArchive.open(await fileSource(`${DIR}/Patch_D2.mpq`), { listfile: names })
|
||||
for (const table of ['Levels.txt', 'LvlMaze.txt', 'LvlPrest.txt', 'LvlSub.txt', 'LvlTypes.txt']) {
|
||||
const member = `data\\global\\excel\\${table}`
|
||||
const file = patch.find(member)
|
||||
check(`Patch_D2 有 ${table}`, file !== undefined, file === undefined ? '未找到' : `${String(file.fileSize)} 字节`)
|
||||
}
|
||||
console.log(`\n Patch_D2 里 objects 目录:${String((resolvedByArchive.get('Patch_D2.mpq') ?? []).filter(name => /objects[\\/]/i.test(name)).length)}`
|
||||
+ '(补丁归档不含可破坏物品美术,这是实测结论而不是猜测)')
|
||||
|
||||
// 物体美术的关键结论:哪些 token 在全部归档里根本没有美术。
|
||||
const all = [...resolvedByArchive.values()].flat()
|
||||
for (const [token, expected] of [['9b', 4], ['tp', 12], ['ta', 0]] as const) {
|
||||
const hits = all.filter(name => new RegExp(`objects[\\\\/]${token}[\\\\/]`, 'i').test(name))
|
||||
check(`token ${token.toUpperCase()} 的成员数 = ${String(expected)}`, hits.length === expected, `${String(hits.length)} 个`)
|
||||
}
|
||||
|
||||
const passed = checks.filter(entry => entry.ok).length
|
||||
console.log(`\n${String(passed)}/${String(checks.length)} passed`)
|
||||
if (passed !== checks.length) process.exitCode = 1
|
||||
}
|
||||
|
||||
await main()
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* M4 checks: skills, projectiles, quests, NPC dialogue, headless.
|
||||
*
|
||||
* Skills and quests are state machines with resource gates, and both fail in ways
|
||||
* that are annoying to notice by playing: a skill that fires while on cooldown, a
|
||||
* projectile that passes through a wall, a quest that counts the wrong monster or
|
||||
* rewards twice, an NPC that offers a quest already in progress. Each is an
|
||||
* assertion here.
|
||||
*
|
||||
* Name resolution is checked too: tables address strings by index into a `.tbl`,
|
||||
* and the fallback (a literal cell, or no `.tbl` at all) has to keep working —
|
||||
* that is the path a real archive takes while the TBL decoder is still unproven
|
||||
* against shipped files.
|
||||
*
|
||||
* Usage: node scripts/verify-m4.ts
|
||||
*/
|
||||
import { parseTable, resolveText, textSourceOf } from '../src/game/tables.ts'
|
||||
import { castSkill, skillDamageAt, skillsFromTable, tickProjectiles } from '../src/game/skills.ts'
|
||||
import type { Projectile, ProjectileTarget, SkillDef } from '../src/game/skills.ts'
|
||||
import { QuestLog, npcDialog, npcsFromTable, questsFromTable } from '../src/game/quests.ts'
|
||||
import { Rng } from '../src/game/rng.ts'
|
||||
|
||||
const problems: string[] = []
|
||||
let checks = 0
|
||||
|
||||
/**
|
||||
* Assert one condition.
|
||||
*
|
||||
* @param condition - the condition to hold.
|
||||
* @param description - what it means.
|
||||
*/
|
||||
function expect(condition: boolean, description: string): void {
|
||||
checks += 1
|
||||
if (!condition) problems.push(description)
|
||||
}
|
||||
|
||||
// A tiny string table, as if decoded from a real `.tbl`.
|
||||
const strings = textSourceOf(['', 'Fire Bolt', 'Ice Blast', 'Deckard Cain', 'Kill the fallen', 'Slay the fallen'])
|
||||
expect(resolveText('1', strings) === 'Fire Bolt', 'a numeric cell resolves through the string table')
|
||||
expect(resolveText('Fire Bolt', strings) === 'Fire Bolt', 'a literal cell passes through unchanged')
|
||||
expect(resolveText('99', strings) === '99', 'an index the table does not have stays as written')
|
||||
const noTable = textSourceOf(null)
|
||||
expect(resolveText('7', noTable) === '7', 'with no table loaded, indices stay literal')
|
||||
|
||||
// --- skills -----------------------------------------------------------------
|
||||
|
||||
const skills = skillsFromTable(parseTable([
|
||||
'Id\tName\tManaCost\tCooldownTicks\tRange\tSpeed\tMinDam\tMaxDam\tPerLevel\tRadius',
|
||||
'firebolt\t1\t5\t8\t240\t300\t6\t9\t3\t20',
|
||||
'frostnova\t2\t12\t40\t60\t0\t10\t14\t4\t90',
|
||||
'basic\tBasic Attack\t0\t0\t48\t0\t2\t3\t1\t16',
|
||||
].join('\r\n')), strings)
|
||||
|
||||
expect(skills.length === 3, 'every skill row becomes a definition')
|
||||
expect(skills[0]?.name === 'Fire Bolt', 'skill names resolve through the string table')
|
||||
expect(skills[0]?.projectile === true, 'a skill with missile speed is a projectile skill')
|
||||
expect(skills[1]?.projectile === false, 'a skill with no speed strikes instantly')
|
||||
expect(skills[2]?.name === 'Basic Attack', 'a literal name needs no table')
|
||||
|
||||
const firebolt = skills[0]!
|
||||
const frostnova = skills[1]!
|
||||
|
||||
// damage scaling: base at level 1, plus the slope per level, inside the range
|
||||
const level1 = Array.from({ length: 40 }, (_, index) => skillDamageAt(firebolt, 1, new Rng(index)))
|
||||
expect(level1.every(damage => damage >= 6 && damage <= 9), 'level 1 damage stays inside the base range')
|
||||
const level5 = Array.from({ length: 40 }, (_, index) => skillDamageAt(firebolt, 5, new Rng(index)))
|
||||
expect(level5.every(damage => damage >= 18 && damage <= 21), 'each level adds the table slope')
|
||||
expect(Math.min(...level5) > Math.max(...level1), 'a higher skill level always out-damages a lower one')
|
||||
|
||||
// --- casting gates ----------------------------------------------------------
|
||||
|
||||
const caster = { x: 100, y: 100, facing: 6 }
|
||||
const ready = castSkill(firebolt, caster, 1, new Rng(1), 0, 50)
|
||||
expect(ready.kind === 'projectile', 'a ready cast with mana produces a projectile')
|
||||
expect(castSkill(firebolt, caster, 1, new Rng(1), 3, 50).kind === 'cooldown', 'a skill on cooldown refuses to fire')
|
||||
expect(castSkill(firebolt, caster, 1, new Rng(1), 0, 1).kind === 'mana', 'a cast without mana refuses to fire')
|
||||
expect(castSkill(firebolt, caster, 1, new Rng(1), 0, 5).kind === 'projectile', 'exactly enough mana is enough')
|
||||
expect(castSkill(frostnova, caster, 1, new Rng(1), 0, 50).kind === 'instant', 'an instant skill reports as instant')
|
||||
|
||||
if (ready.kind === 'projectile') {
|
||||
const shot = ready.projectile
|
||||
// Facing 6 is east: the velocity must run along +x and nowhere else.
|
||||
expect(shot.vx > 0 && Math.abs(shot.vy) < 1e-9, 'a projectile flies along the caster\'s facing')
|
||||
expect(Math.abs(Math.hypot(shot.vx, shot.vy) - firebolt.speed / 25) < 1e-9, 'projectile speed is the table speed per tick')
|
||||
expect(shot.ttl === Math.round(firebolt.range / (firebolt.speed / 25)), 'range divided by step gives the lifetime')
|
||||
expect(shot.fromPlayer === true, 'a cast projectile is marked as the player\'s')
|
||||
}
|
||||
|
||||
const aimed = castSkill(firebolt, { x: 0, y: 0, facing: 0 }, 1, new Rng(1), 0, 50, { x: 0, y: 100 })
|
||||
if (aimed.kind === 'projectile') {
|
||||
expect(aimed.projectile.vy > 0 && Math.abs(aimed.projectile.vx) < 1e-9, 'an aim point overrides the facing')
|
||||
} else {
|
||||
problems.push('an aimed cast did not produce a projectile')
|
||||
}
|
||||
|
||||
// --- projectiles ------------------------------------------------------------
|
||||
|
||||
const flying: Projectile = { skillId: 'firebolt', x: 0, y: 0, vx: 12, vy: 0, damage: 7, ttl: 5, fromPlayer: true }
|
||||
const targetAt = (x: number, alive = true): ProjectileTarget => ({ index: 1, x, y: 0, radius: 16, alive })
|
||||
const openTerrain = { overlap: () => 0 }
|
||||
|
||||
const stepOne = tickProjectiles([flying], [targetAt(500)], openTerrain)
|
||||
expect(stepOne.alive.length === 1 && stepOne.hits.length === 0, 'a projectile with nothing in the way keeps flying')
|
||||
expect(stepOne.alive[0]?.x === 12, 'a projectile advances by its velocity each tick')
|
||||
|
||||
const hit = tickProjectiles([{ ...flying, x: 100 }], [targetAt(110)], openTerrain)
|
||||
expect(hit.hits.length === 1 && hit.hits[0]?.targetIndex === 1, 'a projectile hits a target in its path')
|
||||
expect(hit.alive.length === 0, 'a projectile that hits is consumed')
|
||||
expect(hit.hits[0]?.damage === 7, 'the hit carries the rolled damage')
|
||||
|
||||
const wall = tickProjectiles([{ ...flying, x: 100 }], [targetAt(104)], { overlap: x => (x >= 105 ? 1 : 0) })
|
||||
expect(wall.wallHits === 1 && wall.hits.length === 0, 'a wall stops a projectile before a target behind it')
|
||||
expect(wall.alive.length === 0, 'a projectile that hits a wall is consumed')
|
||||
|
||||
const expired = tickProjectiles([{ ...flying, ttl: 0 }], [targetAt(500)], openTerrain)
|
||||
expect(expired.expired === 1 && expired.alive.length === 0, 'a projectile dies when its lifetime runs out')
|
||||
|
||||
const dead = tickProjectiles([{ ...flying, x: 100 }], [targetAt(105, false)], openTerrain)
|
||||
expect(dead.hits.length === 0 && dead.alive.length === 1, 'a dead target is not hit')
|
||||
|
||||
// --- quests and NPCs --------------------------------------------------------
|
||||
|
||||
const quests = questsFromTable(parseTable([
|
||||
'Id\tName\tDescription\tMonsterId\tKillCount\tRewardXP\tRewardGold',
|
||||
'den\t4\t5\tfallen\t3\t120\t50',
|
||||
'any\tDen of Evil\tKill anything\t*\t2\t40\t10',
|
||||
].join('\n')), strings)
|
||||
expect(quests[0]?.name === 'Kill the fallen', 'quest names resolve through the string table')
|
||||
expect(quests[0]?.description === 'Slay the fallen', 'descriptions resolve too')
|
||||
expect(quests[1]?.name === 'Den of Evil', 'a literal quest name passes through')
|
||||
|
||||
const npcs = npcsFromTable(parseTable([
|
||||
'Id\tName\tQuest\tOffer\tProgress\tDone',
|
||||
'cain\t3\tden\tAccept|Go now\t1|Still alive?\tWell done',
|
||||
].join('\n')), strings)
|
||||
expect(npcs[0]?.name === 'Deckard Cain', 'NPC names resolve through the string table')
|
||||
expect(npcs[0]?.offerLines.length === 2, 'pipe-separated dialogue becomes several lines')
|
||||
expect(npcs[0]?.progressLines[0] === 'Fire Bolt', 'a numeric dialogue line resolves to its string')
|
||||
|
||||
const log = new QuestLog(quests)
|
||||
const cain = npcs[0]!
|
||||
expect(npcDialog(cain, log).join('|') === 'Accept|Go now', 'before accepting, the NPC offers the quest')
|
||||
expect(log.accept('den'), 'accepting an inactive quest works')
|
||||
expect(!log.accept('den'), 'accepting an active quest is refused')
|
||||
expect(npcDialog(cain, log).join('|') === 'Fire Bolt|Still alive?', 'while active, the NPC reports progress')
|
||||
expect(log.get('den')?.status === 'active', 'the quest is active')
|
||||
|
||||
expect(log.recordKill('zombie').length === 0, 'a kill of the wrong monster does not complete the quest')
|
||||
expect(log.recordKill('fallen').length === 0, 'the first wanted kill does not complete a three-kill quest')
|
||||
const rewards = log.recordKill('fallen')
|
||||
expect(rewards.length === 0, 'the second kills still does not complete it')
|
||||
const finalRewards = log.recordKill('fallen')
|
||||
expect(finalRewards.length === 1 && finalRewards[0]?.questId === 'den', 'the third kill completes the quest')
|
||||
expect(finalRewards[0]?.xp === 120 && finalRewards[0]?.gold === 50, 'the reward comes from the quest row')
|
||||
expect(log.get('den')?.status === 'complete', 'the quest is complete')
|
||||
expect(log.recordKill('fallen').length === 0, 'a completed quest does not reward again')
|
||||
expect(npcDialog(cain, log).join('|') === 'Well done', 'after completion, the NPC changes to the done lines')
|
||||
|
||||
// A wildcard quest counts any monster, and both quests can be active at once.
|
||||
const wildcardLog = new QuestLog(quests)
|
||||
wildcardLog.accept('any')
|
||||
expect(wildcardLog.recordKill('zombie').length === 0, 'a wildcard quest counts the first kill')
|
||||
expect(wildcardLog.recordKill('skeleton').length === 1, 'a wildcard quest completes on any monsters')
|
||||
expect(wildcardLog.active.length === 0, 'no quest is left active once complete')
|
||||
expect(wildcardLog.all.length === 2, 'the log tracks every quest')
|
||||
|
||||
// --- determinism ------------------------------------------------------------
|
||||
|
||||
const castRun = (seed: number): string => {
|
||||
const rng = new Rng(seed)
|
||||
const shots: string[] = []
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
const result = castSkill(firebolt, { x: index, y: 0, facing: 6 }, 3, rng, 0, 100)
|
||||
shots.push(result.kind === 'projectile' ? String(result.projectile.damage) : result.kind)
|
||||
}
|
||||
return shots.join(',')
|
||||
}
|
||||
expect(castRun(9) === castRun(9), 'the same seed casts for the same damage')
|
||||
expect(new Set([1, 2, 3, 4].map(castRun)).size > 1, 'different seeds roll different damage')
|
||||
|
||||
console.log(`checks ${String(checks)}`)
|
||||
console.log(`problems ${String(problems.length)}`)
|
||||
for (const problem of problems.slice(0, 12)) console.log(` - ${problem}`)
|
||||
console.log(problems.length === 0 ? 'RESULT skill, projectile and quest behaviours hold' : 'RESULT FAILED')
|
||||
process.exit(problems.length === 0 ? 0 : 1)
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
/**
|
||||
* M5 checks: snapshots, character files and deterministic lockstep, headless.
|
||||
*
|
||||
* A save is verified the only way that means anything: restore it, keep
|
||||
* simulating, and require the restored copy to stay *identical* to the original
|
||||
* tick for tick. A field left out of the save shows up here as a divergence a few
|
||||
* hundred ticks later, which is exactly how such a bug appears in a real game.
|
||||
*
|
||||
* Lockstep is verified by running two independent sessions over identical input
|
||||
* and comparing their per-tick hashes, then by tampering with one peer's input and
|
||||
* requiring the divergence to be *detected* — a desync detector that never fires
|
||||
* is worse than none.
|
||||
*
|
||||
* Usage: node scripts/verify-m5.ts
|
||||
*/
|
||||
import { createWorld, spawnMonsters, tickCombat, damageMonster } from '../src/game/combat.ts'
|
||||
import type { CombatOptions, MonsterStats } from '../src/game/combat.ts'
|
||||
import { Inventory, goldItem, rollDrop } from '../src/game/items.ts'
|
||||
import type { Affix, ItemBase } from '../src/game/items.ts'
|
||||
import { QuestLog } from '../src/game/quests.ts'
|
||||
import type { QuestDef } from '../src/game/quests.ts'
|
||||
import { Rng } from '../src/game/rng.ts'
|
||||
import {
|
||||
captureSnapshot, createD2s, d2sChecksum, parseSnapshot, readD2s, restoreSnapshot, serializeSnapshot, writeD2s,
|
||||
} from '../src/game/save.ts'
|
||||
import { LockstepSession } from '../src/net/lockstep.ts'
|
||||
import type { InputFrame, LockstepSimulation } from '../src/net/lockstep.ts'
|
||||
import { parseTable } from '../src/game/tables.ts'
|
||||
import { monsterStatsFromTable } from '../src/game/combat.ts'
|
||||
|
||||
const problems: string[] = []
|
||||
let checks = 0
|
||||
|
||||
/**
|
||||
* Assert one condition.
|
||||
*
|
||||
* @param condition - the condition to hold.
|
||||
* @param description - what it means.
|
||||
*/
|
||||
function expect(condition: boolean, description: string): void {
|
||||
checks += 1
|
||||
if (!condition) problems.push(description)
|
||||
}
|
||||
|
||||
// --- a small game to save and to simulate ------------------------------------
|
||||
|
||||
const stats: MonsterStats[] = monsterStatsFromTable(parseTable([
|
||||
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
|
||||
'fallen\tFallen\t12\t3\t24\t36\t400\t80\t8',
|
||||
'zombie\tZombie\t30\t6\t32\t40\t300\t50\t15',
|
||||
].join('\n')))
|
||||
|
||||
const bases: ItemBase[] = [
|
||||
{ id: 'swd', name: 'Short Sword', kind: 'weapon', invWidth: 1, invHeight: 3, maxStack: 1, value: 30, damage: 5, defense: 0, tags: ['weap'], level: 1 },
|
||||
{ id: 'potion', name: 'Potion', kind: 'misc', invWidth: 1, invHeight: 1, maxStack: 5, value: 20, damage: 0, defense: 0, tags: ['misc'], level: 1 },
|
||||
]
|
||||
const prefixes: Affix[] = [{ id: 'cruel', name: 'Cruel', kind: 'prefix', level: 1, itemTypes: ['weap'], modifiers: [{ stat: 'maxdamage', min: 2, max: 6 }] }]
|
||||
const suffixes: Affix[] = [{ id: 'might', name: 'of Might', kind: 'suffix', level: 1, itemTypes: [], modifiers: [{ stat: 'strength', min: 1, max: 3 }] }]
|
||||
const quests: QuestDef[] = [{ id: 'den', name: 'Den', description: '', monsterId: '*', killCount: 3, rewardXp: 40, rewardGold: 60 }]
|
||||
|
||||
const options: CombatOptions = {
|
||||
playerSpeed: 180, playerReach: 48, playerCooldownTicks: 12, playerDamage: 6,
|
||||
playerManaPerAttack: 2, respawnTicks: 40,
|
||||
}
|
||||
const xpTable: readonly number[] = [0, 0, 1000]
|
||||
|
||||
/** The part of the game a save has to carry. */
|
||||
interface Game {
|
||||
world: ReturnType<typeof createWorld>
|
||||
rng: Rng
|
||||
inventory: Inventory
|
||||
quests: QuestLog
|
||||
ground: { x: number; y: number; item: ReturnType<typeof goldItem> }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh game.
|
||||
*
|
||||
* @param seed - random seed and world seed.
|
||||
* @returns the game.
|
||||
*/
|
||||
function newGame(seed: number): Game {
|
||||
const world = createWorld(0, 0)
|
||||
spawnMonsters(world, stats, 5, { x: 200, y: 0 }, 200, { overlap: () => 0 })
|
||||
return { world, rng: new Rng(seed), inventory: new Inventory(10, 4), quests: new QuestLog(quests), ground: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* The input script: deterministic in the tick number, so both runs and both
|
||||
* sessions see exactly the same thing.
|
||||
*
|
||||
* @param tick - the tick.
|
||||
* @returns movement and intent.
|
||||
*/
|
||||
function scriptedInput(tick: number): { movement: { x: number; y: number }; attack: boolean } {
|
||||
const phase = Math.floor(tick / 25) % 4
|
||||
const movement = phase === 0 ? { x: 1, y: 0 } : phase === 1 ? { x: 0, y: 1 } : phase === 2 ? { x: -1, y: 0 } : { x: 0, y: -1 }
|
||||
return { movement, attack: tick % 5 === 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a game one tick, including loot and quest progress.
|
||||
*
|
||||
* @param game - the game.
|
||||
* @param tick - the tick number.
|
||||
*/
|
||||
function stepGame(game: Game, tick: number): void {
|
||||
const input = scriptedInput(tick)
|
||||
tickCombat(game.world, input, options, { overlap: () => 0 }, xpTable)
|
||||
for (const event of game.world.events) {
|
||||
if (event.kind !== 'kill') continue
|
||||
for (const reward of game.quests.recordKill(event.subjectId ?? '')) {
|
||||
game.world.player.xp += reward.xp
|
||||
game.inventory.add(goldItem(reward.gold))
|
||||
}
|
||||
const drop = rollDrop(bases, prefixes, suffixes, game.rng, {
|
||||
level: game.world.player.level + 1, dropChance: 0.9, goldChance: 0.2, goldRange: [3, 25],
|
||||
})
|
||||
// Items land on the ground first, like in the scene; a few are then picked up,
|
||||
// so the save has both ground and bag contents to carry.
|
||||
if (drop.kind === 'item') game.ground.push({ x: event.x, y: event.y, item: drop.item })
|
||||
else if (drop.kind === 'gold') game.inventory.add(goldItem(drop.amount))
|
||||
}
|
||||
if (game.ground.length > 0 && tick % 17 === 0) {
|
||||
const entry = game.ground.shift()
|
||||
if (entry !== undefined) game.inventory.add(entry.item)
|
||||
}
|
||||
// The player swings at whatever is in reach, and the damage goes through the
|
||||
// shared entry point so kills behave as they do in the scene.
|
||||
if (input.attack && game.world.player.cooldown === 0) {
|
||||
game.world.monsters.forEach((monster, index) => {
|
||||
if (monster.state === 'dead') return
|
||||
if (Math.hypot(monster.x - game.world.player.x, monster.y - game.world.player.y) > 48) return
|
||||
damageMonster(game.world, index, options.playerDamage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Digest everything a continued simulation depends on.
|
||||
*
|
||||
* @param game - the game.
|
||||
* @returns a digest string.
|
||||
*/
|
||||
function digest(game: Game): string {
|
||||
const round = (value: number): number => Math.round(value * 1000)
|
||||
return JSON.stringify({
|
||||
tick: game.world.tick,
|
||||
kills: game.world.kills,
|
||||
rng: game.rng.seed,
|
||||
player: {
|
||||
x: round(game.world.player.x), y: round(game.world.player.y),
|
||||
hp: game.world.player.hp, mana: game.world.player.mana, xp: game.world.player.xp,
|
||||
level: game.world.player.level, cooldown: game.world.player.cooldown,
|
||||
},
|
||||
monsters: game.world.monsters.map(m => [round(m.x), round(m.y), m.hp, m.state, m.cooldown, m.hitFlash, m.corpseTicks]),
|
||||
inventory: game.inventory.contents.map(entry => [entry.x, entry.y, entry.item.name, entry.item.stack, entry.item.value]),
|
||||
ground: game.ground.map(entry => [round(entry.x), round(entry.y), entry.item.name]),
|
||||
quests: game.quests.all.map(entry => [entry.def.id, entry.status, entry.kills]),
|
||||
})
|
||||
}
|
||||
|
||||
// --- snapshots ---------------------------------------------------------------
|
||||
|
||||
const original = newGame(0x1234)
|
||||
for (let tick = 0; tick < 200; tick += 1) stepGame(original, tick)
|
||||
const snapshot = captureSnapshot(original)
|
||||
const text = serializeSnapshot(snapshot)
|
||||
const parsed = parseSnapshot(text)
|
||||
expect(parsed.version === snapshot.version, 'a serialized snapshot parses back')
|
||||
expect(parsed.rngState === original.rng.seed, 'the random stream position is saved')
|
||||
expect(parsed.inventory.placed.length === original.inventory.contents.length, 'every carried item is saved')
|
||||
expect(parsed.quests[0]?.kills === original.quests.all[0]?.kills, 'quest progress is saved')
|
||||
expect(parsed.ground.length === original.ground.length, 'items on the ground are saved')
|
||||
|
||||
const restored = restoreSnapshot(parsed, {
|
||||
inventory: (width, height, placed) => Inventory.restore(width, height, placed),
|
||||
quests: states => QuestLog.restore(quests, states),
|
||||
})
|
||||
const restoredGame: Game = {
|
||||
ground: restored.ground.map(entry => ({ ...entry })),
|
||||
world: {
|
||||
...original.world,
|
||||
...restored.world,
|
||||
events: [],
|
||||
monsters: restored.world.monsters.map(monster => ({ ...monster })),
|
||||
},
|
||||
rng: new Rng(restored.rngState),
|
||||
inventory: restored.inventory,
|
||||
quests: restored.quests,
|
||||
}
|
||||
expect(restoredGame.inventory.contents.length === original.inventory.contents.length, 'the restored bag holds the same items')
|
||||
expect(
|
||||
restoredGame.inventory.contents.every((entry, index) => entry.x === original.inventory.contents[index]?.x
|
||||
&& entry.y === original.inventory.contents[index]?.y),
|
||||
'restored items sit in the slots they were saved in',
|
||||
)
|
||||
|
||||
// The real test: keep simulating both and require them to stay identical.
|
||||
for (let tick = 200; tick < 400; tick += 1) {
|
||||
stepGame(original, tick)
|
||||
stepGame(restoredGame, tick)
|
||||
}
|
||||
expect(digest(original) === digest(restoredGame), 'a restored game continues identically for 200 more ticks')
|
||||
|
||||
// A snapshot missing a field must be rejected, not half-applied.
|
||||
let rejected = false
|
||||
try {
|
||||
parseSnapshot(JSON.stringify({ version: 1, rngState: 1 }))
|
||||
} catch {
|
||||
rejected = true
|
||||
}
|
||||
expect(rejected, 'a malformed save is rejected')
|
||||
let versionRejected = false
|
||||
try {
|
||||
parseSnapshot(JSON.stringify({ ...snapshot, version: 99 }))
|
||||
} catch {
|
||||
versionRejected = true
|
||||
}
|
||||
expect(versionRejected, 'a save from a future version is rejected')
|
||||
|
||||
// --- character files ---------------------------------------------------------
|
||||
|
||||
const character = createD2s('Deckard', 1, 12, 0x80)
|
||||
const readBack = readD2s(character)
|
||||
expect(readBack.name === 'Deckard', 'the character name round-trips')
|
||||
expect(readBack.classIndex === 1 && readBack.level === 12, 'class and level round-trip')
|
||||
expect(readBack.version === 96, 'the version word is written')
|
||||
const tampered = new Uint8Array(character)
|
||||
tampered[0x40] = (tampered[0x40]! + 1) & 0xff
|
||||
let checksumRejected = false
|
||||
try {
|
||||
readD2s(tampered)
|
||||
} catch {
|
||||
checksumRejected = true
|
||||
}
|
||||
expect(checksumRejected, 'a corrupted character file fails its checksum')
|
||||
const notAFile = new Uint8Array(character)
|
||||
notAFile[0] = 0
|
||||
let signatureRejected = false
|
||||
try {
|
||||
readD2s(writeD2s({ ...readBack, raw: notAFile }))
|
||||
} catch {
|
||||
signatureRejected = true
|
||||
}
|
||||
expect(!signatureRejected, 'writing fixes the signature')
|
||||
expect(d2sChecksum(character) === new DataView(character.buffer).getUint32(0x0c, true), 'the stored checksum is the computed one')
|
||||
|
||||
// --- lockstep ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wrap a game as a lockstep simulation: peer 0 drives it, and the hash covers the
|
||||
* whole state.
|
||||
*
|
||||
* @param game - the game to drive.
|
||||
* @returns the simulation.
|
||||
*/
|
||||
function asSimulation(game: Game): LockstepSimulation {
|
||||
return {
|
||||
advance: (inputs) => {
|
||||
const frame = inputs[0]!
|
||||
tickCombat(game.world, { movement: frame.movement, attack: frame.attack }, options, { overlap: () => 0 }, xpTable)
|
||||
if (frame.attack && game.world.player.cooldown === 0) {
|
||||
game.world.monsters.forEach((monster, index) => {
|
||||
if (monster.state === 'dead') return
|
||||
if (Math.hypot(monster.x - game.world.player.x, monster.y - game.world.player.y) > 48) return
|
||||
damageMonster(game.world, index, options.playerDamage)
|
||||
})
|
||||
}
|
||||
},
|
||||
hash: () => LockstepSession.digest(digest(game)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a frame for a tick.
|
||||
*
|
||||
* @param tick - the tick.
|
||||
* @param attack - attack flag override.
|
||||
* @returns the frame.
|
||||
*/
|
||||
function frameFor(tick: number, attack?: boolean): InputFrame {
|
||||
const input = scriptedInput(tick)
|
||||
return {
|
||||
tick,
|
||||
movement: input.movement,
|
||||
attack: attack ?? input.attack,
|
||||
pickup: false,
|
||||
talk: false,
|
||||
skill: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const sessionA = new LockstepSession({ peers: 1, inputDelayTicks: 2 }, asSimulation(newGame(7)))
|
||||
const sessionB = new LockstepSession({ peers: 1, inputDelayTicks: 2 }, asSimulation(newGame(7)))
|
||||
const hashesA: number[] = []
|
||||
const hashesB: number[] = []
|
||||
for (let tick = 0; tick < 300; tick += 1) {
|
||||
sessionA.submit(0, frameFor(tick))
|
||||
sessionB.submit(0, frameFor(tick))
|
||||
const a = sessionA.step()
|
||||
const b = sessionB.step()
|
||||
if (a.kind === 'stepped') hashesA.push(a.hash)
|
||||
if (b.kind === 'stepped') hashesB.push(b.hash)
|
||||
}
|
||||
expect(hashesA.length === 300 && hashesB.length === 300, 'both sessions ran every tick')
|
||||
expect(hashesA.join(',') === hashesB.join(','), 'the same inputs produce the same hashes tick for tick')
|
||||
expect(new Set(hashesA).size > 10, 'the hash actually changes as the world does')
|
||||
|
||||
// A session must wait rather than run ahead on partial input.
|
||||
const waitingSession = new LockstepSession({ peers: 2, inputDelayTicks: 0 }, asSimulation(newGame(3)))
|
||||
waitingSession.submit(0, frameFor(0))
|
||||
const stalled = waitingSession.step()
|
||||
expect(stalled.kind === 'waiting', 'a tick with a missing peer input does not run')
|
||||
expect(stalled.kind === 'waiting' && stalled.missing.join(',') === '1', 'the wait names the peer that is missing')
|
||||
expect(waitingSession.tick === 0 && waitingSession.stalls === 1, 'the tick does not advance and the stall is counted')
|
||||
waitingSession.submit(1, frameFor(0))
|
||||
expect(waitingSession.step().kind === 'stepped', 'the tick runs once the input arrives')
|
||||
|
||||
// Input delay: a frame submitted now is due later, which is the latency budget.
|
||||
expect(waitingSession.inputDueTick === 1, 'with no delay a frame is due for the next tick')
|
||||
const delayedSession = new LockstepSession({ peers: 1, inputDelayTicks: 5 }, asSimulation(newGame(3)))
|
||||
expect(delayedSession.inputDueTick === 5, 'input delay pushes the due tick out')
|
||||
|
||||
// Stale input is refused rather than rewinding history.
|
||||
const staleSession = new LockstepSession({ peers: 1, inputDelayTicks: 0 }, asSimulation(newGame(3)))
|
||||
staleSession.submit(0, frameFor(0))
|
||||
staleSession.step()
|
||||
expect(!staleSession.submit(0, frameFor(0)), 'input for a tick that already ran is dropped')
|
||||
expect(staleSession.submit(0, frameFor(1)), 'input for the next tick is accepted')
|
||||
|
||||
// Desync detection: one peer sees different input, and the hashes must disagree.
|
||||
const honestSession = new LockstepSession({ peers: 1, inputDelayTicks: 0 }, asSimulation(newGame(11)))
|
||||
const tamperedSession = new LockstepSession({ peers: 1, inputDelayTicks: 0 }, asSimulation(newGame(11)))
|
||||
// The tamper has to be a real difference: `frameFor(0)` already attacks (the
|
||||
// script attacks every fifth tick), so the honest peer is given the opposite.
|
||||
honestSession.submit(0, frameFor(0, false))
|
||||
const honestHash = honestSession.step()
|
||||
tamperedSession.submit(0, frameFor(0, true))
|
||||
const tamperedHash = tamperedSession.step()
|
||||
expect(honestHash.kind === 'stepped' && tamperedHash.kind === 'stepped', 'both peers ran tick 0')
|
||||
if (honestHash.kind === 'stepped' && tamperedHash.kind === 'stepped') {
|
||||
const report = honestSession.compare({ tick: 0, hash: tamperedHash.hash })
|
||||
expect(report !== null, 'a diverging input is detected as a desync')
|
||||
expect(report?.tick === 0, 'the report names the tick that diverged')
|
||||
expect(honestSession.compare({ tick: 0, hash: honestHash.hash }) === null, 'matching hashes are not a desync')
|
||||
expect(honestSession.desyncReport !== null, 'the first desync is remembered')
|
||||
}
|
||||
expect(honestSession.compare({ tick: 999, hash: 1 }) === null, 'a hash for an unknown tick is ignored, not guessed at')
|
||||
|
||||
console.log(`checks ${String(checks)}`)
|
||||
console.log(`problems ${String(problems.length)}`)
|
||||
for (const problem of problems.slice(0, 12)) console.log(` - ${problem}`)
|
||||
console.log(problems.length === 0 ? 'RESULT save, snapshot and lockstep behaviours hold' : 'RESULT FAILED')
|
||||
process.exit(problems.length === 0 ? 0 : 1)
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Round-trip check for the MPQ reader against an archive written by
|
||||
* `scripts/make-mpq-fixture.ts`.
|
||||
*
|
||||
* The archive's contents are known byte for byte, so this is a two-sided test:
|
||||
* the reader must list exactly the packed names, report exactly the packed sizes
|
||||
* and flags, and return exactly the packed bytes — for members stored raw *and*
|
||||
* for members stored as zlib sectors, which are the two read paths that exist.
|
||||
*
|
||||
* Usage: node scripts/verify-mpq-roundtrip.ts <fixture-directory>
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { memorySource } from '../src/mpq/source.ts'
|
||||
|
||||
const dir = process.argv[2]
|
||||
if (dir === undefined) {
|
||||
console.error('usage: node scripts/verify-mpq-roundtrip.ts <fixture-directory>')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const archive = await MpqArchive.open(memorySource(new Uint8Array(await readFile(join(dir, 'fixture.mpq'))), 'fixture.mpq'))
|
||||
const names = await archive.listFiles()
|
||||
const problems: string[] = []
|
||||
|
||||
// `listFiles()` returns the names the archive publishes — the members named by
|
||||
// `(listfile)` — which by design is not the listfile itself.
|
||||
const published = [
|
||||
'actor.dc6',
|
||||
'data/global/excel/armor.txt', 'data/global/excel/experience.txt',
|
||||
'data/global/excel/magicprefix.txt', 'data/global/excel/magicsuffix.txt',
|
||||
'data/global/excel/misc.txt', 'data/global/excel/monstats.txt', 'data/global/excel/npcs.txt',
|
||||
'data/global/excel/quests.txt', 'data/global/excel/skills.txt', 'data/global/excel/weapons.txt',
|
||||
'data/local/string.tbl',
|
||||
'fixture.dc6', 'fixture.ds1', 'fixture.dt1', 'palette.pal',
|
||||
]
|
||||
const listed = [...names].sort()
|
||||
if (JSON.stringify(listed) !== JSON.stringify(published)) {
|
||||
problems.push(`published names: ${JSON.stringify(listed)} != ${JSON.stringify(published)}`)
|
||||
}
|
||||
// Every member, the listfile included, must be present as a block.
|
||||
const expectedNames = ['(listfile)', ...published]
|
||||
if (archive.files().length !== expectedNames.length) {
|
||||
problems.push(`occupied blocks: ${String(archive.files().length)} != ${String(expectedNames.length)}`)
|
||||
}
|
||||
|
||||
// `(listfile)` is synthesised by the packer rather than read from disk, so its
|
||||
// expected bytes are rebuilt here the same way the packer built them.
|
||||
const synthetic = new TextEncoder().encode(`${[
|
||||
'fixture.ds1', 'fixture.dt1', 'palette.pal', 'fixture.dc6', 'actor.dc6',
|
||||
'data/global/excel/monstats.txt', 'data/global/excel/experience.txt',
|
||||
'data/global/excel/weapons.txt', 'data/global/excel/armor.txt', 'data/global/excel/misc.txt',
|
||||
'data/global/excel/magicprefix.txt', 'data/global/excel/magicsuffix.txt',
|
||||
'data/global/excel/skills.txt', 'data/global/excel/npcs.txt', 'data/global/excel/quests.txt',
|
||||
'data/local/string.tbl',
|
||||
].join('\r\n')}\r\n`)
|
||||
|
||||
let comparedBytes = 0
|
||||
for (const name of expectedNames) {
|
||||
// Table members are stored under a directory path; on disk they live in the
|
||||
// same tree, so the member name doubles as a relative path.
|
||||
const onDisk = name === '(listfile)'
|
||||
? synthetic
|
||||
: new Uint8Array(await readFile(join(dir, name)))
|
||||
const entry = archive.find(name)
|
||||
if (entry === undefined) { problems.push(`${name}: not found by the reader`); continue }
|
||||
if (entry.fileSize !== onDisk.byteLength) {
|
||||
problems.push(`${name}: reported size ${String(entry.fileSize)} != ${String(onDisk.byteLength)}`)
|
||||
}
|
||||
const decoded = await archive.read(entry)
|
||||
if (decoded.byteLength !== onDisk.byteLength) {
|
||||
problems.push(`${name}: decoded ${String(decoded.byteLength)} bytes != ${String(onDisk.byteLength)}`)
|
||||
continue
|
||||
}
|
||||
let firstDifference = -1
|
||||
for (let at = 0; at < onDisk.byteLength; at += 1) {
|
||||
if (decoded[at] !== onDisk[at]) { firstDifference = at; break }
|
||||
}
|
||||
if (firstDifference !== -1) {
|
||||
problems.push(`${name}: byte ${String(firstDifference)} differs (${String(decoded[firstDifference])} != ${String(onDisk[firstDifference])})`)
|
||||
continue
|
||||
}
|
||||
comparedBytes += decoded.byteLength
|
||||
const flags = entry.flags >>> 0
|
||||
const compressed = (flags & 0x00000200) !== 0
|
||||
console.log(` ${name.padEnd(14)} ${String(decoded.byteLength).padStart(7)} bytes ${compressed ? 'zlib sectors' : 'stored'} flags 0x${flags.toString(16)}`)
|
||||
}
|
||||
|
||||
console.log(`archive ${join(dir, 'fixture.mpq')}`)
|
||||
console.log(`members ${String(archive.files().length)} blocks, ${String(names.length)} published names`)
|
||||
console.log(`compared ${String(comparedBytes)} bytes across ${String(expectedNames.length)} members`)
|
||||
console.log(`problems ${String(problems.length)}`)
|
||||
for (const problem of problems.slice(0, 8)) console.log(` - ${problem}`)
|
||||
console.log(problems.length === 0 ? 'RESULT reader round-trips the packed archive exactly' : 'RESULT FAILED')
|
||||
process.exit(problems.length === 0 ? 0 : 1)
|
||||
|
|
@ -0,0 +1,860 @@
|
|||
/**
|
||||
* Network checks: wire protocol, transports, and two peers playing over a socket.
|
||||
*
|
||||
* The claim being tested is not "messages encode" — it is that two independently
|
||||
* simulated worlds, driven only by messages that crossed a real byte pipe, stay
|
||||
* bit-identical; that a peer which goes quiet *stalls* the world instead of
|
||||
* corrupting it; and that a peer whose input is altered in flight is *detected*.
|
||||
*
|
||||
* The last section runs the same session code over a real TCP socket and real
|
||||
* WebSocket framing (through the relay in `scripts/net-relay.ts`), because an
|
||||
* in-memory pipe cannot prove that the browser transport works: it never frames,
|
||||
* never splits a message across TCP segments, and never delivers asynchronously.
|
||||
*
|
||||
* Usage: node scripts/verify-net.ts
|
||||
*/
|
||||
import { addPlayer, createWorld, damageMonster, spawnMonsters, tickCombat, tickCombatMulti } from '../src/game/combat.ts'
|
||||
import type { CombatOptions, CombatWorld, MonsterStats } from '../src/game/combat.ts'
|
||||
import { Rng } from '../src/game/rng.ts'
|
||||
import { LockstepSession } from '../src/net/lockstep.ts'
|
||||
import type { InputFrame, LockstepSimulation } from '../src/net/lockstep.ts'
|
||||
import { NetplaySession } from '../src/net/netplay.ts'
|
||||
import { decodeMessage, encodeMessage, NO_ACK, ProtocolError } from '../src/net/protocol.ts'
|
||||
import type { NetMessage } from '../src/net/protocol.ts'
|
||||
import { MemoryHub, memoryTransportPair, socketTransport, MemoryTransport } from '../src/net/transport.ts'
|
||||
import type { Transport } from '../src/net/transport.ts'
|
||||
import { parseTable } from '../src/game/tables.ts'
|
||||
import { monsterStatsFromTable } from '../src/game/combat.ts'
|
||||
import { startRelay } from './net-relay.ts'
|
||||
|
||||
const problems: string[] = []
|
||||
let checks = 0
|
||||
|
||||
/**
|
||||
* Assert one condition.
|
||||
*
|
||||
* @param condition - the condition to hold.
|
||||
* @param description - what it means.
|
||||
*/
|
||||
function expect(condition: boolean, description: string): void {
|
||||
checks += 1
|
||||
if (!condition) problems.push(description)
|
||||
}
|
||||
|
||||
// --- the wire codec ----------------------------------------------------------
|
||||
|
||||
const messages: NetMessage[] = [
|
||||
{ kind: 'hello', peer: 1, peers: 2, seed: 0xdeadbeef, ackTo: NO_ACK },
|
||||
{ kind: 'hello', peer: 0, peers: 2, seed: 0xdeadbeef, ackTo: 1 },
|
||||
{ kind: 'input', peer: 0, frame: { tick: 1234, movement: { x: -1, y: 0.5 }, attack: true, pickup: false, talk: true, skill: 3 } },
|
||||
{ kind: 'hash', peer: 1, tick: 987654, hash: 0x89abcdef },
|
||||
{ kind: 'bye', peer: 0 },
|
||||
]
|
||||
for (const message of messages) {
|
||||
const round = decodeMessage(encodeMessage(message))
|
||||
expect(round.kind === message.kind, `${message.kind} survives a round trip`)
|
||||
if (round.kind === 'input' && message.kind === 'input') {
|
||||
expect(round.frame.tick === message.frame.tick, 'the tick round-trips')
|
||||
expect(Math.abs(round.frame.movement.x - message.frame.movement.x) < 1e-3, 'the x movement round-trips as fixed point')
|
||||
expect(Math.abs(round.frame.movement.y - message.frame.movement.y) < 1e-3, 'the y movement round-trips as fixed point')
|
||||
expect(round.frame.attack && !round.frame.pickup && round.frame.talk, 'the control flags round-trip')
|
||||
expect(round.frame.skill === 3, 'the skill slot round-trips')
|
||||
}
|
||||
if (round.kind === 'hello' && message.kind === 'hello') expect(round.seed === 0xdeadbeef, 'the seed round-trips')
|
||||
if (round.kind === 'hash' && message.kind === 'hash') {
|
||||
expect(round.tick === 987654 && round.hash === 0x89abcdef, 'the hash and tick round-trip')
|
||||
}
|
||||
}
|
||||
|
||||
// The layout is pinned, not just self-consistent: a peer running an older build
|
||||
// must fail loudly rather than silently misread a field.
|
||||
const pinned = encodeMessage({ kind: 'input', peer: 1, frame: { tick: 1, movement: { x: -1, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 } })
|
||||
expect(pinned.byteLength === 12, 'an input message is twelve bytes')
|
||||
expect(pinned[0] === 2 && pinned[1] === 1, 'an input message starts with its type and peer')
|
||||
expect(pinned[6] === 0x18 && pinned[7] === 0xfc, 'a movement of -1 is fixed point -1000, little-endian')
|
||||
expect(pinned[10] === 1, 'only the attack flag is set')
|
||||
expect(encodeMessage({ kind: 'hash', peer: 0, tick: 0, hash: 0 }).byteLength === 10, 'a hash message is ten bytes')
|
||||
expect(encodeMessage({ kind: 'hello', peer: 0, peers: 1, seed: 0, ackTo: NO_ACK }).byteLength === 8, 'a hello message is eight bytes')
|
||||
const acked = decodeMessage(encodeMessage({ kind: 'hello', peer: 0, peers: 4, seed: 7, ackTo: 3 }))
|
||||
expect(acked.kind === 'hello' && acked.ackTo === 3, 'an acknowledgement names the peer it acknowledges')
|
||||
const unacked = decodeMessage(encodeMessage({ kind: 'hello', peer: 0, peers: 4, seed: 7, ackTo: NO_ACK }))
|
||||
expect(unacked.kind === 'hello' && unacked.ackTo === NO_ACK, 'a plain introduction is not mistaken for an acknowledgement')
|
||||
|
||||
const bad: [string, Uint8Array][] = [
|
||||
['an empty message', new Uint8Array(0)],
|
||||
['an unknown type', new Uint8Array([99, 0])],
|
||||
['a truncated input', new Uint8Array([2, 0, 0, 0])],
|
||||
['an over-long input', new Uint8Array(16)],
|
||||
['a hello claiming zero peers', new Uint8Array([1, 0, 0, 0, 0, 0, 0, 0])],
|
||||
['a hello claiming nine peers', new Uint8Array([1, 0, 9, 0, 0, 0, 0, 0])],
|
||||
['a truncated hash', new Uint8Array([3, 0, 0])],
|
||||
]
|
||||
for (const [label, bytes] of bad) {
|
||||
let rejected = false
|
||||
try {
|
||||
decodeMessage(bytes)
|
||||
} catch (error) {
|
||||
rejected = error instanceof ProtocolError
|
||||
}
|
||||
expect(rejected, `${label} is rejected before it reaches the simulation`)
|
||||
}
|
||||
|
||||
const movementOverflow = decodeMessage(encodeMessage({
|
||||
kind: 'input', peer: 0, frame: { tick: 0, movement: { x: 1e9, y: -1e9 }, attack: false, pickup: false, talk: false, skill: 255 },
|
||||
}))
|
||||
expect(movementOverflow.kind === 'input' && movementOverflow.frame.movement.x <= 32767, 'an absurd movement is clamped, not wrapped')
|
||||
|
||||
// --- transports --------------------------------------------------------------
|
||||
|
||||
const [endA, endB] = memoryTransportPair()
|
||||
let receivedAtB = 0
|
||||
endB.onMessage(() => { receivedAtB += 1 })
|
||||
expect(endA.open && endB.open, 'a fresh memory pair is open at both ends')
|
||||
endA.send(new Uint8Array([1, 2, 3]))
|
||||
expect(receivedAtB === 0, 'a memory message is queued, not delivered on send')
|
||||
endA.flush()
|
||||
expect(receivedAtB === 1, 'flushing delivers the queued message')
|
||||
const oversizeRejected = ((): boolean => {
|
||||
try {
|
||||
endA.send(new Uint8Array(5000))
|
||||
return false
|
||||
} catch { return true }
|
||||
})()
|
||||
expect(oversizeRejected, 'an oversized message is refused at the transport')
|
||||
let closed = 0
|
||||
endA.onClose(() => { closed += 1 })
|
||||
endB.close()
|
||||
expect(closed === 1 && !endA.open && !endB.open, 'closing one end closes the pair')
|
||||
endA.send(new Uint8Array([9]))
|
||||
endA.flush()
|
||||
expect(endA.dropped === 1, 'sending on a closed pair drops instead of throwing')
|
||||
|
||||
// --- two peers, one world, over a byte pipe ----------------------------------
|
||||
|
||||
const stats: MonsterStats[] = monsterStatsFromTable(parseTable([
|
||||
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
|
||||
// Fast enough to catch the player and weak enough that neither peer's player
|
||||
// dies before the kills can be compared: the point of this table is contact.
|
||||
'fallen\tFallen\t12\t1\t24\t36\t400\t220\t8',
|
||||
'zombie\tZombie\t30\t1\t32\t40\t300\t210\t15',
|
||||
].join('\n')))
|
||||
|
||||
const options: CombatOptions = {
|
||||
playerSpeed: 180, playerReach: 48, playerCooldownTicks: 12, playerDamage: 6,
|
||||
playerManaPerAttack: 2, respawnTicks: 40,
|
||||
}
|
||||
const xpTable: readonly number[] = [0, 0, 1000]
|
||||
|
||||
/** A world plus its random stream: everything a peer simulates. */
|
||||
interface Game { world: ReturnType<typeof createWorld>; rng: Rng }
|
||||
|
||||
/**
|
||||
* Build a fresh game, identical on both peers.
|
||||
*
|
||||
* @param seed - the world seed.
|
||||
* @returns the game.
|
||||
*/
|
||||
function newGame(seed: number): Game {
|
||||
const world = createWorld(0, 0)
|
||||
// The monsters start close enough to reach the player inside the first few
|
||||
// seconds: a network test where nothing ever fights would agree on nothing.
|
||||
spawnMonsters(world, stats, 5, { x: 30, y: 0 }, 40, { overlap: () => 0 })
|
||||
return { world, rng: new Rng(seed) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The input script: a pure function of the tick and the peer, so there is nothing
|
||||
* here that two peers could disagree about.
|
||||
*
|
||||
* @param peer - the peer index.
|
||||
* @param tick - the tick.
|
||||
* @returns movement and intent.
|
||||
*/
|
||||
function scriptedInput(peer: number, tick: number): { movement: { x: number; y: number }; attack: boolean; pickup: boolean; talk: boolean; skill: number } {
|
||||
const phase = Math.floor((tick + peer * 7) / 25) % 4
|
||||
const movement = phase === 0 ? { x: 1, y: 0 } : phase === 1 ? { x: 0, y: 1 } : phase === 2 ? { x: -1, y: 0 } : { x: 0, y: -1 }
|
||||
return { movement, attack: (tick + peer) % 5 === 0, pickup: tick % 41 === 0, talk: false, skill: (tick + peer) % 4 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a game as a lockstep simulation. Every peer applies *every* peer's input,
|
||||
* and the digest covers the whole world, so a field missed by one peer's
|
||||
* simulation shows up as a desync.
|
||||
*
|
||||
* @param game - the game to drive.
|
||||
* @returns the simulation.
|
||||
*/
|
||||
function asSimulation(game: Game): LockstepSimulation {
|
||||
return {
|
||||
advance: (inputs) => {
|
||||
for (const input of inputs) {
|
||||
// Only movement and attack reach the combat tick; pickup and talk belong
|
||||
// to the scene, so they are deliberately not part of the digest here.
|
||||
tickCombat(game.world, { movement: input.movement, attack: input.attack }, options, { overlap: () => 0 }, xpTable)
|
||||
if (input.attack && game.world.player.cooldown === 0) {
|
||||
const swing = game.rng.next() // the drop stream is part of the world
|
||||
game.world.monsters.forEach((monster, index) => {
|
||||
if (monster.state === 'dead') return
|
||||
if (Math.hypot(monster.x - game.world.player.x, monster.y - game.world.player.y) > 48) return
|
||||
damageMonster(game.world, index, options.playerDamage + (swing > 0.5 ? 1 : 0))
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
hash: () => LockstepSession.digest(digest(game)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Digest everything, so a divergence cannot hide in a field the hash forgets.
|
||||
*
|
||||
* @param game - the game.
|
||||
* @returns a digest string.
|
||||
*/
|
||||
function digest(game: Game): string {
|
||||
const round = (value: number): number => Math.round(value * 1000)
|
||||
return JSON.stringify({
|
||||
tick: game.world.tick,
|
||||
kills: game.world.kills,
|
||||
rng: game.rng.seed,
|
||||
events: game.world.events.map(event => [event.kind, event.subjectId ?? '', round(event.x), round(event.y)]),
|
||||
player: {
|
||||
x: round(game.world.player.x), y: round(game.world.player.y),
|
||||
hp: game.world.player.hp, mana: game.world.player.mana,
|
||||
cooldown: game.world.player.cooldown, facing: game.world.player.facing,
|
||||
alive: game.world.player.alive,
|
||||
},
|
||||
monsters: game.world.monsters.map(m => [round(m.x), round(m.y), m.hp, m.state, m.cooldown, m.hitFlash]),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A peer: its session and the game it drives.
|
||||
*/
|
||||
interface Peer { session: NetplaySession; game: Game }
|
||||
|
||||
/**
|
||||
* Build one networked peer.
|
||||
*
|
||||
* @param index - the peer index.
|
||||
* @param transport - its byte pipe.
|
||||
* @param seed - the world seed.
|
||||
* @returns the peer.
|
||||
*/
|
||||
function makePeer(index: number, transport: Transport, seed: number): Peer {
|
||||
const game = newGame(seed)
|
||||
const session = new NetplaySession(
|
||||
{ peer: index, peers: 2, seed, inputDelayTicks: 3, hashInterval: 1, timeoutTicks: 40 },
|
||||
asSimulation(game),
|
||||
transport,
|
||||
)
|
||||
const peer: Peer = { session, game }
|
||||
session.start()
|
||||
return peer
|
||||
}
|
||||
|
||||
// --- the quiet-peer case: a stall must not corrupt the world ------------------
|
||||
|
||||
const [pipeA, pipeB] = memoryTransportPair()
|
||||
const peerA = makePeer(0, pipeA, 0x51ed)
|
||||
const peerB = makePeer(1, pipeB, 0x51ed)
|
||||
// The handshake is owed by `start` and delivered by the first pump: a session is
|
||||
// normally created while its socket is still connecting, so sending on `start`
|
||||
// would write into a socket that is not open yet.
|
||||
expect(peerA.session.stats.helloSent === 0 && !peerA.session.stats.handshaked, 'nothing is sent before the session is pumped')
|
||||
peerA.session.pump()
|
||||
peerB.session.pump()
|
||||
pipeA.flush()
|
||||
expect(peerA.session.remoteSeed === 0x51ed && peerB.session.remoteSeed === 0x51ed, 'the handshake exchanges the world seed')
|
||||
expect(peerA.session.stats.handshaked && peerB.session.stats.handshaked, 'both peers complete the handshake')
|
||||
// The acknowledgements are still in flight; delivering them must end the
|
||||
// handshake for good rather than leaving either peer repeating itself.
|
||||
pipeA.flush()
|
||||
expect(peerA.session.stats.acknowledged && peerB.session.stats.acknowledged, 'both peers learn that their own hello was heard')
|
||||
for (let tick = 0; tick < 40; tick += 1) {
|
||||
peerA.session.pump()
|
||||
peerB.session.pump()
|
||||
pipeA.flush()
|
||||
}
|
||||
expect(
|
||||
peerA.session.stats.helloSent === 2 && peerB.session.stats.helloSent === 2,
|
||||
'a completed handshake stops: one hello and one acknowledgement each',
|
||||
)
|
||||
|
||||
const TICKS = 200
|
||||
let stepsA = 0
|
||||
let stepsB = 0
|
||||
for (let tick = 0; tick < TICKS; tick += 1) {
|
||||
const inputA = scriptedInput(0, tick)
|
||||
const inputB = scriptedInput(1, tick)
|
||||
peerA.session.setIntent(inputA)
|
||||
peerB.session.setIntent(inputB)
|
||||
if (peerA.session.pump().kind === 'stepped') stepsA += 1
|
||||
if (peerB.session.pump().kind === 'stepped') stepsB += 1
|
||||
// B's link is held for a stretch: A must stall, B must run on, and neither may
|
||||
// end up with a different world.
|
||||
pipeB.hold = tick >= 60 && tick < 90
|
||||
pipeA.flush()
|
||||
}
|
||||
// Drain the backlog the held link accumulated.
|
||||
for (let round = 0; round < 60; round += 1) {
|
||||
peerA.session.setIntent({ movement: { x: 0, y: 0 }, attack: false })
|
||||
peerB.session.setIntent({ movement: { x: 0, y: 0 }, attack: false })
|
||||
peerA.session.pump()
|
||||
peerB.session.pump()
|
||||
pipeA.flush()
|
||||
}
|
||||
|
||||
expect(peerA.session.desyncReport === null && peerB.session.desyncReport === null, 'a peer whose link stalls does not cause a desync')
|
||||
expect(peerA.session.stats.hashesCompared === peerA.session.stats.hashesAgreed, 'every hash peer 0 could check agreed')
|
||||
expect(peerA.session.stats.waiting > 0, 'the peer that waited counted the stalled ticks')
|
||||
expect(peerA.session.stats.hashesCompared > 0 && peerB.session.stats.hashesCompared > 0, 'both peers compared state hashes')
|
||||
expect(
|
||||
peerB.session.stats.hashesCompared === peerB.session.stats.hashesAgreed,
|
||||
'every hash that arrived at peer 1 agreed',
|
||||
)
|
||||
expect(peerA.session.stats.malformed === 0 && peerB.session.stats.malformed === 0, 'no message was malformed')
|
||||
expect(peerA.session.stats.timedOut === false, 'a link that stalls for a moment is not declared lost')
|
||||
expect(peerA.session.stats.sent > 100 && peerA.session.stats.received > 100, 'traffic actually crossed the pipe')
|
||||
expect(stepsA > 100 && stepsB > 100, 'both peers ran the game, one eventually catching up')
|
||||
expect(stepsB > stepsA, 'the peer whose link was open ran ahead while the other was starved')
|
||||
// A peer that is starved stops producing input, so its partner runs only as far
|
||||
// as the inputs it already holds and then waits too — the world as a whole is held
|
||||
// back, which is the point of lockstep. The two ends therefore resume with a small
|
||||
// constant lead rather than snapping level.
|
||||
const lead = peerB.session.lockstep.tick - peerA.session.lockstep.tick
|
||||
expect(lead >= 0 && lead <= 6, 'the two ends resume within a few ticks of each other')
|
||||
// The starved peer still holds the other's backlog, so it can be brought level
|
||||
// without any further traffic from the far end: pumping peer 0 alone (and holding
|
||||
// its own sends) replays exactly the ticks it was missing.
|
||||
for (let extra = 0; extra < 12 && peerA.session.lockstep.tick < peerB.session.lockstep.tick; extra += 1) {
|
||||
peerA.session.setIntent(scriptedInput(0, peerA.session.lockstep.tick))
|
||||
peerA.session.pump()
|
||||
}
|
||||
expect(
|
||||
peerA.session.lockstep.tick === peerB.session.lockstep.tick,
|
||||
'the starved peer can be brought level from the backlog it already holds',
|
||||
)
|
||||
expect(
|
||||
digest(peerA.game) === digest(peerB.game),
|
||||
'at the same tick, the peer that was starved holds exactly the other peer\'s world',
|
||||
)
|
||||
expect(peerA.game.world.kills === peerB.game.world.kills, 'both peers agree on the kill count')
|
||||
expect(peerA.game.rng.seed === peerB.game.rng.seed, 'both peers agree on the random stream position')
|
||||
expect(peerA.game.world.monsters.every((m, i) => m.hp === peerB.game.world.monsters[i]?.hp), 'both peers agree on monster health')
|
||||
expect(peerA.game.world.kills > 0, 'the test actually produced kills to disagree about')
|
||||
// The starved peer buffered the hashes that arrived for ticks it had not run yet,
|
||||
// so once it catches up it checks them all: being behind costs nothing.
|
||||
expect(peerA.session.stats.hashesIgnored === 0, 'a peer that fell behind still checks every hash it received')
|
||||
expect(peerA.session.stats.hashesCompared > 100, 'catching up results in a real number of compared hashes')
|
||||
expect(peerB.session.stats.hashesAgreed === peerB.session.stats.hashesCompared, 'every hash peer 1 could check agreed')
|
||||
|
||||
|
||||
// --- co-op: two peers, one shared world, one fighter each ---------------------
|
||||
|
||||
/**
|
||||
* Build a co-op world: two players, monsters between them.
|
||||
*
|
||||
* Both peers call this with the same seed and get the same world, which is the
|
||||
* whole premise of lockstep — nothing about the world is sent, only what the
|
||||
* players pressed.
|
||||
*
|
||||
* @returns the world.
|
||||
*/
|
||||
function newCoopWorld(): CombatWorld {
|
||||
const world = createWorld(-40, 0)
|
||||
addPlayer(world, 40, 0)
|
||||
spawnMonsters(world, coopStats, 6, { x: 0, y: 0 }, 90, { overlap: () => 0 })
|
||||
return world
|
||||
}
|
||||
|
||||
/**
|
||||
* Digest a co-op world.
|
||||
*
|
||||
* The player list is digested in peer order; `world.player` is a view alias and is
|
||||
* deliberately absent, because which player is "local" must not affect the state
|
||||
* two peers agree on.
|
||||
*
|
||||
* @param world - the world.
|
||||
* @returns a digest string.
|
||||
*/
|
||||
function coopDigest(world: CombatWorld): string {
|
||||
const round = (value: number): number => Math.round(value * 1000)
|
||||
return JSON.stringify({
|
||||
tick: world.tick,
|
||||
kills: world.kills,
|
||||
players: world.players.map(player => [round(player.x), round(player.y), player.hp, player.xp, player.level, player.alive]),
|
||||
monsters: world.monsters.map(m => [round(m.x), round(m.y), m.hp, m.state]),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A co-op simulation: each peer's input drives its own fighter.
|
||||
*
|
||||
* @param world - the shared world.
|
||||
* @returns the simulation.
|
||||
*/
|
||||
function coopSimulation(world: CombatWorld): LockstepSimulation {
|
||||
return {
|
||||
advance: (inputs) => {
|
||||
tickCombatMulti(world, inputs.map(input => ({ movement: input.movement, attack: input.attack })), options, { overlap: () => 0 }, xpTable)
|
||||
},
|
||||
hash: () => LockstepSession.digest(coopDigest(world)),
|
||||
}
|
||||
}
|
||||
|
||||
const coopStats: MonsterStats[] = monsterStatsFromTable(parseTable([
|
||||
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
|
||||
'fallen\tFallen\t14\t1\t24\t36\t400\t220\t8',
|
||||
].join('\n')))
|
||||
|
||||
const [coopPipeA, coopPipeB] = memoryTransportPair()
|
||||
const coopWorldA = newCoopWorld()
|
||||
const coopWorldB = newCoopWorld()
|
||||
const coopA = new NetplaySession(
|
||||
{ peer: 0, peers: 2, seed: 0x9a9a, inputDelayTicks: 3, hashInterval: 1, timeoutTicks: 400 },
|
||||
coopSimulation(coopWorldA), coopPipeA,
|
||||
)
|
||||
const coopB = new NetplaySession(
|
||||
{ peer: 1, peers: 2, seed: 0x9a9a, inputDelayTicks: 3, hashInterval: 1, timeoutTicks: 400 },
|
||||
coopSimulation(coopWorldB), coopPipeB,
|
||||
)
|
||||
coopA.start()
|
||||
coopB.start()
|
||||
coopPipeA.flush()
|
||||
for (let tick = 0; tick < 300; tick += 1) {
|
||||
coopA.setIntent(scriptedInput(0, tick))
|
||||
coopB.setIntent(scriptedInput(1, tick))
|
||||
coopA.pump()
|
||||
coopB.pump()
|
||||
coopPipeA.flush()
|
||||
}
|
||||
expect(coopA.desyncReport === null && coopB.desyncReport === null, 'two players in one world do not desync')
|
||||
expect(coopA.stats.hashesCompared > 200 && coopA.stats.hashesCompared === coopA.stats.hashesAgreed, 'every co-op hash peer 0 could check agreed')
|
||||
expect(coopB.stats.hashesCompared > 200 && coopB.stats.hashesCompared === coopB.stats.hashesAgreed, 'every co-op hash peer 1 could check agreed')
|
||||
expect(coopA.lockstep.tick >= 295 && coopB.lockstep.tick === coopA.lockstep.tick, 'both peers ran the whole co-op run, tick for tick together')
|
||||
expect(coopDigest(coopWorldA) === coopDigest(coopWorldB), 'the two peers hold the same two-player world')
|
||||
expect(coopWorldA.players.length === 2, 'the world really has two players in it')
|
||||
expect(coopWorldA.kills > 0, 'the two players killed something together')
|
||||
expect(coopWorldA.players[0]!.xp !== coopWorldA.players[1]!.xp, 'experience is credited to the peer that landed the killing blow')
|
||||
expect(coopWorldA.players[0]!.x !== coopWorldB.players[0]!.x || coopWorldA.players[0]!.y === coopWorldB.players[0]!.y, 'player 0 moved under its own input')
|
||||
expect(coopDigest(coopWorldA) !== coopDigest(newCoopWorld()), 'the co-op world actually changed while it ran')
|
||||
|
||||
// Monsters go for whoever is nearest, not for player 0 by seniority.
|
||||
const bait = createWorld(-300, 0)
|
||||
addPlayer(bait, 0, 0)
|
||||
bait.monsters.push({
|
||||
index: 0, stats: { ...coopStats[0]!, aggroRadius: 400, reach: 40, speed: 0 }, x: 6, y: 0,
|
||||
hp: 50, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
||||
})
|
||||
const idle = { movement: { x: 0, y: 0 }, attack: false }
|
||||
for (let tick = 0; tick < 40; tick += 1) tickCombatMulti(bait, [idle, idle], options, { overlap: () => 0 }, xpTable)
|
||||
expect(bait.players[1]!.hp < bait.players[1]!.maxHp, 'a monster attacks the nearest player')
|
||||
expect(bait.players[0]!.hp === bait.players[0]!.maxHp, 'a monster 300 pixels away from the other player leaves it alone')
|
||||
|
||||
// A dead peer must not stall the monsters or hide the living one.
|
||||
const bereaved = createWorld(-30, 0)
|
||||
addPlayer(bereaved, 30, 0)
|
||||
bereaved.players[1]!.hp = 0
|
||||
bereaved.players[1]!.alive = false
|
||||
bereaved.players[1]!.respawnIn = 30
|
||||
// The monster stands on top of the dead peer and within reach of the living one,
|
||||
// so a monster that still targeted corpses would attack the wrong player.
|
||||
bereaved.monsters.push({
|
||||
index: 0, stats: { ...coopStats[0]!, aggroRadius: 400, reach: 80, speed: 0 }, x: 30, y: 0,
|
||||
hp: 50, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
||||
})
|
||||
for (let tick = 0; tick < 10; tick += 1) tickCombatMulti(bereaved, [idle, idle], options, { overlap: () => 0 }, xpTable)
|
||||
expect(bereaved.players[0]!.hp < bereaved.players[0]!.maxHp, 'with one peer dead the monsters hunt the other')
|
||||
expect(!bereaved.players[1]!.alive && bereaved.players[1]!.hp === 0, 'a dead peer stays dead while its respawn timer runs')
|
||||
|
||||
// A hash whose tick has already fallen out of history is a different case: it
|
||||
// cannot be checked at all, and saying so is the honest answer.
|
||||
const [latePipeA, latePipeB] = memoryTransportPair()
|
||||
const lateA = makePeer(0, latePipeA, 0x6666)
|
||||
const lateB = makePeer(1, latePipeB, 0x6666)
|
||||
latePipeA.flush()
|
||||
for (let tick = 0; tick < 120; tick += 1) {
|
||||
lateA.session.setIntent(scriptedInput(0, tick))
|
||||
lateB.session.setIntent(scriptedInput(1, tick))
|
||||
lateA.session.pump()
|
||||
lateB.session.pump()
|
||||
latePipeA.flush()
|
||||
}
|
||||
latePipeB.send(encodeMessage({ kind: 'hash', peer: 1, tick: 0, hash: 0x1234 }))
|
||||
latePipeA.flush()
|
||||
for (let extra = 0; extra < 5; extra += 1) { lateA.session.pump(); latePipeA.flush() }
|
||||
expect(lateA.session.stats.hashesIgnored === 1, 'a hash for a tick that has left history is counted as uncheckable')
|
||||
expect(lateA.session.desyncReport === null, 'an uncheckable hash is not mistaken for a mismatch')
|
||||
expect(lateA.session.stats.hashesAgreed === lateA.session.stats.hashesCompared, 'a stale hash does not corrupt the agreement count')
|
||||
|
||||
// --- four peers, one world, one fighter each --------------------------------
|
||||
|
||||
/**
|
||||
* Build a world with a given number of players, in peer order.
|
||||
*
|
||||
* @param count - how many players.
|
||||
* @returns the world.
|
||||
*/
|
||||
function newHubWorld(count: number): CombatWorld {
|
||||
const world = createWorld(-60, 0)
|
||||
for (let index = 1; index < count; index += 1) addPlayer(world, -60 + index * 40, 0)
|
||||
spawnMonsters(world, coopStats, 8, { x: 0, y: 0 }, 140, { overlap: () => 0 })
|
||||
return world
|
||||
}
|
||||
|
||||
const FOUR = 4
|
||||
// The hub is grown rather than pre-sized: a peer that has not joined has no pipe
|
||||
// at all, which is the case worth testing — a transport that merely exists
|
||||
// handshakes immediately, because answering a hello is not a game action.
|
||||
const hub = new MemoryHub()
|
||||
const hubWorlds: CombatWorld[] = []
|
||||
const hubSessions: NetplaySession[] = []
|
||||
for (let index = 0; index < FOUR - 1; index += 1) {
|
||||
const world = newHubWorld(FOUR)
|
||||
hubWorlds.push(world)
|
||||
const session = new NetplaySession(
|
||||
{ peer: index, peers: FOUR, seed: 0x4bee, inputDelayTicks: 3, hashInterval: 1, timeoutTicks: 400 },
|
||||
coopSimulation(world),
|
||||
hub.attach(),
|
||||
)
|
||||
session.start()
|
||||
hubSessions.push(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pump one round: every running peer once, then deliver.
|
||||
*
|
||||
* @param tick - the scripted tick.
|
||||
*/
|
||||
function pumpRound(tick: number): void {
|
||||
for (let index = 0; index < hubSessions.length; index += 1) {
|
||||
hubSessions[index]!.setIntent(scriptedInput(index, tick))
|
||||
hubSessions[index]!.pump()
|
||||
}
|
||||
hub.flush()
|
||||
}
|
||||
|
||||
// Three peers are up; the fourth has not joined.
|
||||
for (let tick = 0; tick < 60; tick += 1) pumpRound(tick)
|
||||
expect(hubSessions[0]!.stats.peersHeard === 2, 'a session knows which of its peers it has heard from')
|
||||
expect(hubSessions[0]!.stats.peersExpected === 3, 'a four-peer session expects three other peers')
|
||||
expect(!hubSessions[0]!.stats.ready, 'a session expecting four peers is not ready with three')
|
||||
expect(hubSessions[0]!.lockstep.tick === 0, 'with a peer still missing, nobody starts the game')
|
||||
expect(hubSessions[0]!.stats.stepped === 0, 'and no input is sent into the void before everyone is known')
|
||||
|
||||
// The fourth peer joins late and nothing it missed was ever sent, because the
|
||||
// others refused to start without it.
|
||||
const lateWorld = newHubWorld(FOUR)
|
||||
const lateSession = new NetplaySession(
|
||||
{ peer: 3, peers: FOUR, seed: 0x4bee, inputDelayTicks: 3, hashInterval: 1, timeoutTicks: 400 },
|
||||
coopSimulation(lateWorld),
|
||||
hub.attach(),
|
||||
)
|
||||
lateSession.start()
|
||||
hubWorlds.push(lateWorld)
|
||||
hubSessions.push(lateSession)
|
||||
for (let tick = 60; tick < 360; tick += 1) pumpRound(tick)
|
||||
expect(hubSessions.every(session => session.stats.handshaked), 'all four peers complete the handshake')
|
||||
expect(hubSessions.every(session => session.stats.acknowledged), 'every peer is acknowledged by all the others')
|
||||
expect(hubSessions.every(session => session.stats.ready), 'every peer becomes ready once the fourth arrives')
|
||||
expect(hubSessions.every(session => session.lockstep.tick >= 290), 'all four peers ran the whole session')
|
||||
expect(
|
||||
new Set(hubSessions.map(session => session.lockstep.tick)).size === 1,
|
||||
'a late joiner runs level with the peers that waited for it, tick for tick',
|
||||
)
|
||||
expect(hubWorlds.every(world => world.players.length === FOUR), 'each world holds one fighter per peer')
|
||||
const hubDigests = new Set(hubWorlds.map(world => coopDigest(world)))
|
||||
expect(hubDigests.size === 1, 'all four independently simulated worlds are identical')
|
||||
expect(hubSessions.every(session => session.desyncReport === null), 'four peers do not desync')
|
||||
expect(
|
||||
hubSessions.every(session => session.stats.hashesCompared > 200 && session.stats.hashesAgreed === session.stats.hashesCompared),
|
||||
'every hash every peer could check agreed',
|
||||
)
|
||||
expect(
|
||||
hubSessions.every(session => session.stats.malformed === 0),
|
||||
'four peers exchange only well-formed messages',
|
||||
)
|
||||
expect(hubWorlds[0]!.kills > 0, 'four fighters killed something together')
|
||||
expect(
|
||||
hubWorlds[0]!.players.some((player, index) => index > 0 && player.x !== hubWorlds[0]!.players[0]!.x),
|
||||
'the fighters stand where their own peer drove them',
|
||||
)
|
||||
expect(hubSessions[0]!.peerSeeds.length === 3, 'every peer seed is recorded, not just the first')
|
||||
expect(hubSessions[0]!.peerSeeds.every(entry => entry.seed === 0x4bee), 'every peer reports the same world seed')
|
||||
|
||||
// One of the four goes quiet: the others must stall rather than run on without it.
|
||||
const beforeQuiet = hubSessions.map(session => session.lockstep.tick)
|
||||
hub.held.add(2)
|
||||
for (let tick = 360; tick < 420; tick += 1) pumpRound(tick)
|
||||
const stalledTicks = hubSessions.map((session, index) => session.lockstep.tick - beforeQuiet[index]!)
|
||||
expect(hubSessions.every(session => session.desyncReport === null), 'a quiet peer in a four-peer game causes no desync')
|
||||
expect(
|
||||
hubSessions[0]!.lockstep.tick <= hubSessions[2]!.lockstep.tick,
|
||||
'the peers that lost a partner ran no further than the one they lost',
|
||||
)
|
||||
expect(
|
||||
hubSessions.slice(0, 3).every(session => session.stats.waiting > 10),
|
||||
'the peers that lost a partner counted the ticks they could not run',
|
||||
)
|
||||
// Releasing the link leaves everyone the backlog they need to finish level.
|
||||
hub.held.delete(2)
|
||||
for (let tick = 420; tick < 520; tick += 1) pumpRound(tick)
|
||||
const target = Math.max(...hubSessions.map(session => session.lockstep.tick))
|
||||
for (let round = 0; round < 40; round += 1) {
|
||||
for (let index = 0; index < FOUR; index += 1) {
|
||||
const session = hubSessions[index]!
|
||||
if (session.lockstep.tick >= target) continue
|
||||
session.setIntent(scriptedInput(index, session.lockstep.tick))
|
||||
session.pump()
|
||||
}
|
||||
hub.flush()
|
||||
}
|
||||
expect(
|
||||
hubSessions.every(session => session.lockstep.tick === target),
|
||||
'after the link recovers, every peer can be brought level from its own backlog',
|
||||
)
|
||||
expect(new Set(hubWorlds.map(world => coopDigest(world))).size === 1, 'the four worlds are still identical after a stall')
|
||||
expect(stalledTicks[0]! < 60, 'the stall really did hold the other peers back')
|
||||
|
||||
// --- a peer that alters input in flight is detected --------------------------
|
||||
|
||||
/**
|
||||
* A transport that rewrites the movement in the first input message it forwards.
|
||||
*
|
||||
* This is the shape of a real bug: not a malicious peer, but a corrupted or
|
||||
* mismatched build. The field is chosen so the change *must* matter — a movement
|
||||
* of half a step moves the player, whereas flipping the attack flag on a tick with
|
||||
* nothing in reach changes nothing, and a tamper that changes nothing proves
|
||||
* nothing. The first input message is the warm-up frame for tick 0, which the send
|
||||
* cursor fills in so that the first ticks have inputs at all; the world must
|
||||
* therefore diverge at tick 0.
|
||||
*
|
||||
* @param inner - the transport to wrap.
|
||||
* @returns the tampering transport.
|
||||
*/
|
||||
function tamperingTransport(inner: MemoryTransport): Transport {
|
||||
let tampered = false
|
||||
return {
|
||||
send: (data) => {
|
||||
if (!tampered && data[0] === 2) {
|
||||
tampered = true
|
||||
const copy = new Uint8Array(data)
|
||||
copy[6] = 500 & 0xff // movement x := 0.5, a value the script never sends
|
||||
copy[7] = (500 >> 8) & 0xff
|
||||
inner.send(copy)
|
||||
return
|
||||
}
|
||||
inner.send(data)
|
||||
},
|
||||
onMessage: handler => { inner.onMessage(handler) },
|
||||
onClose: handler => { inner.onClose(handler) },
|
||||
close: () => { inner.close() },
|
||||
get open(): boolean { return inner.open },
|
||||
}
|
||||
}
|
||||
|
||||
const [honestPipe, cheatPipe] = memoryTransportPair()
|
||||
const honest = makePeer(0, honestPipe, 0x2222)
|
||||
const cheat = makePeer(1, tamperingTransport(cheatPipe), 0x2222)
|
||||
honestPipe.flush()
|
||||
for (let tick = 0; tick < 40; tick += 1) {
|
||||
honest.session.setIntent(scriptedInput(0, tick))
|
||||
cheat.session.setIntent(scriptedInput(1, tick))
|
||||
honest.session.pump()
|
||||
cheat.session.pump()
|
||||
honestPipe.flush()
|
||||
}
|
||||
const report = honest.session.desyncReport ?? cheat.session.desyncReport
|
||||
expect(report !== null, 'a tampered input is detected as a desync')
|
||||
expect(report !== null && report.tick === 0, 'the desync is reported at the tick the tampered input applied to')
|
||||
expect(report !== null && report.local !== report.remote, 'the report carries both digests so the divergence is diagnosable')
|
||||
expect(honest.session.stats.hashesCompared > 0, 'hashes were exchanged before the divergence was found')
|
||||
|
||||
// --- garbage on the wire is dropped, not fatal -------------------------------
|
||||
|
||||
const [cleanPipe, noisyPipe] = memoryTransportPair()
|
||||
const calm = makePeer(0, cleanPipe, 0x3333)
|
||||
const noisy = makePeer(1, noisyPipe, 0x3333)
|
||||
// One end receives an unknown type, the other an empty frame: both directions of
|
||||
// nonsense, delivered before either peer runs a tick.
|
||||
cleanPipe.send(new Uint8Array([200, 1, 2, 3]))
|
||||
noisyPipe.send(new Uint8Array(0))
|
||||
cleanPipe.flush()
|
||||
for (let tick = 0; tick < 20; tick += 1) {
|
||||
calm.session.setIntent(scriptedInput(0, tick))
|
||||
noisy.session.setIntent(scriptedInput(1, tick))
|
||||
calm.session.pump()
|
||||
noisy.session.pump()
|
||||
cleanPipe.flush()
|
||||
}
|
||||
expect(noisy.session.stats.malformed === 1, 'an unknown message type is counted and dropped')
|
||||
expect(calm.session.stats.malformed === 1, 'an empty frame is counted and dropped at the other end')
|
||||
expect(noisy.session.stats.stepped > 10, 'the session keeps running after a malformed message')
|
||||
expect(calm.session.desyncReport === null && noisy.session.desyncReport === null, 'garbage does not desync the game')
|
||||
expect(digest(calm.game) === digest(noisy.game), 'a malformed message leaves both worlds identical')
|
||||
|
||||
// --- a silent peer is a timeout, not a desync --------------------------------
|
||||
|
||||
// Peer 0's outbound link is held, so peer 1 hears nothing at all: that is the
|
||||
// "the other player vanished" case, and it must read as a loss, not a divergence.
|
||||
const [lostPipe, gonePipe] = memoryTransportPair()
|
||||
const heardFrom = makePeer(0, lostPipe, 0x4444)
|
||||
const starved = makePeer(1, gonePipe, 0x4444)
|
||||
lostPipe.flush()
|
||||
lostPipe.hold = true
|
||||
for (let tick = 0; tick < 60; tick += 1) {
|
||||
heardFrom.session.setIntent(scriptedInput(0, tick))
|
||||
starved.session.setIntent(scriptedInput(1, tick))
|
||||
heardFrom.session.pump()
|
||||
starved.session.pump()
|
||||
lostPipe.flush()
|
||||
}
|
||||
expect(starved.session.stats.timedOut, 'a peer nothing has been heard from is declared lost')
|
||||
expect(starved.session.stats.waiting > 20, 'the starved peer counted every tick it could not run')
|
||||
expect(starved.session.lockstep.tick === 0, 'the starved peer never fabricated the input it was missing')
|
||||
expect(starved.session.desyncReport === null && heardFrom.session.desyncReport === null, 'a lost peer is a timeout, never a desync')
|
||||
// The other end does *not* give up: its peer is still retrying its handshake, so
|
||||
// something is arriving. "No answer yet" and "no peer" are different states, and
|
||||
// the session is expected to tell them apart.
|
||||
expect(!heardFrom.session.stats.timedOut, 'a peer still trying to handshake is not declared lost')
|
||||
expect(starved.session.stats.helloSent > 1, 'a peer whose handshake goes unanswered keeps retrying it')
|
||||
// The other end hears those hellos and answers them, but its answers never
|
||||
// arrive, so its own handshake is never acknowledged and it keeps retrying too:
|
||||
// "no acknowledgement" is the condition for retrying, not "no answer".
|
||||
expect(heardFrom.session.stats.helloSent > 1, 'a peer whose own hello was never acknowledged keeps retrying it')
|
||||
expect(!heardFrom.session.stats.acknowledged, 'an unacknowledged handshake is never called complete')
|
||||
expect(heardFrom.session.lockstep.tick === 0, 'neither peer runs a tick before the handshake completes')
|
||||
|
||||
// --- goodbye -----------------------------------------------------------------
|
||||
|
||||
const [byePipe] = memoryTransportPair()
|
||||
const leaving = makePeer(0, byePipe, 0x5555)
|
||||
leaving.session.pump()
|
||||
byePipe.flush()
|
||||
leaving.session.leave()
|
||||
expect(!leaving.session.stats.connected, 'leaving closes the pipe')
|
||||
expect(leaving.session.stats.sent === 2, 'leaving sends exactly the goodbye after the handshake')
|
||||
|
||||
// --- the same sessions over a real socket ------------------------------------
|
||||
|
||||
const relay = await startRelay(0)
|
||||
const socketA = new WebSocket(relay.url)
|
||||
const socketB = new WebSocket(relay.url)
|
||||
await Promise.all([
|
||||
new Promise<void>(resolve => { socketA.addEventListener('open', () => { resolve() }) }),
|
||||
new Promise<void>(resolve => { socketB.addEventListener('open', () => { resolve() }) }),
|
||||
])
|
||||
expect(relay.accepted === 2, 'the relay accepted both peers')
|
||||
|
||||
const netA = makePeer(0, socketTransport(socketA as unknown as Parameters<typeof socketTransport>[0]), 0x7777)
|
||||
const netB = makePeer(1, socketTransport(socketB as unknown as Parameters<typeof socketTransport>[0]), 0x7777)
|
||||
|
||||
const deadline = Date.now() + 4000
|
||||
for (let tick = 0; tick < 150 && Date.now() < deadline; tick += 1) {
|
||||
netA.session.setIntent(scriptedInput(0, tick))
|
||||
netB.session.setIntent(scriptedInput(1, tick))
|
||||
netA.session.pump()
|
||||
netB.session.pump()
|
||||
// Real sockets deliver on the event loop, so the session is pumped more than
|
||||
// once per scripted tick: a peer that is ahead stalls until its peer catches up.
|
||||
await new Promise(resolve => { setTimeout(resolve, 8) })
|
||||
netA.session.pump()
|
||||
netB.session.pump()
|
||||
await new Promise(resolve => { setTimeout(resolve, 1) })
|
||||
}
|
||||
|
||||
expect(netA.session.stats.handshaked && netB.session.stats.handshaked, 'the handshake crosses a real socket')
|
||||
expect(relay.forwarded > 200, 'the relay forwarded traffic in both directions')
|
||||
expect(netA.session.stats.stepped > 120 && netB.session.stats.stepped > 120, 'both peers ran the game over the socket')
|
||||
expect(Math.abs(netA.session.stats.stepped - netB.session.stats.stepped) <= 1, 'the peers stay within one tick of each other')
|
||||
expect(netA.session.desyncReport === null && netB.session.desyncReport === null, 'no desync over a real socket')
|
||||
expect(
|
||||
netA.session.stats.hashesCompared > 50 && netA.session.stats.hashesCompared === netA.session.stats.hashesAgreed,
|
||||
'every hash that crossed the socket agreed',
|
||||
)
|
||||
expect(
|
||||
netB.session.stats.hashesCompared > 50 && netB.session.stats.hashesCompared === netB.session.stats.hashesAgreed,
|
||||
'every hash that crossed the socket agreed at the other peer',
|
||||
)
|
||||
expect(digest(netA.game) === digest(netB.game), 'two worlds fed by a real socket end up identical')
|
||||
expect(netA.session.stats.malformed === 0 && netB.session.stats.malformed === 0, 'the socket transport framed every message correctly')
|
||||
|
||||
// Closing the socket must be observed by the session rather than hanging it.
|
||||
netA.session.leave()
|
||||
await new Promise(resolve => { setTimeout(resolve, 40) })
|
||||
expect(!netB.session.stats.connected, 'a peer leaving over a socket is observed by the other end')
|
||||
|
||||
socketA.close()
|
||||
socketB.close()
|
||||
await relay.close()
|
||||
expect(relay.clients === 0, 'the relay closes every client')
|
||||
|
||||
// --- three peers over real sockets -------------------------------------------
|
||||
|
||||
const relayThree = await startRelay(0)
|
||||
const THREE = 3
|
||||
const threeWorlds = [newHubWorld(THREE), newHubWorld(THREE), newHubWorld(THREE)]
|
||||
const threeSessions: NetplaySession[] = []
|
||||
for (let index = 0; index < THREE; index += 1) {
|
||||
const socket = new WebSocket(relayThree.url)
|
||||
await new Promise<void>(resolve => { socket.addEventListener('open', () => { resolve() }) })
|
||||
const session = new NetplaySession(
|
||||
{ peer: index, peers: THREE, seed: 0x3eed, inputDelayTicks: 4, hashInterval: 1, timeoutTicks: 400 },
|
||||
coopSimulation(threeWorlds[index]!),
|
||||
socketTransport(socket as unknown as Parameters<typeof socketTransport>[0]),
|
||||
)
|
||||
session.start()
|
||||
threeSessions.push(session)
|
||||
}
|
||||
const threeSockets: WebSocket[] = []
|
||||
expect(relayThree.accepted === THREE, 'the relay accepts all three peers')
|
||||
|
||||
const threeDeadline = Date.now() + 6000
|
||||
for (let tick = 0; tick < 160 && Date.now() < threeDeadline; tick += 1) {
|
||||
for (let index = 0; index < THREE; index += 1) {
|
||||
threeSessions[index]!.setIntent(scriptedInput(index, tick))
|
||||
threeSessions[index]!.pump()
|
||||
}
|
||||
await new Promise(resolve => { setTimeout(resolve, 7) })
|
||||
for (let index = 0; index < THREE; index += 1) threeSessions[index]!.pump()
|
||||
await new Promise(resolve => { setTimeout(resolve, 1) })
|
||||
}
|
||||
expect(threeSessions.every(session => session.stats.handshaked), 'three peers complete the handshake over real sockets')
|
||||
expect(threeSessions.every(session => session.stats.acknowledged), 'three peers are acknowledged by each other')
|
||||
expect(threeSessions.every(session => session.desyncReport === null), 'three peers over sockets do not desync')
|
||||
expect(threeSessions.every(session => session.stats.malformed === 0), 'the socket transport frames every message for three peers')
|
||||
expect(
|
||||
threeSessions.every(session => session.stats.hashesCompared > 50 && session.stats.hashesAgreed === session.stats.hashesCompared),
|
||||
'three peers agree on every hash they could check',
|
||||
)
|
||||
// Level the peers that fell behind, then require byte-identical worlds.
|
||||
const threeTarget = Math.max(...threeSessions.map(session => session.lockstep.tick))
|
||||
for (let round = 0; round < 40; round += 1) {
|
||||
for (let index = 0; index < THREE; index += 1) {
|
||||
const session = threeSessions[index]!
|
||||
if (session.lockstep.tick >= threeTarget) continue
|
||||
session.setIntent(scriptedInput(index, session.lockstep.tick))
|
||||
session.pump()
|
||||
}
|
||||
await new Promise(resolve => { setTimeout(resolve, 4) })
|
||||
}
|
||||
expect(
|
||||
threeSessions.every(session => session.lockstep.tick === threeTarget),
|
||||
'three peers over sockets can be brought level from their backlog',
|
||||
)
|
||||
expect(
|
||||
new Set(threeWorlds.map(world => coopDigest(world))).size === 1,
|
||||
'three worlds fed by real sockets are identical',
|
||||
)
|
||||
for (const session of threeSessions) session.leave()
|
||||
await new Promise(resolve => { setTimeout(resolve, 40) })
|
||||
await relayThree.close()
|
||||
expect(relayThree.clients === 0, 'the three-peer relay closes every client')
|
||||
void threeSockets
|
||||
|
||||
console.log(`checks ${String(checks)}`)
|
||||
console.log(`problems ${String(problems.length)}`)
|
||||
for (const problem of problems.slice(0, 12)) console.log(` - ${problem}`)
|
||||
console.log(problems.length === 0 ? 'RESULT protocol, transports and two-peer lockstep hold' : 'RESULT FAILED')
|
||||
process.exit(problems.length === 0 ? 0 : 1)
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
// verify-object-lookup.ts — 验收"DS1 对象 id → 实际 token/mode"这层查找
|
||||
//
|
||||
// 这一层是移植来的(OpenDiablo2 的 object_lookup_record_data.go),所以它必须与**另一个独立
|
||||
// 来源**对得上才能算数:拿表里的 `objectsTxtId` 去 `Objects.txt` 查 Token,两者应当一致;
|
||||
// 不一致的地方要**逐条列出来**并写清为什么(实测正好 26 条,全是 `Objects.txt` 写了占位符
|
||||
// `SS`/`XX`/`SL`/`QO` 而表里是真 token 的情况)。
|
||||
//
|
||||
// node scripts/verify-object-lookup.ts [--dir=samples/d2]
|
||||
//
|
||||
// 退出码非 0 表示有断言没过。
|
||||
|
||||
export {}
|
||||
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { loadObjectsTable } from '../src/game/objects.ts'
|
||||
import type { ObjectsTable } from '../src/game/objects.ts'
|
||||
import { OBJECT_LOOKUP_WITH_ART, OBJECT_LOOKUP_WITH_ROW, OBJECT_LOOKUP_WITHOUT_ART } from '../src/game/object-lookup-data.ts'
|
||||
import { lookupObject, objectLookupStats } from '../src/game/object-lookup.ts'
|
||||
|
||||
/** 归档目录。 */
|
||||
const DIR = process.argv.find(argument => argument.startsWith('--dir='))?.slice('--dir='.length) ?? 'samples/d2'
|
||||
|
||||
/**
|
||||
* `Objects.txt` 的 Token 列与查找表不一致、且**以查找表为准**的记录。
|
||||
*
|
||||
* 这些行的 `Objects.txt` Token 是占位符(`SS`/`XX`/`SL`/`QO`/`5F`/`6T`/…),而表里是引擎真正
|
||||
* 使用的 token(例如 act 2 的 jerhyn 是 `JE`)。上一轮"有美术但 Objects.txt 里没有对应行"的
|
||||
* 那串 token(`5I 5J 5M 5N 5O 9C …`)正是这批。表是这三条来源里唯一的真值,所以这里是白名单
|
||||
* 而不是失败项;但**必须逐条固定**,任何新增都会让断言失败,逼人重新核对。
|
||||
*/
|
||||
const TOKEN_OVERRIDES: readonly string[] = [
|
||||
'1/110/385:DC',
|
||||
'1/113/0:29',
|
||||
'2/16/121:JE',
|
||||
'2/17/122:JE',
|
||||
'2/102/133:AZ',
|
||||
'3/13/194:9C',
|
||||
'3/93/361:XO',
|
||||
'3/109/378:HR',
|
||||
'3/110/379:HR',
|
||||
'4/19/363:XQ',
|
||||
'4/46/255:DI',
|
||||
'4/64/408:98',
|
||||
'4/65/409:99',
|
||||
'5/31/419:YO',
|
||||
'5/33/425:YU',
|
||||
'5/53/459:XS',
|
||||
'5/54/460:2N',
|
||||
'5/55/461:0J',
|
||||
'5/56/462:0J',
|
||||
'5/92/509:5M',
|
||||
'5/103/511:5O',
|
||||
'5/107/504:5I',
|
||||
'5/108/505:5J',
|
||||
'5/111/510:5N',
|
||||
'5/125/542:XR',
|
||||
'5/126/543:XR',
|
||||
]
|
||||
|
||||
/** 断言结果。 */
|
||||
const checks: { name: string; ok: boolean; detail: string }[] = []
|
||||
|
||||
/**
|
||||
* 记录一条断言。
|
||||
*
|
||||
* @param name - what was checked.
|
||||
* @param ok - whether it held.
|
||||
* @param detail - evidence.
|
||||
*/
|
||||
function check(name: string, ok: boolean, detail: string): void {
|
||||
checks.push({ name, ok, detail })
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${name} — ${detail}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程。
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const stats = objectLookupStats()
|
||||
console.log(`lookup: acts ${stats.acts.join(',')},记录 ${String(stats.rows)},有 token ${String(stats.withArt)},无 token ${String(stats.withoutArt)},有 Objects.txt 行 ${String(stats.withRow)}\n`)
|
||||
|
||||
check('五个 act 都有查找表', stats.acts.join(',') === '1,2,3,4,5', stats.acts.join(','))
|
||||
check('记录数与生成物一致', stats.rows === OBJECT_LOOKUP_WITH_ART + OBJECT_LOOKUP_WITHOUT_ART,
|
||||
`${String(stats.rows)} = ${String(OBJECT_LOOKUP_WITH_ART)} + ${String(OBJECT_LOOKUP_WITHOUT_ART)}`)
|
||||
check('有 token 的记录数稳定', stats.withArt === OBJECT_LOOKUP_WITH_ART && stats.withRow === OBJECT_LOOKUP_WITH_ROW,
|
||||
`withArt=${String(stats.withArt)} withRow=${String(stats.withRow)}`)
|
||||
check('记录数 = 3,614(3,615 条物体行里有 1 条重复 id 被覆盖)', stats.rows === 3614, `${String(stats.rows)} 条`)
|
||||
check('无 token 的记录数 = 193(不可见/占位对象)', stats.withoutArt === 193, `${String(stats.withoutArt)} 条`)
|
||||
|
||||
// 具体点位:这些名字是从社区表里读出来的,写成断言是为了让"表换了版本"立刻暴露。
|
||||
const spots: readonly [number, number, string, string, string][] = [
|
||||
[1, 0, 'FN', 'NU', 'rogue fountain'],
|
||||
[1, 1, 'TO', 'ON', 'torch 1 tiki'],
|
||||
[1, 2, 'RB', 'ON', 'Fire, rogue camp'],
|
||||
[1, 5, 'L1', 'NU', 'Chest, R Large'],
|
||||
[2, 17, 'JE', 'NU', 'jerhyn'],
|
||||
[5, 0, 'AO', 'NU', 'act 5 first object'],
|
||||
]
|
||||
for (const [act, id, token, mode, label] of spots) {
|
||||
const entry = lookupObject(act, 2, id)
|
||||
check(`act ${String(act)} id ${String(id)} → ${token}/${mode}(${label})`,
|
||||
entry !== null && entry.token === token && entry.mode === mode,
|
||||
entry === null ? '查不到' : `${entry.token}/${entry.mode}`)
|
||||
}
|
||||
check('怪物类型不命中本表', lookupObject(1, 1, 0) === null, 'type 1 走 MonPreset,不是本表')
|
||||
check('表里没有的 act 返回 null', lookupObject(9, 2, 0) === null, 'act 9 不存在')
|
||||
|
||||
// 与 Objects.txt 交叉核对。
|
||||
const archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`${DIR}/${name}`)))
|
||||
}
|
||||
const tables: ObjectsTable = await loadObjectsTable(archives)
|
||||
let same = 0
|
||||
let diffToken = 0
|
||||
let emptyToken = 0
|
||||
let missingRow = 0
|
||||
const unexpected: string[] = []
|
||||
const missingOverrides: string[] = []
|
||||
const seenOverrides = new Set<string>()
|
||||
const missingRowTokens: string[] = []
|
||||
for (const act of stats.acts) {
|
||||
for (const entry of lookupObjectRows(act)) {
|
||||
// 表说"没有 token"的记录(193 条)是不可见/占位对象:画不出来是对的。
|
||||
if (entry.token === '') { emptyToken += 1; continue }
|
||||
// 表给了 token 却没给行号:靠 token 反查元数据,下面单独断言。
|
||||
if (entry.objectsTxtId < 0) continue
|
||||
const row = tables.byId.get(entry.objectsTxtId)
|
||||
if (row === undefined) {
|
||||
// 社区表是对着另一版 Objects.txt 做的:有 3 条行号在 1.13c 里不存在。
|
||||
missingRow += 1
|
||||
missingRowTokens.push(entry.token)
|
||||
continue
|
||||
}
|
||||
const tableToken = row.token.trim().toUpperCase()
|
||||
const lookupToken = entry.token.toUpperCase()
|
||||
if (lookupToken === tableToken) { same += 1; continue }
|
||||
diffToken += 1
|
||||
const key = `${String(act)}/${String(entry.ds1Id)}/${String(entry.objectsTxtId)}:${lookupToken}`
|
||||
seenOverrides.add(key)
|
||||
if (!TOKEN_OVERRIDES.includes(key)) {
|
||||
unexpected.push(`${key}(Objects.txt 写的是 "${tableToken}",name "${row.name}")`)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of TOKEN_OVERRIDES) if (!seenOverrides.has(key)) missingOverrides.push(key)
|
||||
console.log(`\n 与 Objects.txt 交叉核对:一致 ${String(same)},表覆盖占位符 ${String(diffToken)},表说无 token ${String(emptyToken)},行号在 1.13c 里不存在 ${String(missingRow)}`)
|
||||
check('不一致的记录恰好是那批占位符 token', unexpected.length === 0,
|
||||
unexpected.length === 0 ? `${String(TOKEN_OVERRIDES.length)} 条全部对上` : unexpected.slice(0, 5).join(' | '))
|
||||
check('覆盖条数 = 26', diffToken === TOKEN_OVERRIDES.length, `${String(diffToken)} 条`)
|
||||
check('白名单没有过期条目', missingOverrides.length === 0,
|
||||
missingOverrides.length === 0 ? '全部仍然有效' : missingOverrides.slice(0, 5).join(' | '))
|
||||
check('与 Objects.txt 完全一致的记录数 = 527', same === 527, `${String(same)} 条`)
|
||||
check('表说无 token 的记录数 = 193', emptyToken === 193, `${String(emptyToken)} 条`)
|
||||
check('行号缺失的 3 条仍然带真 token', missingRow === 3 && missingRowTokens.sort().join(',') === '7C,PX,PY',
|
||||
`缺失行号的 token:${missingRowTokens.join(',')}`)
|
||||
|
||||
const passed = checks.filter(entry => entry.ok).length
|
||||
console.log(`\n${String(passed)}/${String(checks.length)} passed`)
|
||||
if (passed !== checks.length) process.exitCode = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 枚举一个 act 的全部记录(`lookupObject` 是单点查询,这里要遍历)。
|
||||
*
|
||||
* @param act - act 号.
|
||||
* @returns the entries of that act.
|
||||
*/
|
||||
function lookupObjectRows(act: number): { ds1Id: number; objectsTxtId: number; token: string }[] {
|
||||
const out: { ds1Id: number; objectsTxtId: number; token: string }[] = []
|
||||
for (let id = 0; id < 4000; id += 1) {
|
||||
const entry = lookupObject(act, 2, id)
|
||||
if (entry !== null) out.push(entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
await main()
|
||||
|
|
@ -0,0 +1,694 @@
|
|||
/**
|
||||
* Verify the DS1 object → art rule against the real Diablo II archives.
|
||||
*
|
||||
* The rule this checks is not a guess: it is ported from ThePhrozenKeep/D2MOO
|
||||
* (see the module comment in `src/game/objects.ts`), and the point of this script
|
||||
* is to prove the port against bytes the user supplied rather than against the
|
||||
* doc comment that describes it. Four independent things are measured:
|
||||
*
|
||||
* 1. **Where the object art actually is.** The project's notes said `d2char.mpq`;
|
||||
* the archives say otherwise, and this prints the per-archive counts so the
|
||||
* claim is checkable rather than asserted.
|
||||
* 2. **The composition template.** Every object COF in the archives declares its
|
||||
* layers as *composite indices*, not file names, so the sprite path has to be
|
||||
* reconstructed from the COF's own name plus that index — the same trick
|
||||
* `scripts/verify-dcc.ts` uses for the Sorceress. This rebuilds all 1746 layer
|
||||
* references and reports exactly which ones the rule does not reach.
|
||||
* 3. **Every object placed by a preset level.** `Levels.txt` rows with
|
||||
* `DrlgType === 2` name fixed DS1 files, so this walks all 35 of them, decodes
|
||||
* the maps and resolves each placed object's art, grouping the results by
|
||||
* level type. Any object that fails to resolve fails the run — a packer that
|
||||
* silently drops one is the bug this exists to catch.
|
||||
* 4. **The draw anchors.** `DUNGEON_GameToClientTileDrawPositionCoords` and
|
||||
* `DUNGEON_GameToClientSubtileDrawPositionCoords` (D2Common ordinals 10115 and
|
||||
* 10117) are printed next to the formulas this project currently uses, and the
|
||||
* DT1 `minBlockY` of every floor and wall tile a preset level actually
|
||||
* references is measured, because that is the term that decides whether the
|
||||
* project's wall formula and the engine's agree.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-objects.ts [archive-directory]
|
||||
*
|
||||
* Exits non-zero when an object placed in a preset level cannot be resolved, when
|
||||
* a sampled member decodes to empty pixels, or when the ported coordinate
|
||||
* arithmetic fails its own round-trips.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { cell, loadActTables, resolveLevel } from '../src/game/acts.ts'
|
||||
import type { ActTables } from '../src/game/acts.ts'
|
||||
import { decodeDs1 } from '../src/formats/ds1.ts'
|
||||
import type { Ds1 } from '../src/formats/ds1.ts'
|
||||
import { decodeDt1 } from '../src/formats/dt1.ts'
|
||||
import type { Dt1, Dt1Tile } from '../src/formats/dt1.ts'
|
||||
import { decodeCof } from '../src/formats/cof.ts'
|
||||
import { decodeDcc } from '../src/formats/dcc.ts'
|
||||
import { decodeDc6 } from '../src/formats/dc6.ts'
|
||||
import {
|
||||
OBJECT_COMPONENTS,
|
||||
OBJECT_MODE_COUNT,
|
||||
OBJECT_MODE_TOKENS,
|
||||
clientSubtileDrawPositionToGameCoords,
|
||||
clientTileDrawPositionToGameCoords,
|
||||
gameSubtileToClientCoords,
|
||||
gameTileToClientCoords,
|
||||
loadObjectSheet,
|
||||
loadObjectsTable,
|
||||
objectAnchor,
|
||||
objectCofMember,
|
||||
objectDrawAnchor,
|
||||
objectSpriteMember,
|
||||
resolveDs1Object,
|
||||
resolveObjectArt,
|
||||
subtileDrawPositionCoords,
|
||||
tileDrawPositionCoords,
|
||||
} from '../src/game/objects.ts'
|
||||
import type { ObjectsRow, ObjectsTable } from '../src/game/objects.ts'
|
||||
|
||||
/** Archives to mount, in load order (later overrides earlier). */
|
||||
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
||||
/**
|
||||
* Community listfile that supplies the names Storm stores encrypted.
|
||||
*
|
||||
* `d2data.mpq` and `Patch_D2.mpq` ship no `(listfile)`, so without it "this member
|
||||
* is not in the archives" would only mean "not in the archives we could enumerate".
|
||||
* The file is optional: when it is absent the sweep still runs, but the member
|
||||
* index is then incomplete and the run says so.
|
||||
*/
|
||||
const LISTFILE = 'listfile_113c.txt'
|
||||
/** Prefix every object member lives under. */
|
||||
const OBJECT_ROOT = 'data\\global\\objects\\'
|
||||
/** `DrlgType` value for a preset level: its layout is a fixed DS1. */
|
||||
const DRLG_PRESET = '2'
|
||||
/** Cap on members decoded in the sample sweep, so the run stays bounded. */
|
||||
const DECODE_SAMPLE_LIMIT = 60
|
||||
/** Cap on individually printed failures. */
|
||||
const MAX_REPORTED_FAILURES = 25
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const archiveDir = args.find(argument => !argument.startsWith('--')) ?? 'samples/d2'
|
||||
|
||||
let checks = 0
|
||||
let failures = 0
|
||||
const failureLog: string[] = []
|
||||
|
||||
/**
|
||||
* Record one assertion.
|
||||
*
|
||||
* @param ok - whether it held.
|
||||
* @param what - what was asserted, printed only when it fails.
|
||||
*/
|
||||
function check(ok: boolean, what: string): void {
|
||||
checks += 1
|
||||
if (ok) return
|
||||
failures += 1
|
||||
if (failureLog.length < MAX_REPORTED_FAILURES) failureLog.push(what)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a condition, returning it for reuse.
|
||||
*
|
||||
* @param ok - whether it held.
|
||||
* @param what - description used in the failure log.
|
||||
* @returns `ok`.
|
||||
*/
|
||||
function assert(ok: boolean, what: string): boolean {
|
||||
check(ok, what)
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Format a number with a leading sign, for the anchor comparison table. */
|
||||
function signed(value: number): string {
|
||||
return value >= 0 ? `+${String(value)}` : String(value)
|
||||
}
|
||||
|
||||
/** Open the four archives and mount them the way the game loads them. */
|
||||
async function mount(): Promise<MountedArchives> {
|
||||
const archives = new MountedArchives()
|
||||
let listfile: string[] | undefined
|
||||
try {
|
||||
listfile = readFileSync(`${archiveDir}/${LISTFILE}`, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
console.log(` listfile: ${String(listfile.length)} names from ${LISTFILE}`)
|
||||
} catch {
|
||||
console.log(` listfile: ${LISTFILE} missing — member index is incomplete`)
|
||||
listfile = undefined
|
||||
}
|
||||
for (const name of MOUNTS) {
|
||||
const archive = await MpqArchive.open(await fileSource(`${archiveDir}/${name}`), { listfile })
|
||||
archives.add(name, archive)
|
||||
}
|
||||
return archives
|
||||
}
|
||||
|
||||
/** The union of every mounted archive's member list, plus a lower-case index. */
|
||||
interface MemberSet {
|
||||
readonly names: readonly string[]
|
||||
readonly lower: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the case-insensitive member index.
|
||||
*
|
||||
* Diablo II's own lookups go through Storm's case-insensitive hash and the
|
||||
* archives really do mix case (`Data\Global\Objects\1Y\S1\1ys1litnuhth.DC6`
|
||||
* against `data\global\objects\c5\tr\c5trlitnuhth.dcc`), so every comparison here
|
||||
* is on the lower-cased name while the archive's own spelling is what gets used
|
||||
* for reads.
|
||||
*
|
||||
* @param archives - the mounted stack.
|
||||
* @returns the names and the index.
|
||||
*/
|
||||
async function memberSet(archives: MountedArchives): Promise<MemberSet> {
|
||||
const names: string[] = []
|
||||
for (const entry of archives.mounted) names.push(...await entry.archive.listFiles())
|
||||
const lower = new Map<string, string>()
|
||||
for (const name of names) if (!lower.has(name.toLowerCase())) lower.set(name.toLowerCase(), name)
|
||||
return { names, lower }
|
||||
}
|
||||
|
||||
/** Per-level-type tally for the preset-level walk. */
|
||||
interface LevelTypeTally {
|
||||
levels: number
|
||||
objects: number
|
||||
resolved: number
|
||||
fallbacks: number
|
||||
/** Objects the archives ship no sprite for at all; `null` is the correct answer. */
|
||||
artless: number
|
||||
/** DS1 entries that are monster spawn points (`type` 1), not objects. */
|
||||
monsterSpawns: number
|
||||
modeGuesses: number
|
||||
undefinedRows: number
|
||||
tokens: Set<string>
|
||||
}
|
||||
|
||||
/** Measure 1: where the object art lives, and how much of it there is. */
|
||||
async function reportArchiveLayout(archives: MountedArchives): Promise<void> {
|
||||
console.log('== object art in the archives ==')
|
||||
let dataTotal = 0
|
||||
for (const entry of archives.mounted) {
|
||||
const names = await entry.archive.listFiles()
|
||||
const objects = names.filter(name => name.toLowerCase().startsWith(OBJECT_ROOT))
|
||||
const dcc = objects.filter(name => name.toLowerCase().endsWith('.dcc')).length
|
||||
const cof = objects.filter(name => name.toLowerCase().endsWith('.cof')).length
|
||||
const dc6 = objects.filter(name => name.toLowerCase().endsWith('.dc6')).length
|
||||
console.log(` ${entry.label.padEnd(14)} members=${String(names.length).padStart(6)} objects=${String(objects.length).padStart(5)} dcc=${String(dcc).padStart(5)} cof=${String(cof).padStart(5)} dc6=${String(dc6).padStart(3)}`)
|
||||
if (entry.label === 'd2data.mpq' || entry.label === 'd2exp.mpq') dataTotal += objects.length
|
||||
}
|
||||
assert(dataTotal > 0, 'object art must exist in d2data.mpq/d2exp.mpq')
|
||||
console.log(' (note: d2char.mpq holds character art only; object art is in d2data/d2exp)')
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure 2: rebuild every COF layer's sprite path and see how far the rule goes.
|
||||
*
|
||||
* @param archives - the mounted stack.
|
||||
* @param members - the member index.
|
||||
* @param tables - the parsed `Objects.txt`.
|
||||
* @returns the tally, for the summary.
|
||||
*/
|
||||
async function verifyCompositionTemplate(
|
||||
archives: MountedArchives,
|
||||
members: MemberSet,
|
||||
tables: ObjectsTable,
|
||||
): Promise<{ layers: number; dcc: number; dc6: number; unresolved: readonly string[]; cofs: number; prefixes: number }> {
|
||||
console.log('== COF -> DCC template (D2Common_10884_COMPOSIT_unk) ==')
|
||||
const cofs = members.names
|
||||
.filter(name => name.toLowerCase().startsWith(OBJECT_ROOT) && name.toLowerCase().endsWith('.cof'))
|
||||
.sort()
|
||||
const byToken = new Map<string, ObjectsRow>()
|
||||
for (const row of tables.rows) if (!byToken.has(row.token)) byToken.set(row.token, row)
|
||||
|
||||
let layers = 0
|
||||
let dcc = 0
|
||||
let dc6 = 0
|
||||
let prefixes = 0
|
||||
const unresolved: string[] = []
|
||||
const unknownTokens = new Set<string>()
|
||||
for (const cofMember of cofs) {
|
||||
const parts = cofMember.split('\\')
|
||||
const token = (parts[3] ?? '').toLowerCase()
|
||||
const file = (parts[5] ?? '').replace(/\.cof$/i, '')
|
||||
if (!byToken.has(token.toUpperCase())) unknownTokens.add(token.toUpperCase())
|
||||
let cof
|
||||
try {
|
||||
cof = decodeCof(await archives.read(cofMember))
|
||||
} catch (err) {
|
||||
unresolved.push(`${cofMember}: ${(err as Error).message}`)
|
||||
continue
|
||||
}
|
||||
// The COF's name is <TOKEN><MODE><WEAPON>; the suffix length comes from the
|
||||
// layer record itself rather than from a hard-coded 3.
|
||||
const weapon = cof.layers[0]?.weaponClass ?? 'hth'
|
||||
const mode = file.slice(token.length, file.length - weapon.length).toLowerCase()
|
||||
if (cofMember.toLowerCase().split('\\').slice(0, 3).join('\\') === OBJECT_ROOT.slice(0, -1)) prefixes += 1
|
||||
for (const layer of cof.layers) {
|
||||
layers += 1
|
||||
const component = OBJECT_COMPONENTS[layer.type]
|
||||
if (component === undefined) { unresolved.push(`${cofMember}: composite ${String(layer.type)} has no directory`); continue }
|
||||
const wanted = `${OBJECT_ROOT}${token}\\${component}\\${token}${component}lit${mode}${weapon}.dcc`
|
||||
if (members.lower.has(wanted)) { dcc += 1; continue }
|
||||
if (members.lower.has(wanted.replace(/\.dcc$/i, '.dc6'))) { dc6 += 1; continue }
|
||||
unresolved.push(wanted)
|
||||
}
|
||||
}
|
||||
const resolved = dcc + dc6
|
||||
console.log(` cofs=${String(cofs.length)} layers=${String(layers)} resolved=${String(resolved)} (.dcc ${String(dcc)} / .dc6 ${String(dc6)}) unresolved=${String(unresolved.length)}`)
|
||||
console.log(` tokens with art but no Objects.txt row: ${[...unknownTokens].sort().join(' ') || 'none'}`)
|
||||
for (const miss of unresolved.slice(0, 8)) console.log(` unresolved: ${miss}`)
|
||||
if (unresolved.length > 8) console.log(` ... ${String(unresolved.length - 8)} more`)
|
||||
// The five known duds (E1/E2/HO/TP/YQ ship a COF whose layer sprite is absent,
|
||||
// or spell the armor class differently); everything else must resolve.
|
||||
check(layers > 1700, `expected ~1746 object COF layer references, saw ${String(layers)}`)
|
||||
check(resolved / Math.max(layers, 1) >= 0.99, `at least 99% of layers must resolve, saw ${String(resolved)}/${String(layers)}`)
|
||||
return { layers, dcc, dc6, unresolved, cofs: cofs.length, prefixes }
|
||||
}
|
||||
|
||||
/** One placed object, reduced to what the report needs. */
|
||||
interface Placement {
|
||||
readonly levelId: number
|
||||
readonly levelName: string
|
||||
readonly levelType: string
|
||||
readonly row: ObjectsRow
|
||||
readonly member: string | null
|
||||
readonly mode: string
|
||||
readonly anchor: { readonly x: number; readonly y: number }
|
||||
readonly guessedMode: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure 3: walk every preset level and resolve every object it places.
|
||||
*
|
||||
* @param archives - the mounted stack.
|
||||
* @param members - the member index.
|
||||
* @param tables - the parsed `Objects.txt`.
|
||||
* @param levelTables - the level tables.
|
||||
* @returns every placement, in walk order.
|
||||
*/
|
||||
async function walkPresetObjects(
|
||||
archives: MountedArchives,
|
||||
members: MemberSet,
|
||||
tables: ObjectsTable,
|
||||
levelTables: ActTables,
|
||||
): Promise<{ placements: readonly Placement[]; tallies: Map<string, LevelTypeTally>; ds1Count: number; levels: number }> {
|
||||
console.log('== objects placed by the 35 preset levels (Levels.txt DrlgType=2) ==')
|
||||
const presetRows = levelTables.levels.rows.filter(row => cell(levelTables.levels, row, 'DrlgType') === DRLG_PRESET)
|
||||
const libraryCache = new Map<string, Dt1>()
|
||||
const tallies = new Map<string, LevelTypeTally>()
|
||||
const placements: Placement[] = []
|
||||
let ds1Count = 0
|
||||
let levels = 0
|
||||
for (const levelRow of presetRows) {
|
||||
const levelId = Number(cell(levelTables.levels, levelRow, 'Id'))
|
||||
let info
|
||||
try {
|
||||
info = resolveLevel(levelTables, levelId)
|
||||
} catch (err) {
|
||||
failures += 1
|
||||
failureLog.push(`level ${String(levelId)}: ${(err as Error).message}`)
|
||||
continue
|
||||
}
|
||||
levels += 1
|
||||
const tally = tallies.get(info.levelTypeName) ?? {
|
||||
levels: 0, objects: 0, resolved: 0, fallbacks: 0, artless: 0, monsterSpawns: 0,
|
||||
modeGuesses: 0, undefinedRows: 0, tokens: new Set<string>(),
|
||||
}
|
||||
tally.levels += 1
|
||||
tallies.set(info.levelTypeName, tally)
|
||||
|
||||
let ds1: Ds1
|
||||
let found = false
|
||||
for (const ds1Name of info.ds1Names) {
|
||||
try {
|
||||
ds1 = decodeDs1(await archives.read(ds1Name))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
ds1Count += 1
|
||||
for (const object of ds1.objects) {
|
||||
tally.objects += 1
|
||||
let resolved
|
||||
try {
|
||||
resolved = resolveDs1Object(tables, info.act, object.type, object.id)
|
||||
} catch (err) {
|
||||
tally.undefinedRows += 1
|
||||
failures += 1
|
||||
if (failureLog.length < MAX_REPORTED_FAILURES) failureLog.push(`${info.levelName} ${ds1Name}: ${(err as Error).message}`)
|
||||
continue
|
||||
}
|
||||
// DS1 `type` 1 is a monster spawn point, not an object: it has no object art and
|
||||
// this project has no monsters, so it is counted and left alone.
|
||||
if (resolved.kind === 'monster') {
|
||||
tally.monsterSpawns += 1
|
||||
continue
|
||||
}
|
||||
const row = resolved.row
|
||||
tally.tokens.add(resolved.token)
|
||||
const art = resolveObjectArt({
|
||||
object,
|
||||
token: resolved.token,
|
||||
row: row === null
|
||||
? null
|
||||
: { name: row.name, token: resolved.token, subClass: row.subClass, mode: 0, hp: 0 },
|
||||
mode: resolved.mode,
|
||||
members: members.names,
|
||||
})
|
||||
const guessedMode = art.notes.some(note => note.includes('fell back to'))
|
||||
if (guessedMode) tally.modeGuesses += 1
|
||||
// `null` is the correct answer only when the archives really ship no sprite for
|
||||
// this token *and this mode*: `Objects.txt` has invisible helper rows (`Dummy`)
|
||||
// and modes that were never drawn (a portal's closed `NU` state has a COF but no
|
||||
// `NU` DCC). A token that does have art for the requested mode may not resolve to
|
||||
// null — that is the case this assertion exists to catch.
|
||||
const tokenRoot = `\\objects\\${resolved.token.trim().toLowerCase()}\\`
|
||||
const modeTag = `lit${art.mode.toLowerCase()}hth.`
|
||||
const modeHasSprite = members.names.some((name) => {
|
||||
const lower = name.toLowerCase()
|
||||
return lower.includes(tokenRoot) && lower.includes(modeTag)
|
||||
})
|
||||
if (art.member === null) {
|
||||
tally.fallbacks += 1
|
||||
if (!assert(!modeHasSprite,
|
||||
`${info.levelName}: ${resolved.token} has ${art.mode} art in the archives but resolved to null`)) continue
|
||||
tally.artless += 1
|
||||
continue
|
||||
}
|
||||
tally.resolved += 1
|
||||
const exists = members.lower.has(art.member.toLowerCase())
|
||||
if (!assert(exists, `${info.levelName}: ${resolved.token} member ${String(art.member)} is not in the archives`)) continue
|
||||
if (!assert(Number.isFinite(art.anchor.x) && Number.isFinite(art.anchor.y), `${info.levelName}: ${resolved.token} anchor is not finite`)) continue
|
||||
placements.push({
|
||||
levelId,
|
||||
levelName: info.levelName,
|
||||
levelType: info.levelTypeName,
|
||||
// Records with no Objects.txt row still have a real token; synthesise the
|
||||
// metadata row so downstream reporting has one shape to deal with.
|
||||
row: row ?? {
|
||||
id: object.id, name: resolved.token, token: resolved.token, subClass: 0, act: info.act,
|
||||
sizeX: 0, sizeY: 0, xOffset: 0, yOffset: 0, isDoor: false, trans: 0, draw: 0,
|
||||
totalPieces: 0, autoMap: 0, mode: [], selectable: [], frameCnt: [], frameDelta: [],
|
||||
start: [], cycleAnim: [], lit: [], components: [],
|
||||
},
|
||||
member: art.member,
|
||||
mode: art.mode,
|
||||
anchor: art.anchor,
|
||||
guessedMode,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
if (!found) {
|
||||
failures += 1
|
||||
failureLog.push(`level ${String(levelId)} (${info.levelName}): none of its ${String(info.ds1Names.length)} DS1 members decoded`)
|
||||
}
|
||||
// Keep the DT1 libraries of the last level of each type reachable for the
|
||||
// anchor measurement below.
|
||||
for (const dt1Name of info.dt1Names) {
|
||||
if (libraryCache.has(dt1Name)) continue
|
||||
libraryCache.set(dt1Name, decodeDt1(await archives.read(dt1Name)))
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' level type levels objects resolved no-art monsters mode-guessed tokens')
|
||||
// `no-art` counts objects the archives genuinely ship no sprite for (invisible helper
|
||||
// rows and modes that were never drawn); `resolved` counts the ones that drew something.
|
||||
for (const [type, tally] of [...tallies].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
console.log(` ${type.padEnd(28)} ${String(tally.levels).padStart(6)} ${String(tally.objects).padStart(8)} ${String(tally.resolved).padStart(9)} ${String(tally.artless).padStart(7)} ${String(tally.monsterSpawns).padStart(8)} ${String(tally.modeGuesses).padStart(13)} ${String(tally.tokens.size).padStart(6)}`)
|
||||
}
|
||||
const tokens = new Set(placements.map(placement => placement.row.token))
|
||||
console.log(` distinct tokens across all preset levels: ${String(tokens.size)} -> ${[...tokens].sort().join(' ')}`)
|
||||
assert(levels === 35, `expected 35 preset levels, saw ${String(levels)}`)
|
||||
assert(placements.length > 0, 'at least one object must resolve')
|
||||
return { placements, tallies, ds1Count, levels }
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure 4a: decode a sample of the resolved members and prove they have pixels.
|
||||
*
|
||||
* A name that exists is not art: this is what catches a rule that reconstructs a
|
||||
* *plausible* path to a real file with the wrong contents, or a decoder that
|
||||
* returns zeroed frames.
|
||||
*
|
||||
* @param archives - the mounted stack.
|
||||
* @param members - the member index.
|
||||
* @param placements - the resolved placements.
|
||||
* @returns the number of members decoded and the total opaque pixels seen.
|
||||
*/
|
||||
async function decodeSample(
|
||||
archives: MountedArchives,
|
||||
members: MemberSet,
|
||||
placements: readonly Placement[],
|
||||
): Promise<{ decoded: number; frames: number; opaque: number }> {
|
||||
console.log('== sample decode of resolved members ==')
|
||||
const distinct = new Map<string, Placement>()
|
||||
for (const placement of placements) if (placement.member !== null && !distinct.has(placement.member)) distinct.set(placement.member, placement)
|
||||
const chosen: Placement[] = []
|
||||
const seenToken = new Set<string>()
|
||||
for (const placement of [...distinct.values()].sort((a, b) => a.row.token.localeCompare(b.row.token))) {
|
||||
if (seenToken.has(placement.row.token)) continue
|
||||
seenToken.add(placement.row.token)
|
||||
chosen.push(placement)
|
||||
if (chosen.length >= DECODE_SAMPLE_LIMIT) break
|
||||
}
|
||||
let decoded = 0
|
||||
let frames = 0
|
||||
let opaque = 0
|
||||
for (const placement of chosen) {
|
||||
const member = members.lower.get((placement.member ?? '').toLowerCase())
|
||||
if (member === undefined) continue
|
||||
const bytes = await archives.read(member)
|
||||
const isDc6 = member.toLowerCase().endsWith('.dc6')
|
||||
let frameList: { width: number; height: number; mask: Uint8Array }[] = []
|
||||
try {
|
||||
if (isDc6) {
|
||||
const sheet = decodeDc6(bytes)
|
||||
frameList = sheet.groups.flatMap(group => group.frames.map(frame => ({ width: frame.width, height: frame.height, mask: frame.mask })))
|
||||
} else {
|
||||
const file = decodeDcc(bytes)
|
||||
frameList = file.directions.flatMap(direction => direction.frames.map(frame => ({ width: frame.frame.width, height: frame.frame.height, mask: frame.frame.mask })))
|
||||
}
|
||||
} catch (err) {
|
||||
assert(false, `${member}: ${(err as Error).message}`)
|
||||
continue
|
||||
}
|
||||
const dims = frameList.every(frame => frame.width >= 1 && frame.height >= 1)
|
||||
if (!assert(dims && frameList.length >= 1, `${member}: ${String(frameList.length)} frames, dims ok=${String(dims)}`)) continue
|
||||
const pixels = frameList.reduce((sum, frame) => sum + frame.mask.reduce((inner, value) => inner + value, 0), 0)
|
||||
assert(pixels > 0, `${member}: decoded to ${String(pixels)} opaque pixels`)
|
||||
decoded += 1
|
||||
frames += frameList.length
|
||||
opaque += pixels
|
||||
}
|
||||
console.log(` decoded ${String(decoded)} members (${String(frames)} frames, ${String(opaque)} opaque pixels) from ${String(chosen.length)} sampled objects`)
|
||||
return { decoded, frames, opaque }
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure 4b: composite one object through its real COF, the engine's own path.
|
||||
*
|
||||
* @param archives - the mounted stack.
|
||||
* @param members - the member index, to confirm the COF is really in the archives.
|
||||
* @param placements - the resolved placements.
|
||||
* @returns the number of sheets composited.
|
||||
*/
|
||||
async function compositeSample(
|
||||
archives: MountedArchives,
|
||||
members: MemberSet,
|
||||
placements: readonly Placement[],
|
||||
): Promise<number> {
|
||||
console.log('== COF composition (the engine\'s entry point) ==')
|
||||
const chosen: Placement[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const placement of placements) {
|
||||
const key = `${placement.row.token}:${placement.mode}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
chosen.push(placement)
|
||||
if (chosen.length >= 12) break
|
||||
}
|
||||
let composited = 0
|
||||
let layers = 0
|
||||
let opaque = 0
|
||||
for (const placement of chosen) {
|
||||
const modeIndex = OBJECT_MODE_TOKENS.indexOf(placement.mode as (typeof OBJECT_MODE_TOKENS)[number])
|
||||
try {
|
||||
const sheet = await loadObjectSheet(archives, placement.row.token, Math.max(modeIndex, 0), placement.row)
|
||||
const frames = sheet.sheet.groups[0]?.frames ?? []
|
||||
const pixels = frames.reduce((sum, frame) => sum + frame.mask.reduce((inner, value) => inner + value, 0), 0)
|
||||
const cofExists = members.lower.has(objectCofMember(placement.row.token, Math.max(modeIndex, 0)))
|
||||
assert(cofExists, `${objectCofMember(placement.row.token, modeIndex)} must exist for ${placement.row.token}`)
|
||||
assert(sheet.members.length >= 1, `${placement.row.token} ${placement.mode}: COF resolved no sprite layers (${sheet.notes.join('; ')})`)
|
||||
if (!assert(pixels > 0, `${placement.row.token} ${placement.mode}: composited to ${String(pixels)} opaque pixels`)) continue
|
||||
composited += 1
|
||||
layers += sheet.members.length
|
||||
opaque += pixels
|
||||
} catch (err) {
|
||||
assert(false, `${placement.row.token} ${placement.mode}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
// Every object token a preset level places must have a readable COF for its
|
||||
// neutral mode: that is the file the engine actually loads.
|
||||
const tokensPlaced = new Set(placements.map(placement => placement.row.token))
|
||||
const withoutCof = [...tokensPlaced].filter(token => !members.lower.has(objectCofMember(token, 0)))
|
||||
assert(withoutCof.length === 0, `placed tokens with no neutral COF: ${withoutCof.join(' ')}`)
|
||||
console.log(` composited ${String(composited)} object animations (${String(layers)} layers, ${String(opaque)} opaque pixels) from ${String(tokensPlaced.size)} distinct tokens`)
|
||||
return composited
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure 4c: the anchor arithmetic, ported and compared.
|
||||
*
|
||||
* @param archives - the mounted stack.
|
||||
* @param levelTables - the level tables.
|
||||
* @param placements - the resolved placements.
|
||||
*/
|
||||
async function reportAnchors(
|
||||
archives: MountedArchives,
|
||||
levelTables: ActTables,
|
||||
placements: readonly Placement[],
|
||||
): Promise<void> {
|
||||
console.log('== draw anchors: D2Dungeon.cpp 10115 / 10117 vs this project ==')
|
||||
// Identities the port must satisfy.
|
||||
let identity = true
|
||||
for (let x = -6; x <= 6; x += 1) {
|
||||
for (let y = -6; y <= 6; y += 1) {
|
||||
const tile = tileDrawPositionCoords(x, y)
|
||||
const tileCentre = gameTileToClientCoords(x, y)
|
||||
if (tile.x !== tileCentre.x - 80 || tile.y !== tileCentre.y + 80) identity = false
|
||||
const sub = subtileDrawPositionCoords(x, y)
|
||||
const subCentre = gameSubtileToClientCoords(x, y)
|
||||
if (sub.x !== subCentre.x - 16 || sub.y !== subCentre.y + 16) identity = false
|
||||
const backTile = clientTileDrawPositionToGameCoords(tile.x, tile.y)
|
||||
if (backTile.x !== x || backTile.y !== y) identity = false
|
||||
const backSub = clientSubtileDrawPositionToGameCoords(sub.x, sub.y)
|
||||
if (backSub.x !== x || backSub.y !== y) identity = false
|
||||
// Negative results must floor, not truncate: the C++ idiom is `v / n - 1`.
|
||||
const floored = clientTileDrawPositionToGameCoords(tile.x - 1, tile.y - 1)
|
||||
if (floored.x !== x - 1 || floored.y !== y - 1) identity = false
|
||||
}
|
||||
}
|
||||
assert(identity, 'tile/subtile draw positions must equal the centre conversion plus (-80,+80)/(-16,+16) and round-trip on 169 coordinates')
|
||||
const engineObject = objectDrawAnchor(0, 0, 0, 0)
|
||||
assert(engineObject.x === -16 && engineObject.y === 16, `objectDrawAnchor(0,0,0,0) must be (-16,+16), saw (${String(engineObject.x)},${String(engineObject.y)})`)
|
||||
|
||||
const sampleX = 12
|
||||
const sampleY = 7
|
||||
const engineFloor = tileDrawPositionCoords(sampleX, sampleY)
|
||||
// This project's values, from src/game/d2map.ts (TILE_ANCHOR_X = -80,
|
||||
// WALL_SURFACE_HEIGHT = 80) and from the packer's object placement.
|
||||
const projectFloorX = (sampleX - sampleY) * 80 - 80
|
||||
const projectFloorY = (sampleX + sampleY) * 40
|
||||
const projectWallExtra = 80
|
||||
const engineObjectAt = subtileDrawPositionCoords(sampleX * 5, sampleY * 5)
|
||||
const projectObjectX = (sampleX * 5 - sampleY * 5) * 16 - 32 / 2
|
||||
const projectObjectY = (sampleX * 5 + sampleY * 5) * 8 + 16
|
||||
|
||||
console.log(` cell (${String(sampleX)},${String(sampleY)})`)
|
||||
console.log(` floor engine DUNGEON_GameToClientTileDrawPositionCoords = (${String(engineFloor.x)}, ${String(engineFloor.y)})`)
|
||||
console.log(` floor this project ((cx-cy)*80-80, (cx+cy)*40) = (${String(projectFloorX)}, ${String(projectFloorY)}) delta (${signed(engineFloor.x - projectFloorX)}, ${signed(engineFloor.y - projectFloorY)})`)
|
||||
console.log(` wall this project adds minBlockY + WALL_SURFACE_HEIGHT = minBlockY ${signed(projectWallExtra)}; engine adds only the block's own offset to the same tile draw position`)
|
||||
console.log(` object engine DUNGEON_GameToClientSubtileDrawPositionCoords = (${String(engineObjectAt.x)}, ${String(engineObjectAt.y)})`)
|
||||
console.log(` object this project (orthoX - w/2, orthoY - h + 16), w=h=32 = (${String(projectObjectX)}, ${String(projectObjectY)}) delta (${signed(engineObjectAt.x - projectObjectX)}, ${signed(engineObjectAt.y - projectObjectY)})`)
|
||||
const anchorVsObject = placements[0]
|
||||
if (anchorVsObject !== undefined) {
|
||||
console.log(` object resolveObjectArt anchor for the first placement (${anchorVsObject.row.token}): (${String(anchorVsObject.anchor.x)}, ${String(anchorVsObject.anchor.y)})`)
|
||||
}
|
||||
|
||||
// The term that decides whether the project's floor and wall formulas agree
|
||||
// with the engine: the DT1 block offset the decoder shifts by.
|
||||
console.log(' DT1 minBlockY actually referenced by preset levels:')
|
||||
const presetRows = levelTables.levels.rows.filter(row => cell(levelTables.levels, row, 'DrlgType') === DRLG_PRESET)
|
||||
const floorHist = new Map<number, number>()
|
||||
const wallHist = new Map<number, number>()
|
||||
let floorTiles = 0
|
||||
let wallTiles = 0
|
||||
for (const levelRow of presetRows.slice(0, 8)) {
|
||||
const levelId = Number(cell(levelTables.levels, levelRow, 'Id'))
|
||||
let info
|
||||
try {
|
||||
info = resolveLevel(levelTables, levelId)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const libraries: Dt1[] = []
|
||||
for (const dt1Name of info.dt1Names) libraries.push(decodeDt1(await archives.read(dt1Name)))
|
||||
const tiles: Dt1Tile[] = libraries.flatMap(library => [...library.tiles])
|
||||
for (const ds1Name of info.ds1Names) {
|
||||
let ds1: Ds1
|
||||
try {
|
||||
ds1 = decodeDs1(await archives.read(ds1Name))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const row of ds1.cells) {
|
||||
for (const cellRow of row) {
|
||||
for (const floor of cellRow.floors) {
|
||||
if (floor.hidden || floor.prop1 === 0) continue
|
||||
const tile = tiles.find(candidate => candidate.style === floor.style && candidate.sequence === floor.sequence && candidate.direction === 0)
|
||||
if (tile === undefined) continue
|
||||
floorTiles += 1
|
||||
floorHist.set(tile.minBlockY, (floorHist.get(tile.minBlockY) ?? 0) + 1)
|
||||
}
|
||||
for (const wall of cellRow.walls) {
|
||||
if (wall.hidden || wall.prop1 === 0) continue
|
||||
const tile = tiles.find(candidate => candidate.style === wall.style && candidate.sequence === wall.sequence && candidate.direction === wall.type)
|
||||
if (tile === undefined) continue
|
||||
wallTiles += 1
|
||||
wallHist.set(tile.minBlockY, (wallHist.get(tile.minBlockY) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
const show = (label: string, hist: Map<number, number>, total: number): void => {
|
||||
const entries = [...hist].sort((a, b) => a[0] - b[0]).slice(0, 8)
|
||||
console.log(` ${label} tiles=${String(total)} ` + entries.map(([value, count]) => `minBlockY ${String(value)}:${String(count)}`).join(' '))
|
||||
}
|
||||
show('floor', floorHist, floorTiles)
|
||||
show('wall ', wallHist, wallTiles)
|
||||
console.log(' a floor tile in this project is drawn at (cx+cy)*40 + (blockY - minBlockY); the engine draws it at (cx+cy)*40 + 80 + blockY,')
|
||||
console.log(' so the two agree only where minBlockY is -80. A wall in this project adds minBlockY back at draw time, which cancels the shift.')
|
||||
}
|
||||
|
||||
/** Entry point. */
|
||||
async function main(): Promise<void> {
|
||||
const archives = await mount()
|
||||
const members = await memberSet(archives)
|
||||
const tables = await loadObjectsTable(archives)
|
||||
const levelTables = await loadActTables(archives)
|
||||
|
||||
console.log(`archives: ${archives.describe().join(', ')}`)
|
||||
console.log(`Objects.txt rows: ${String(tables.rows.length)} (${String(new Set(tables.rows.map(row => row.token)).size)} distinct tokens)`)
|
||||
console.log(`modes: ${OBJECT_MODE_TOKENS.join(' ')} (${String(OBJECT_MODE_COUNT)}); components: ${OBJECT_COMPONENTS.join(' ')}`)
|
||||
|
||||
await reportArchiveLayout(archives)
|
||||
const template = await verifyCompositionTemplate(archives, members, tables)
|
||||
const walk = await walkPresetObjects(archives, members, tables, levelTables)
|
||||
const decoded = await decodeSample(archives, members, walk.placements)
|
||||
const composited = await compositeSample(archives, members, walk.placements)
|
||||
await reportAnchors(archives, levelTables, walk.placements)
|
||||
|
||||
console.log('== summary ==')
|
||||
console.log(` preset levels walked: ${String(walk.levels)}, DS1 members decoded: ${String(walk.ds1Count)}`)
|
||||
console.log(` object placements resolved: ${String(walk.placements.length)}`)
|
||||
console.log(` COF layer references rebuilt: ${String(template.layers)} (${String(template.dcc + template.dc6)} resolved, ${String(template.unresolved.length)} known duds)`)
|
||||
console.log(` members decoded: ${String(decoded.decoded)} (${String(decoded.frames)} frames, ${String(decoded.opaque)} opaque pixels)`)
|
||||
console.log(` object animations composited: ${String(composited)}`)
|
||||
console.log('')
|
||||
if (failures > 0) {
|
||||
console.log(`FAIL: ${String(failures)}/${String(checks)} checks failed`)
|
||||
for (const line of failureLog) console.log(` - ${line}`)
|
||||
if (failures > failureLog.length) console.log(` ... ${String(failures - failureLog.length)} more`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(`PASS: ${String(checks)}/${String(checks)} checks passed`)
|
||||
}
|
||||
|
||||
await main()
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
/**
|
||||
* Prove a pack equals what the archives say.
|
||||
*
|
||||
* A prebaked pack is only trustworthy if it is indistinguishable from decoding
|
||||
* the MPQs directly, so this script rebuilds every packed map through the live
|
||||
* path — mount, tables, DS1, DT1, isometric scene — and compares it against the
|
||||
* pack's JSON field by field:
|
||||
*
|
||||
* 1. map extent (cells, scene size, origin)
|
||||
* 2. every floor and wall draw, in order, by frame rect and position
|
||||
* 3. the collision grid, byte for byte
|
||||
* 4. the spawn point
|
||||
* 5. each frame's indexed pixels, by hash (the pack stores hashes, not pixels)
|
||||
* 6. object placements (art is deliberately not baked yet)
|
||||
*
|
||||
* Anything that differs is a bug in the packer or in the shared code, and the
|
||||
* script exits non-zero rather than letting a subtly wrong pack ship.
|
||||
*
|
||||
* Usage: node scripts/verify-packs.ts [archive-directory] [pack-directory]
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { loadActTables, resolveLevel } from '../src/game/acts.ts'
|
||||
import { decodeDs1 } from '../src/formats/ds1.ts'
|
||||
import { decodeDt1 } from '../src/formats/dt1.ts'
|
||||
import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.ts'
|
||||
import { levelSeed, buildIsoMapScene, findIsoSpawn } from '../src/game/d2map.ts'
|
||||
|
||||
const [archiveDir = 'samples/d2', packDir = 'samples/d2-packs'] = process.argv.slice(2)
|
||||
|
||||
/** Packed scene fields this script reads. */
|
||||
interface PackedScene {
|
||||
readonly act: number
|
||||
readonly levelId: number
|
||||
readonly levelName: string
|
||||
readonly ds1: string
|
||||
readonly cellsX: number
|
||||
readonly cellsY: number
|
||||
readonly originX: number
|
||||
readonly originY: number
|
||||
readonly widthPx: number
|
||||
readonly heightPx: number
|
||||
readonly frames: readonly (readonly number[])[]
|
||||
readonly frameHash: readonly string[]
|
||||
readonly framePlacement: readonly (readonly number[])[]
|
||||
readonly floors: readonly (readonly number[])[]
|
||||
readonly walls: readonly (readonly number[])[]
|
||||
/** Roof draws, painted last; absent in packs baked before roofs were split out. */
|
||||
readonly roofs?: readonly (readonly number[])[]
|
||||
readonly objects: readonly {
|
||||
readonly id: number
|
||||
readonly type: number
|
||||
readonly name: string
|
||||
/** Token the hardcoded object lookup table resolves this DS1 id to. */
|
||||
readonly token: string
|
||||
/** Animation mode token the engine places the object in. */
|
||||
readonly mode: string
|
||||
/** `Objects.txt` row the table points at, or -1 when it points at none. */
|
||||
readonly objectsTxtId: number
|
||||
}[]
|
||||
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
|
||||
readonly spawn: readonly number[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* FNV-1a over a frame's indexed pixels — the same function the packer used.
|
||||
*
|
||||
* @param indices - palette indices.
|
||||
* @returns an 8-character hex digest.
|
||||
*/
|
||||
function frameHash(indices: Uint8Array): string {
|
||||
let hash = 0x811c9dc5
|
||||
for (const byte of indices) {
|
||||
hash ^= byte
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
const archives = new MountedArchives()
|
||||
for (const name of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(join(archiveDir, name))))
|
||||
}
|
||||
const tables = await loadActTables(archives)
|
||||
const index = JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')) as {
|
||||
levels: readonly { act: number; levelId: number; path: string; ds1: string; label: string }[]
|
||||
}
|
||||
|
||||
let checks = 0
|
||||
let failures = 0
|
||||
let comparedPixels = 0
|
||||
|
||||
/**
|
||||
* Assert one expectation.
|
||||
*
|
||||
* @param ok - whether it held.
|
||||
* @param message - what was checked.
|
||||
*/
|
||||
function check(ok: boolean, message: string): void {
|
||||
checks += 1
|
||||
if (!ok) { failures += 1; console.log(` FAIL ${message}`) }
|
||||
}
|
||||
|
||||
/** DS1 object ids the community lookup table does not cover, summed over every map. */
|
||||
let unknownObjectIds = 0
|
||||
|
||||
/** The hardcoded object lookup needs `Objects.txt` for metadata; load it once. */
|
||||
const objectsTable = await loadObjectsTable(archives)
|
||||
|
||||
for (const entry of index.levels) {
|
||||
const packed = JSON.parse(await readFile(join(packDir, entry.path, 'scene.json'), 'utf8')) as PackedScene
|
||||
const info = resolveLevel(tables, packed.levelId, packed.act)
|
||||
const libraries = []
|
||||
for (const name of info.dt1Names) libraries.push(decodeDt1(await archives.read(name)))
|
||||
const level = decodeDs1(await archives.read(packed.ds1))
|
||||
const live = buildIsoMapScene(level, libraries, levelSeed(packed.ds1))
|
||||
|
||||
check(packed.levelName === info.levelName, `${entry.path}: level name`)
|
||||
check(packed.cellsX === live.cellsX && packed.cellsY === live.cellsY, `${entry.path}: cell extent`)
|
||||
check(packed.originX === live.originX && packed.originY === live.originY, `${entry.path}: scene origin`)
|
||||
check(packed.widthPx === live.widthPx && packed.heightPx === live.heightPx, `${entry.path}: scene size`)
|
||||
|
||||
// Draws: same count, same order, same frame rect and position.
|
||||
check(packed.floors.length === live.floors.length, `${entry.path}: floor count ${String(packed.floors.length)} vs ${String(live.floors.length)}`)
|
||||
check(packed.walls.length === live.walls.length, `${entry.path}: wall count ${String(packed.walls.length)} vs ${String(live.walls.length)}`)
|
||||
const rectOf = (frameIndex: number): string => (packed.framePlacement[frameIndex] ?? []).slice(1).join(',')
|
||||
let floorMismatch = 0
|
||||
for (let at = 0; at < Math.min(packed.floors.length, live.floors.length); at += 1) {
|
||||
const packedRow = packed.floors[at]!
|
||||
const liveDraw = live.floors[at]!
|
||||
if (packedRow[1] !== liveDraw.x || packedRow[2] !== liveDraw.y || packedRow[3] !== liveDraw.cellX || packedRow[4] !== liveDraw.cellY) floorMismatch += 1
|
||||
}
|
||||
check(floorMismatch === 0, `${entry.path}: floor draw geometry (${String(floorMismatch)} mismatches)`)
|
||||
let wallMismatch = 0
|
||||
for (let at = 0; at < Math.min(packed.walls.length, live.walls.length); at += 1) {
|
||||
const packedRow = packed.walls[at]!
|
||||
const liveDraw = live.walls[at]!
|
||||
if (packedRow[1] !== liveDraw.x || packedRow[2] !== liveDraw.y) wallMismatch += 1
|
||||
}
|
||||
check(wallMismatch === 0, `${entry.path}: wall draw geometry and order (${String(wallMismatch)} mismatches)`)
|
||||
|
||||
// Roofs are a separate pass in the engine, so they are compared as their own
|
||||
// list: same count, same order, same geometry.
|
||||
const packedRoofs = packed.roofs ?? []
|
||||
check(packedRoofs.length === live.roofs.length,
|
||||
`${entry.path}: roof count ${String(packedRoofs.length)} vs ${String(live.roofs.length)}`)
|
||||
let roofMismatch = 0
|
||||
for (let at = 0; at < Math.min(packedRoofs.length, live.roofs.length); at += 1) {
|
||||
const packedRow = packedRoofs[at]!
|
||||
const liveDraw = live.roofs[at]!
|
||||
if (packedRow[1] !== liveDraw.x || packedRow[2] !== liveDraw.y) roofMismatch += 1
|
||||
}
|
||||
check(roofMismatch === 0, `${entry.path}: roof draw geometry and order (${String(roofMismatch)} mismatches)`)
|
||||
|
||||
// Collision grid: expand the runs and compare bytes.
|
||||
const expanded = new Uint8Array(packed.collision.width * packed.collision.height)
|
||||
let cursor = 0
|
||||
for (const run of packed.collision.runs) {
|
||||
const value = run[0] ?? 0
|
||||
const length = run[1] ?? 0
|
||||
if (value !== 0) expanded.fill(value, cursor, Math.min(cursor + length, expanded.length))
|
||||
cursor += length
|
||||
}
|
||||
check(packed.collision.width === live.gridWidth, `${entry.path}: collision width`)
|
||||
check(packed.collision.height === live.gridHeight, `${entry.path}: collision height`)
|
||||
let collisionDiff = 0
|
||||
for (let at = 0; at < Math.min(expanded.length, live.blocked.length); at += 1) {
|
||||
if (expanded[at] !== live.blocked[at]) collisionDiff += 1
|
||||
}
|
||||
check(collisionDiff === 0, `${entry.path}: collision grid (${String(collisionDiff)} differing sub-tiles)`)
|
||||
|
||||
// Spawn.
|
||||
const liveSpawn = findIsoSpawn(live)
|
||||
const packedSpawn = packed.spawn
|
||||
check(
|
||||
(packedSpawn === null && liveSpawn === null)
|
||||
|| (packedSpawn !== null && liveSpawn !== null && packedSpawn[0] === Math.round(liveSpawn.x) && packedSpawn[1] === Math.round(liveSpawn.y)),
|
||||
`${entry.path}: spawn point`,
|
||||
)
|
||||
|
||||
// Frames: same tiles, same pixels (by hash), and every frame reachable on a page.
|
||||
check(packed.frames.length === live.frames.length, `${entry.path}: frame count`)
|
||||
let hashMismatch = 0
|
||||
let offPage = 0
|
||||
for (let at = 0; at < Math.min(packed.frames.length, live.frames.length); at += 1) {
|
||||
const liveFrame = live.frames[at]!
|
||||
if (frameHash(liveFrame.indices) !== packed.frameHash[at]) hashMismatch += 1
|
||||
const place = packed.framePlacement[at]
|
||||
if (place === undefined || (place[3] ?? 0) !== liveFrame.width || (place[4] ?? 0) !== liveFrame.height) offPage += 1
|
||||
comparedPixels += liveFrame.indices.byteLength
|
||||
}
|
||||
check(hashMismatch === 0, `${entry.path}: frame pixel hashes (${String(hashMismatch)} differ)`)
|
||||
check(offPage === 0, `${entry.path}: frame sizes match their page placement (${String(offPage)} differ)`)
|
||||
void rectOf
|
||||
|
||||
// Objects: same placements, same count (art is not baked yet, by design).
|
||||
// The packer skips DS1 monster spawn points (`type` 1) and any id the hardcoded
|
||||
// object table does not know, so the expected count is "entries that resolve to a
|
||||
// real object", not the raw DS1 count. Token/mode are compared too: they come from
|
||||
// the lookup table, which is the part a wrong mapping would silently corrupt.
|
||||
// A few DS1 ids are simply absent from the community table; the packer records
|
||||
// them as unresolved instead of guessing, so they are excluded here the same way.
|
||||
let unknownIds = 0
|
||||
const expected: { object: (typeof level.objects)[number]; token: string; mode: string }[] = []
|
||||
for (const object of level.objects) {
|
||||
try {
|
||||
const resolved = resolveDs1Object(objectsTable, packed.act, object.type, object.id)
|
||||
if (resolved.kind === 'object') expected.push({ object, token: resolved.token, mode: resolved.mode })
|
||||
} catch {
|
||||
unknownIds += 1
|
||||
}
|
||||
}
|
||||
check(packed.objects.length === expected.length,
|
||||
`${entry.path}: object count ${String(packed.objects.length)} vs ${String(expected.length)}`)
|
||||
let tokenMismatch = 0
|
||||
for (let index = 0; index < Math.min(packed.objects.length, expected.length); index += 1) {
|
||||
const baked = packed.objects[index]!
|
||||
const want = expected[index]!
|
||||
if (baked.token !== want.token || baked.mode !== (want.mode === '' ? 'NU' : want.mode)) {
|
||||
tokenMismatch += 1
|
||||
if (tokenMismatch <= 3) {
|
||||
console.log(` FAIL ${entry.path}: object ${String(index)} ${baked.token}/${baked.mode} != ${want.token}/${want.mode}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
check(tokenMismatch === 0, `${entry.path}: object tokens/modes match the lookup (${String(tokenMismatch)} mismatches)`)
|
||||
unknownObjectIds += unknownIds
|
||||
|
||||
console.log(
|
||||
`${entry.path.padEnd(26)} ${String(live.floors.length).padStart(5)} 地面 ${String(live.walls.length).padStart(4)} 墙 ${String(live.roofs.length).padStart(4)} 顶 `
|
||||
+ `${String(live.frames.length).padStart(3)} 帧 ${String(packed.collision.width)}x${String(packed.collision.height)} 碰撞 `
|
||||
+ `${String(expected.length).padStart(4)} 对象 ${collisionDiff === 0 && hashMismatch === 0 ? '一致' : '不一致'}`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`\n${String(checks - failures)}/${String(checks)} 项断言通过(逐像素比对了 ${(comparedPixels / 1048576).toFixed(1)} MB 的索引数据)`)
|
||||
if (failures > 0) process.exit(1)
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Decode every Diablo I sprite member in an archive and report what the
|
||||
* decoders had to infer.
|
||||
*
|
||||
* The width story is the interesting part and differs per format:
|
||||
*
|
||||
* - `.cel` runs are row-bounded, so a wrong width makes runs overrun a row and
|
||||
* the file is rejected. Auto-detection is therefore a real check, and a
|
||||
* decoded file is strong evidence the decoder matches the shipped data.
|
||||
* - `.cl2` runs may cross rows, so *any* width consumes the stream. There the
|
||||
* width must come from the game's tables — `SetPlrAnims` for players,
|
||||
* `monsterdata[].width` for monsters — and success means the decoder agrees
|
||||
* with those parameters. `cl2WidthCandidates` additionally reports widths
|
||||
* that leave the stream row-aligned, which is a necessary (not sufficient)
|
||||
* property of the true width.
|
||||
*
|
||||
* Usage: node scripts/verify-sprites.ts <archive> [--player] [--samples]
|
||||
*/
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { cl2WidthCandidates, decodeSpriteFile, detectSpriteWidth } from '../src/formats/cel.ts'
|
||||
import { PLAYER_SPRITE_WIDTH } from '../src/formats/sprite.ts'
|
||||
|
||||
const [path, ...flags] = process.argv.slice(2)
|
||||
if (path === undefined) {
|
||||
console.error('usage: node scripts/verify-sprites.ts <archive> [--player] [--samples]')
|
||||
process.exit(2)
|
||||
}
|
||||
const showPlayer = flags.includes('--player')
|
||||
const showSamples = flags.includes('--samples')
|
||||
|
||||
/** Widths the game's own tables give for player graphics (`SetPlrAnims`). */
|
||||
const PLAYER_WIDTHS = [
|
||||
PLAYER_SPRITE_WIDTH.stand, PLAYER_SPRITE_WIDTH.walk,
|
||||
PLAYER_SPRITE_WIDTH.attack, PLAYER_SPRITE_WIDTH.attackNarrow,
|
||||
PLAYER_SPRITE_WIDTH.monk, PLAYER_SPRITE_WIDTH.monkAttack,
|
||||
]
|
||||
/** Most monospecies monster art is 128 wide (`monsterdata[].width`). */
|
||||
const MONSTER_WIDTHS = [128, 160, 96, 64]
|
||||
|
||||
const archive = await MpqArchive.open(await fileSource(path))
|
||||
const names = await archive.listFiles()
|
||||
|
||||
const celWidths = new Map<number, number>()
|
||||
const cl2Widths = new Map<number, number>()
|
||||
const failures: string[] = []
|
||||
const ambiguous: string[] = []
|
||||
const playerRows: string[] = []
|
||||
let celTotal = 0
|
||||
let celOk = 0
|
||||
let cl2Total = 0
|
||||
let cl2Ok = 0
|
||||
const started = Date.now()
|
||||
|
||||
/** The directory that owns an archive name (`monsters`, `plrgfx`, ...). */
|
||||
function topDir(name: string): string {
|
||||
const cut = name.indexOf('\\')
|
||||
return cut === -1 ? '(root)' : name.slice(0, cut)
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
const lower = name.toLowerCase()
|
||||
const mode = lower.endsWith('.cl2') ? 'cl2' : lower.endsWith('.cel') ? 'cel' : null
|
||||
if (mode === null) continue
|
||||
const file = archive.find(name)
|
||||
if (file === undefined) {
|
||||
failures.push(`${name}: hash lookup failed`)
|
||||
continue
|
||||
}
|
||||
const data = await archive.read(file)
|
||||
|
||||
if (mode === 'cel') {
|
||||
celTotal += 1
|
||||
const width = detectSpriteWidth(data)
|
||||
if (width === null) {
|
||||
failures.push(`${name}: no width consumes the file exactly`)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const sheet = decodeSpriteFile(data, 'cel', { width })
|
||||
const frames = sheet.groups.reduce((total, group) => total + group.frames.length, 0)
|
||||
if (frames === 0) throw new Error('decoded zero frames')
|
||||
celOk += 1
|
||||
celWidths.set(width, (celWidths.get(width) ?? 0) + 1)
|
||||
} catch (error) {
|
||||
failures.push(`${name} (width ${String(width)}): ${(error as Error).message}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
cl2Total += 1
|
||||
const dir = topDir(name)
|
||||
const candidates = dir === 'plrgfx' ? PLAYER_WIDTHS : dir === 'monsters' ? MONSTER_WIDTHS : PLAYER_WIDTHS
|
||||
// The game's tables for this kind of art, tried in order.
|
||||
let matched: number | null = null
|
||||
for (const width of candidates) {
|
||||
try {
|
||||
const sheet = decodeSpriteFile(data, 'cl2', { width })
|
||||
const frames = sheet.groups.reduce((total, group) => total + group.frames.length, 0)
|
||||
if (frames > 0) { matched = width; break }
|
||||
} catch {
|
||||
// try the next width
|
||||
}
|
||||
}
|
||||
if (matched !== null) {
|
||||
cl2Ok += 1
|
||||
cl2Widths.set(matched, (cl2Widths.get(matched) ?? 0) + 1)
|
||||
const aligned = cl2WidthCandidates(data, candidates)
|
||||
if (!aligned.includes(matched)) {
|
||||
ambiguous.push(`${name}: decodes at ${String(matched)} but the stream is not row-aligned there`)
|
||||
}
|
||||
if (showPlayer && dir === 'plrgfx') {
|
||||
const sheet = decodeSpriteFile(data, 'cl2', { width: matched })
|
||||
const frames = sheet.groups.reduce((total, group) => total + group.frames.length, 0)
|
||||
const tallest = Math.max(...sheet.groups.flatMap(group => group.frames.map(frame => frame.height)))
|
||||
playerRows.push(
|
||||
`${name.padEnd(34)} groups=${String(sheet.groups.length)} frames=${String(frames)}`
|
||||
+ ` width=${String(matched)} tallest=${String(tallest)} aligned=[${aligned.join(',')}]`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const aligned = cl2WidthCandidates(data, [...PLAYER_WIDTHS, ...MONSTER_WIDTHS, 32, 48, 64, 80, 160, 192, 256])
|
||||
ambiguous.push(`${name} (${dir}): no table width decoded it; row-aligned candidates: ${aligned.length === 0 ? 'none' : aligned.join(',')}`)
|
||||
}
|
||||
}
|
||||
|
||||
const seconds = ((Date.now() - started) / 1000).toFixed(1)
|
||||
const formatHistogram = (histogram: Map<number, number>): string =>
|
||||
[...histogram].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([w, n]) => `${String(w)}px:${String(n)}`).join(' ')
|
||||
|
||||
console.log(`archive ${path}`)
|
||||
console.log(`cel ${String(celOk)}/${String(celTotal)} decoded widths ${formatHistogram(celWidths)}`)
|
||||
console.log(`cl2 ${String(cl2Ok)}/${String(cl2Total)} decoded widths ${formatHistogram(cl2Widths)}`)
|
||||
console.log(`failures ${String(failures.length)}`)
|
||||
for (const line of failures.slice(0, 10)) console.log(` - ${line}`)
|
||||
console.log(`notes ${String(ambiguous.length)}`)
|
||||
const noteLimit = showSamples ? 12 : 4
|
||||
for (const line of ambiguous.slice(0, noteLimit)) console.log(` · ${line}`)
|
||||
if (showSamples && ambiguous.length > noteLimit) console.log(` · … ${String(ambiguous.length - noteLimit)} more`)
|
||||
console.log(`elapsed ${seconds}s`)
|
||||
if (showPlayer) {
|
||||
console.log('--- player graphics ---')
|
||||
for (const row of playerRows.slice(0, 30)) console.log(` ${row}`)
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Construction check for the TBL decoder.
|
||||
*
|
||||
* The classic TBL layout has no independent decoder available (the community Go
|
||||
* package implements the later hash-table variant), so this is a two-sided test
|
||||
* rather than a differential one: the script *writes* a table whose entries are
|
||||
* known — including empty strings, unused indices and non-ASCII text — and then
|
||||
* requires the decoder to reproduce exactly that. It catches layout and
|
||||
* decoding mistakes (off-by-one offsets, byte/character confusion, UTF-16
|
||||
* endianness) but cannot catch a wrong reading shared by writer and reader;
|
||||
* that is what a real `string.tbl` is for.
|
||||
*
|
||||
* Usage: node scripts/verify-tbl.ts
|
||||
*/
|
||||
import { decodeTbl, tblLookup } from '../src/formats/tbl.ts'
|
||||
import { encodeTbl } from './lib/tbl-writer.ts'
|
||||
import type { TblEntry } from './lib/tbl-writer.ts'
|
||||
|
||||
/** Entries the decoder must reproduce, in order. */
|
||||
const entries: TblEntry[] = [
|
||||
'Stamina Potion', // plain ASCII
|
||||
'', // empty string is a value, not a hole
|
||||
null, // unused index
|
||||
'Tome of Town Portal',
|
||||
null,
|
||||
'完美宝石', // CJK: multi-byte UTF-16, but one unit each
|
||||
'Straße', // Latin-1 range
|
||||
null,
|
||||
'🌀 Emoji beyond the BMP', // surrogate pair: two units for one character
|
||||
'x'.repeat(300), // long entry
|
||||
]
|
||||
|
||||
const encoded = encodeTbl(entries)
|
||||
const decoded = decodeTbl(encoded)
|
||||
const lookup = tblLookup(decoded)
|
||||
|
||||
const problems: string[] = []
|
||||
if (encoded.byteLength !== decoded.length * 0 + encoded.byteLength) {
|
||||
/* v8 ignore next -- placeholder to keep the shape obvious. */
|
||||
problems.push('unreachable')
|
||||
}
|
||||
if (decoded.length !== entries.length) problems.push(`entry count ${String(decoded.length)} != ${String(entries.length)}`)
|
||||
entries.forEach((want, index) => {
|
||||
const got = decoded[index]
|
||||
if (want === null) {
|
||||
if (got !== undefined) problems.push(`index ${String(index)}: expected an unused index, got ${JSON.stringify(got)}`)
|
||||
return
|
||||
}
|
||||
if (got !== want) problems.push(`index ${String(index)}: ${JSON.stringify(got)} != ${JSON.stringify(want)}`)
|
||||
})
|
||||
|
||||
console.log(`table ${String(entries.length)} indices (${String(lookup.size)} used)`)
|
||||
console.log(`encoded ${String(encoded.byteLength)} bytes`)
|
||||
console.log(`unicode CJK=${JSON.stringify(lookup.get(5))} latin1=${JSON.stringify(lookup.get(6))} astral=${JSON.stringify(lookup.get(8))}`)
|
||||
console.log(`long ${String(lookup.get(9)?.length ?? 0)} characters round-tripped`)
|
||||
console.log(`problems ${String(problems.length)}`)
|
||||
for (const problem of problems.slice(0, 8)) console.log(` - ${problem}`)
|
||||
console.log(problems.length === 0 ? 'RESULT every index round-trips exactly' : 'RESULT FAILED')
|
||||
process.exit(problems.length === 0 ? 0 : 1)
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
// verify-tile-alignment.ts — 用实测把"地面比引擎低/高 80 px"这个疑点结掉
|
||||
//
|
||||
// 背景:上一轮的 `verify-objects` 打印过一张对照表,说引擎的
|
||||
// `DUNGEON_GameToClientTileDrawPositionCoords`(D2MOO 移植过来的真引擎函数)算出 cell(12,7) 的地面
|
||||
// 在 (320, 840),而本项目画在 (320, 760),差 (+0, +80),看起来像"地面整体偏了 80 px"。
|
||||
//
|
||||
// 这份脚本证明那**不是错位**,而是两个坐标系之间的**常量平移**,并且用像素实测证明同一格里
|
||||
// 地面的下沿与墙脚是对齐的:
|
||||
//
|
||||
// 1. 常量性:D2MOO 的 tile 位置与本项目的地面绘制位置的差在所有格子上都是 (+80, +80),
|
||||
// 与 (x,y) 无关 → 整张图一起平移,相机又跟着角色走,所以对相对关系没有任何影响。
|
||||
// 2. 公式一致性:我们画墙用的 `minBlockY + 80` 与解析出来的 `bitmapHeight`,必须等于引擎从原始
|
||||
// block 字节独立推出的 `tileMinY + 80` 与 `realHeight = max(|Height|, maxY - minY)`。
|
||||
// 3. 接缝实测:同一格里取墙位图与地面位图**真正的不透明像素**,比较墙脚(墙最下面一行不透明像素)
|
||||
// 与地面下沿。差值应当是 0。
|
||||
//
|
||||
// node scripts/verify-tile-alignment.ts [--dir=samples/d2] [--limit=35]
|
||||
//
|
||||
// 退出码非 0 表示有断言没过。
|
||||
|
||||
export {}
|
||||
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { cell, loadActTables, resolveLevel, resolveLevelLibraries } from '../src/game/acts.ts'
|
||||
import { decodeDs1 } from '../src/formats/ds1.ts'
|
||||
import { decodeDt1 } from '../src/formats/dt1.ts'
|
||||
import type { Dt1, Dt1Tile } from '../src/formats/dt1.ts'
|
||||
import { levelSeed, buildIsoMapScene, ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH } from '../src/game/d2map.ts'
|
||||
|
||||
/** 归档目录。 */
|
||||
const DIR = process.argv.find(argument => argument.startsWith('--dir='))?.slice('--dir='.length) ?? 'samples/d2'
|
||||
/** 最多走多少个预置关卡。 */
|
||||
const LIMIT = Number(process.argv.find(argument => argument.startsWith('--limit='))?.slice('--limit='.length) ?? '35')
|
||||
|
||||
/** 断言结果。 */
|
||||
const checks: { name: string; ok: boolean; detail: string }[] = []
|
||||
|
||||
/**
|
||||
* 记录一条断言。
|
||||
*
|
||||
* @param name - what was checked.
|
||||
* @param ok - whether it held.
|
||||
* @param detail - evidence.
|
||||
*/
|
||||
function check(name: string, ok: boolean, detail: string): void {
|
||||
checks.push({ name, ok, detail })
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${name} — ${detail}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 位图里最后一行不透明像素的行号(没有不透明像素时返回 -1)。
|
||||
*
|
||||
* @param tile - the decoded DT1 tile.
|
||||
* @returns the row index, or -1.
|
||||
*/
|
||||
function lastOpaqueRow(tile: Dt1Tile): number {
|
||||
// 每个 block 的像素缓冲已经是"整块位图尺寸"的画布,解码时按 `-minBlockY` 平移过,
|
||||
// 所以缓冲里的行号就是位图内的行号,不需要再加 block.y(block.y 保留的是原始值)。
|
||||
const stride = tile.width
|
||||
let last = -1
|
||||
for (const block of tile.blocks) {
|
||||
if (stride <= 0) break
|
||||
const rows = Math.floor(block.pixels.length / stride)
|
||||
for (let row = rows - 1; row >= 0; row -= 1) {
|
||||
const start = row * stride
|
||||
let opaque = false
|
||||
for (let column = 0; column < stride; column += 1) {
|
||||
if ((block.pixels[start + column] ?? 0) !== 0) { opaque = true; break }
|
||||
}
|
||||
if (opaque) { last = Math.max(last, row); break }
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
/**
|
||||
* 从原始字节独立算出引擎的 `tileMinY` / `realHeight`(OpenDiablo2 `generateWallCache` 的公式)。
|
||||
*
|
||||
* @param tile - the decoded DT1 tile; `block.y` is already shifted by `minBlockY`.
|
||||
* @returns the engine's numbers.
|
||||
*/
|
||||
function engineMetrics(tile: Dt1Tile): { tileMinY: number; tileMaxY: number; realHeight: number } {
|
||||
// `block.y` 是原始值(解码时像素另有平移),所以这里直接用,不要再叠加 minBlockY。
|
||||
let tileMinY = 0
|
||||
let tileMaxY = 0
|
||||
for (const block of tile.blocks) {
|
||||
tileMinY = Math.min(tileMinY, block.y)
|
||||
tileMaxY = Math.max(tileMaxY, block.y + 32)
|
||||
}
|
||||
return { tileMinY, tileMaxY, realHeight: Math.max(Math.abs(tile.height), tileMaxY - tileMinY) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程。
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`${DIR}/${name}`)))
|
||||
}
|
||||
const tables = await loadActTables(archives)
|
||||
|
||||
const presetRows = tables.levels.rows.filter(row => cell(tables.levels, row, 'DrlgType') === '2')
|
||||
const libraries = new Map<string, Dt1>()
|
||||
let cells = 0
|
||||
let pairs = 0
|
||||
const deltaHistogram = new Map<number, number>()
|
||||
const samples: string[] = []
|
||||
const seenDeltas = new Set<number>()
|
||||
const baseDeltas = new Map<number, number>()
|
||||
const offsetsD2moo = new Set<string>()
|
||||
const formulaMismatches: string[] = []
|
||||
let floorMinYNonZero = 0
|
||||
let roofDraws = 0
|
||||
let roofMatches = 0
|
||||
const wallTypes = new Map<number, number>()
|
||||
let wallTiles = 0
|
||||
let levelsWalked = 0
|
||||
// 键的正确性回归:DS1 wall type 必须与 DT1 头 +20 的 `Type` 对齐,而不是 +0 的 `Direction`。
|
||||
// 键错时树(14)/屋顶(15)/影子(13)全落 loose、画成地面。这里按 type 键全量复核,loose/missing 应为 0。
|
||||
let refsTotal = 0
|
||||
let refsExact = 0
|
||||
let refsLoose = 0
|
||||
let refsMissing = 0
|
||||
const looseRefs = new Set<string>()
|
||||
|
||||
for (const row of presetRows.slice(0, LIMIT)) {
|
||||
const levelId = Number(cell(tables.levels, row, 'Id'))
|
||||
let info
|
||||
try {
|
||||
info = resolveLevel(tables, levelId)
|
||||
} catch { continue }
|
||||
const { dt1Names } = resolveLevelLibraries(tables, levelId)
|
||||
const libs: Dt1[] = []
|
||||
for (const name of dt1Names) {
|
||||
const cached = libraries.get(name)
|
||||
if (cached !== undefined) { libs.push(cached); continue }
|
||||
try {
|
||||
const decoded = decodeDt1(await archives.read(name))
|
||||
libraries.set(name, decoded)
|
||||
libs.push(decoded)
|
||||
} catch { /* 库缺失时后面的 resolveLevelLibraries 已经报过 */ }
|
||||
}
|
||||
if (libs.length === 0) continue
|
||||
|
||||
let ds1
|
||||
try {
|
||||
ds1 = decodeDs1(await archives.read(info.ds1Names[0]!))
|
||||
} catch { continue }
|
||||
levelsWalked += 1
|
||||
const scene = buildIsoMapScene(ds1, libs, levelSeed(info.ds1Names[0] ?? String(levelId)))
|
||||
|
||||
// 键正确性复核(独立于 buildIsoMapScene,用原始 DS1 + 库重算一遍)。
|
||||
const exactKeys = new Set<string>()
|
||||
const looseKeys = new Set<string>()
|
||||
for (const lib of libs) for (const tile of lib.tiles) {
|
||||
exactKeys.add(`${String(tile.style)}:${String(tile.sequence)}:${String(tile.type)}`)
|
||||
looseKeys.add(`${String(tile.style)}:${String(tile.sequence)}`)
|
||||
}
|
||||
for (const cellRef of ds1.cells.flat()) for (const wall of cellRef.walls) {
|
||||
if (wall.hidden || wall.prop1 === 0) continue
|
||||
refsTotal += 1
|
||||
if (exactKeys.has(`${String(wall.style)}:${String(wall.sequence)}:${String(wall.type)}`)) { refsExact += 1; continue }
|
||||
if (looseKeys.has(`${String(wall.style)}:${String(wall.sequence)}`)) { refsLoose += 1; looseRefs.add(`${String(wall.style)}/${String(wall.sequence)}/${String(wall.type)}`); continue }
|
||||
refsMissing += 1
|
||||
}
|
||||
|
||||
// Roofs: the engine offsets them by `-roofHeight` instead of `minBlockY + 80`.
|
||||
for (const roof of scene.roofs) {
|
||||
const tile = libs[roof.library]?.tiles[roof.tile]
|
||||
if (tile === undefined) continue
|
||||
roofDraws += 1
|
||||
// The scene shifts every draw to keep coordinates positive, so the formula is
|
||||
// checked against the *unshifted* placement (scene.originY is that shift).
|
||||
if (roof.y - scene.originY === (roof.cellX + roof.cellY) * ORTHO_CELL_HEIGHT - tile.roofHeight) roofMatches += 1
|
||||
}
|
||||
|
||||
// 按格子收集地面与墙
|
||||
type Draw = (typeof scene.floors)[number]
|
||||
const byCell = new Map<string, { floors: Draw[]; walls: Draw[] }>()
|
||||
for (const cell of ds1.cells.flat()) {
|
||||
for (const wall of cell.walls) {
|
||||
if (wall.prop1 === 0 || wall.hidden) continue
|
||||
wallTypes.set(wall.type, (wallTypes.get(wall.type) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
for (const draw of scene.floors) {
|
||||
const key = `${String(draw.cellX)},${String(draw.cellY)}`
|
||||
const entry = byCell.get(key) ?? { floors: [], walls: [] }
|
||||
entry.floors.push(draw)
|
||||
byCell.set(key, entry)
|
||||
}
|
||||
for (const draw of scene.walls) {
|
||||
const key = `${String(draw.cellX)},${String(draw.cellY)}`
|
||||
const entry = byCell.get(key) ?? { floors: [], walls: [] }
|
||||
entry.walls.push(draw)
|
||||
byCell.set(key, entry)
|
||||
}
|
||||
|
||||
for (const [key, entry] of byCell) {
|
||||
if (entry.floors.length === 0 || entry.walls.length === 0) continue
|
||||
cells += 1
|
||||
const [cxText, cyText] = key.split(',')
|
||||
const cx = Number(cxText)
|
||||
const cy = Number(cyText)
|
||||
|
||||
// (1) 常量性:D2MOO 的 tile 位置 vs 本项目的地面位置
|
||||
const oursX = (cx - cy) * ORTHO_CELL_WIDTH - 80
|
||||
const oursY = (cx + cy) * ORTHO_CELL_HEIGHT
|
||||
const d2mooX = (cx - cy) * ORTHO_CELL_WIDTH
|
||||
const d2mooY = (cx + cy) * ORTHO_CELL_HEIGHT + 80
|
||||
offsetsD2moo.add(`${String(d2mooX - oursX)},${String(d2mooY - oursY)}`)
|
||||
|
||||
const floor = entry.floors[0]!
|
||||
const wall = entry.walls[0]!
|
||||
const floorTile = libs[floor.library]?.tiles[floor.tile]
|
||||
const wallTile = libs[wall.library]?.tiles[wall.tile]
|
||||
if (floorTile === undefined || wallTile === undefined) continue
|
||||
if (floorTile.minBlockY !== 0) floorMinYNonZero += 1
|
||||
wallTiles += 1
|
||||
|
||||
// (2) 公式一致性:我们的 minBlockY / bitmapHeight 必须等于引擎独立算出的值
|
||||
const metrics = engineMetrics(wallTile)
|
||||
if (wallTile.minBlockY !== metrics.tileMinY) {
|
||||
if (formulaMismatches.length < 6) {
|
||||
formulaMismatches.push(`${String(levelId)} wall style ${String(wallTile.style)}: minBlockY ${String(wallTile.minBlockY)} != engine ${String(metrics.tileMinY)}`)
|
||||
}
|
||||
} else if (wallTile.bitmapHeight !== metrics.realHeight) {
|
||||
if (formulaMismatches.length < 6) {
|
||||
formulaMismatches.push(`${String(levelId)} wall style ${String(wallTile.style)}: bitmapHeight ${String(wallTile.bitmapHeight)} != engine realHeight ${String(metrics.realHeight)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// (3) 接缝实测:墙脚(最下面一行不透明像素)与地面下沿之差
|
||||
const floorBottom = floor.y + lastOpaqueRow(floorTile)
|
||||
const wallBottom = wall.y + lastOpaqueRow(wallTile)
|
||||
// 真正的"接缝"判据是**基线**而不是"最下面一行不透明像素":引擎给墙加的
|
||||
// `YAdjust = minBlockY + 80` 正好抵消解码时按 `-minBlockY` 做的平移,使墙的原始 y=0
|
||||
// 落在格子基线(地面位图下沿所在的 80 px 处);不同 tile 类型的美术在基线上下伸出的
|
||||
// 多少各不相同,所以像素行差只能当参考,不能当判据。
|
||||
const wallBase = wall.y - wallTile.minBlockY
|
||||
const floorBase = floor.y + 80
|
||||
baseDeltas.set(wallBase - floorBase, (baseDeltas.get(wallBase - floorBase) ?? 0) + 1)
|
||||
|
||||
const delta = wallBottom - floorBottom
|
||||
deltaHistogram.set(delta, (deltaHistogram.get(delta) ?? 0) + 1)
|
||||
if (samples.length < 8 && !seenDeltas.has(delta)) {
|
||||
seenDeltas.add(delta)
|
||||
samples.push(`Δ${String(delta)} cell(${String(cx)},${String(cy)}) `
|
||||
+ `floor[y=${String(floor.y)} last=${String(floorBottom - floor.y)} bh=${String(floorTile.bitmapHeight)} minY=${String(floorTile.minBlockY)}] `
|
||||
+ `wall[y=${String(wall.y)} last=${String(wallBottom - wall.y)} bh=${String(wallTile.bitmapHeight)} minY=${String(wallTile.minBlockY)} h=${String(wallTile.height)} style=${String(wallTile.style)}/${String(wallTile.sequence)}/${String(wallTile.type)}]`)
|
||||
}
|
||||
pairs += 1
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n走了 ${String(levelsWalked)} 个预置关卡:含"地面+墙"的格子 ${String(cells)},参与接缝实测的配对 ${String(pairs)}`)
|
||||
console.log(` 屋顶层(DS1 wall type 15):${String(roofDraws)} 个,其中偏移与引擎公式 -roofHeight 相符的 ${String(roofMatches)} 个`)
|
||||
console.log(` DS1 墙 type 分布:${[...wallTypes].sort((left, right) => left[0] - right[0]).map(([type, count]) => `${String(type)}×${String(count)}`).join(' ')}`)
|
||||
console.log(` D2MOO 位置 - 本项目地面位置 的不同取值:${[...offsetsD2moo].join(' | ')}`)
|
||||
const deltas = [...deltaHistogram].sort((left, right) => right[1] - left[1])
|
||||
console.log(` 接缝差(墙脚 - 地面下沿)分布:${deltas.slice(0, 8).map(([value, count]) => `${String(value)}px ×${String(count)}`).join(' ')}`)
|
||||
console.log(` 地面瓦片里 minBlockY != 0 的:${String(floorMinYNonZero)} / ${String(cells)}`)
|
||||
|
||||
for (const sample of samples) console.log(` ${sample}`)
|
||||
|
||||
console.log(`\n 墙引用键复核:${String(refsExact)}/${String(refsTotal)} 精确命中,loose ${String(refsLoose)},缺失 ${String(refsMissing)}`)
|
||||
if (refsLoose > 0) console.log(` loose 引用:${[...looseRefs].slice(0, 8).join(' | ')}`)
|
||||
check('DS1 wall type 与 DT1 `Type`(+20) 精确对齐(无 loose/缺失)', refsTotal > 0 && refsExact === refsTotal,
|
||||
`${String(refsExact)}/${String(refsTotal)} 精确命中`)
|
||||
|
||||
check('D2MOO 与本项目的差在所有格子上是同一个常量', offsetsD2moo.size === 1,
|
||||
[...offsetsD2moo].join(' | '))
|
||||
check('该常量与"整张图平移"一致(解释 80 px 疑点)', [...offsetsD2moo][0] === '80,80',
|
||||
`偏移 ${[...offsetsD2moo][0] ?? '?'}:X 差一格半宽(位图锚点),Y 差一个 tile 高(D2MOO 的口径)`)
|
||||
check('屋顶偏移等于引擎的 -roofHeight(没有屋顶层的关卡按通过算)', roofDraws === 0 || roofMatches === roofDraws,
|
||||
`${String(roofMatches)}/${String(roofDraws)} 个屋顶`)
|
||||
|
||||
check('墙的 minBlockY / bitmapHeight 等于引擎从原始字节算出的值', formulaMismatches.length === 0,
|
||||
formulaMismatches.length === 0 ? `${String(wallTiles)} 面墙全部一致` : formulaMismatches.join(' | '))
|
||||
// 墙脚与地面下沿:多数应当正好重合;差值不为 0 的那些是"美术本身伸到格子下沿之外"的墙
|
||||
// (栅栏、树、台阶等),它们的脚本来就在格子外,属于正常。
|
||||
const baseExact = baseDeltas.get(0) ?? 0
|
||||
check('接缝判据:墙的基线与地面基线完全重合', pairs > 0 && baseExact === pairs,
|
||||
`${String(baseExact)}/${String(pairs)} 对基线差为 0`)
|
||||
console.log(` (参考值,不作为判据)最下面一行不透明像素之差最常见 ${String(deltas[0]?.[0] ?? 0)}px:`
|
||||
+ '不同 tile 类型的美术在基线上下伸出的量不同,栅栏/树/台阶本来就越过格子下沿')
|
||||
|
||||
const passed = checks.filter(entry => entry.ok).length
|
||||
console.log(`\n${String(passed)}/${String(checks.length)} passed`)
|
||||
if (passed !== checks.length) process.exitCode = 1
|
||||
}
|
||||
|
||||
await main()
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
// verify-tiles.ts — 验收"每个槽位画的是不是它该有的那种瓦片"
|
||||
//
|
||||
// 这份脚本存在的原因是一次真实事故:地面引用在 `buildIsoMapScene()` 里以 `type = null` 走
|
||||
// **类型无关**的兜底池,池子里混着地面、墙、柱子、影子、树、屋顶。加上"按 RarityFrameIndex
|
||||
// 加权随机选变体"之后,地面槽位会随机挑到石墙/柱子瓦片,而地面绘制不做墙那套
|
||||
// `minBlockY + 80` 补偿 —— 于是画面里出现"悬在地面上的暗色方块"(35 关实测 1,918/28,704
|
||||
// 个地面槽位画错类型,修道院大教堂 28.2%、地下墓穴 4 层 41.5%)。
|
||||
//
|
||||
// 现在地面按 DT1 `type 0` 解析,这里把当年的普查变成永久断言:
|
||||
// 1. 地面槽位画出的瓦片 `type` 必须是 0;
|
||||
// 2. 墙/屋顶槽位不得画出地面瓦片(type 0);
|
||||
// 3. `looseRefs == 0`(没有任何引用走类型无关兜底)与 `missingTiles == 0`——
|
||||
// 这两条合起来保证"画出的类型 == 引用的类型";
|
||||
// 4. 位置自洽:地面 `y == (cx+cy)*40` 且瓦片 `minBlockY == 0`;
|
||||
// 墙 `y == (cx+cy)*40 + minBlockY + 80`;屋顶 `y == (cx+cy)*40 - roofHeight`。
|
||||
//
|
||||
// node scripts/verify-tiles.ts [--dir=samples/d2] [--limit=35]
|
||||
//
|
||||
// 退出码非 0 表示有断言没过。
|
||||
|
||||
export {}
|
||||
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { decodeDs1 } from '../src/formats/ds1.ts'
|
||||
import { decodeDt1 } from '../src/formats/dt1.ts'
|
||||
import type { Dt1 } from '../src/formats/dt1.ts'
|
||||
import { cell, loadActTables, resolveLevel, resolveLevelLibraries } from '../src/game/acts.ts'
|
||||
import { buildIsoMapScene, levelSeed, ORTHO_CELL_HEIGHT } from '../src/game/d2map.ts'
|
||||
|
||||
/** 归档目录。 */
|
||||
const DIR = process.argv.find(argument => argument.startsWith('--dir='))?.slice('--dir='.length) ?? 'samples/d2'
|
||||
/** 最多走多少个预置关卡。 */
|
||||
const LIMIT = Number(process.argv.find(argument => argument.startsWith('--limit='))?.slice('--limit='.length) ?? '35')
|
||||
|
||||
/** 断言结果。 */
|
||||
const checks: { name: string; ok: boolean; detail: string }[] = []
|
||||
|
||||
/**
|
||||
* 记录一条断言。
|
||||
*
|
||||
* @param name - what was checked.
|
||||
* @param ok - whether it held.
|
||||
* @param detail - evidence.
|
||||
*/
|
||||
function check(name: string, ok: boolean, detail: string): void {
|
||||
checks.push({ name, ok, detail })
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${name} — ${detail}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程。
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`${DIR}/${name}`)))
|
||||
}
|
||||
const tables = await loadActTables(archives)
|
||||
const libraryCache = new Map<string, Dt1>()
|
||||
|
||||
let levels = 0
|
||||
let floors = 0
|
||||
let walls = 0
|
||||
let roofs = 0
|
||||
let wrongFloorType = 0
|
||||
let floorAsWall = 0
|
||||
let looseRefs = 0
|
||||
let missingTiles = 0
|
||||
let positionMismatch = 0
|
||||
const worst: [number, string][] = []
|
||||
|
||||
for (const row of tables.levels.rows) {
|
||||
if (cell(tables.levels, row, 'DrlgType') !== '2') continue
|
||||
const levelId = Number(cell(tables.levels, row, 'Id'))
|
||||
let info
|
||||
try {
|
||||
info = resolveLevel(tables, levelId)
|
||||
} catch { continue }
|
||||
const { dt1Names } = resolveLevelLibraries(tables, levelId)
|
||||
const libs: Dt1[] = []
|
||||
for (const name of dt1Names) {
|
||||
const cached = libraryCache.get(name)
|
||||
if (cached !== undefined) { libs.push(cached); continue }
|
||||
try {
|
||||
const decoded = decodeDt1(await archives.read(name))
|
||||
libraryCache.set(name, decoded)
|
||||
libs.push(decoded)
|
||||
} catch { /* 缺库时 verify:acts 会报 */ }
|
||||
}
|
||||
if (libs.length === 0) continue
|
||||
let ds1
|
||||
try {
|
||||
ds1 = decodeDs1(await archives.read(info.ds1Names[0]!))
|
||||
} catch { continue }
|
||||
if (levels >= LIMIT) break
|
||||
levels += 1
|
||||
|
||||
const seed = levelSeed(info.ds1Names[0]!)
|
||||
const scene = buildIsoMapScene(ds1, libs, seed)
|
||||
looseRefs += scene.looseRefs
|
||||
missingTiles += scene.missingTiles
|
||||
|
||||
let badHere = 0
|
||||
for (const draw of scene.floors) {
|
||||
const tile = libs[draw.library]?.tiles[draw.tile]
|
||||
if (tile === undefined) continue
|
||||
floors += 1
|
||||
// (1) 地面槽位只能是 type 0 的瓦片
|
||||
if (tile.type !== 0) { wrongFloorType += 1; badHere += 1 }
|
||||
// (4) 地面位置:不带动画块补偿,且地面瓦片本身不该有块位移
|
||||
if (draw.y - scene.originY !== (draw.cellX + draw.cellY) * ORTHO_CELL_HEIGHT || tile.minBlockY !== 0) positionMismatch += 1
|
||||
}
|
||||
for (const draw of scene.walls) {
|
||||
const tile = libs[draw.library]?.tiles[draw.tile]
|
||||
if (tile === undefined) continue
|
||||
walls += 1
|
||||
// (2) 墙槽位不该画出地面瓦片
|
||||
if (tile.type === 0) floorAsWall += 1
|
||||
// (4) 墙位置 = 格子基线 + 该瓦片自己的块位移 + 一格高
|
||||
if (draw.y - scene.originY !== (draw.cellX + draw.cellY) * ORTHO_CELL_HEIGHT + tile.minBlockY + 80) positionMismatch += 1
|
||||
}
|
||||
for (const draw of scene.roofs) {
|
||||
const tile = libs[draw.library]?.tiles[draw.tile]
|
||||
if (tile === undefined) continue
|
||||
roofs += 1
|
||||
if (draw.y - scene.originY !== (draw.cellX + draw.cellY) * ORTHO_CELL_HEIGHT - tile.roofHeight) positionMismatch += 1
|
||||
}
|
||||
if (scene.floors.length > 0) worst.push([100 * badHere / scene.floors.length, info.levelName])
|
||||
}
|
||||
|
||||
worst.sort((left, right) => right[0] - left[0])
|
||||
console.log(`\n走了 ${String(levels)} 个预置关卡:地面 ${String(floors)} 槽、墙 ${String(walls)} 槽、屋顶 ${String(roofs)} 槽`)
|
||||
|
||||
check('地面槽位画出的瓦片类型都是 0(无错配方块)', wrongFloorType === 0,
|
||||
`${String(wrongFloorType)} 个错类型 / ${String(floors)} 个地面槽`)
|
||||
check('墙/屋顶槽位没有画出地面瓦片', floorAsWall === 0, `${String(floorAsWall)} 个`)
|
||||
check('没有任何引用走类型无关兜底', looseRefs === 0, `looseRefs ${String(looseRefs)}`)
|
||||
check('没有解析不到的引用', missingTiles === 0, `missingTiles ${String(missingTiles)}`)
|
||||
check('地面/墙/屋顶的位置公式自洽', positionMismatch === 0, `${String(positionMismatch)} 个不符`)
|
||||
if (worst.length > 0 && worst[0]![0] > 0) console.log(` 最严重:${worst.slice(0, 5).map(([pct, name]) => `${name} ${pct.toFixed(1)}%`).join(',')}`)
|
||||
|
||||
const passed = checks.filter(entry => entry.ok).length
|
||||
console.log(`\n${String(passed)}/${String(checks.length)} passed`)
|
||||
if (passed !== checks.length) process.exitCode = 1
|
||||
}
|
||||
|
||||
await main()
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Decode a sprite whose frames each have their own width.
|
||||
*
|
||||
* Diablo I's tile and cursor sheets are the case single-width auto-detection
|
||||
* cannot serve: the inventory cursor sheet is paired with a sidecar list of
|
||||
* per-frame widths, and dungeon tile sheets take their widths from the tile
|
||||
* definitions. This drives the decoder with such a list against a real member,
|
||||
* which is the only way to show the per-frame path actually matches shipped
|
||||
* data.
|
||||
*
|
||||
* Usage: node scripts/verify-widths.ts <archive> <member> <widths-file>
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { decodeSpriteFile } from '../src/formats/cel.ts'
|
||||
|
||||
const [path, member, widthsPath] = process.argv.slice(2)
|
||||
if (path === undefined || member === undefined || widthsPath === undefined) {
|
||||
console.error('usage: node scripts/verify-widths.ts <archive> <member> <widths-file>')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const widths = (await readFile(widthsPath, 'utf8'))
|
||||
.split(/\s+/)
|
||||
.filter(part => part !== '')
|
||||
.map(part => Number.parseInt(part, 10))
|
||||
if (widths.some(width => !Number.isFinite(width) || width <= 0)) {
|
||||
console.error('widths file has a non-positive or unparsable entry')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const archive = await MpqArchive.open(await fileSource(path))
|
||||
const mode = member.toLowerCase().endsWith('.cl2') ? 'cl2' : 'cel'
|
||||
const file = archive.find(member)
|
||||
if (file === undefined) {
|
||||
console.error(`no such member: ${member}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const data = await archive.read(file)
|
||||
const sheet = decodeSpriteFile(data, mode, { widths })
|
||||
const frames = sheet.groups.flatMap(group => group.frames)
|
||||
const heights = frames.map(frame => frame.height)
|
||||
const coverage = frames.map(frame => {
|
||||
let opaque = 0
|
||||
for (const value of frame.mask) if (value !== 0) opaque += 1
|
||||
return opaque
|
||||
})
|
||||
const empty = coverage.filter(count => count === 0).length
|
||||
|
||||
console.log(`archive ${path}`)
|
||||
console.log(`member ${member} (${String(data.byteLength)} bytes, ${mode})`)
|
||||
console.log(`widths ${String(widths.length)} entries from ${widthsPath}`)
|
||||
console.log(`groups ${String(sheet.groups.length)}`)
|
||||
console.log(`frames ${String(frames.length)} decoded`)
|
||||
console.log(`heights ${String(Math.min(...heights))}..${String(Math.max(...heights))}`)
|
||||
console.log(`pixels ${String(Math.min(...coverage))}..${String(Math.max(...coverage))} opaque per frame`)
|
||||
console.log(`empty ${String(empty)} frames with no opaque pixels`)
|
||||
console.log(empty === 0 && frames.length === widths.length
|
||||
? 'RESULT every frame decoded and matched its width entry exactly'
|
||||
: 'RESULT mismatch — check the width list against the frame count')
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* Least-significant-bit-first bit reader for Diablo II's packed sprite
|
||||
* containers.
|
||||
*
|
||||
* Diablo II's `.dcc` files are not byte-aligned anywhere. The direction header
|
||||
* ends mid-byte, the per-frame headers that follow are packed at whatever bit
|
||||
* width the direction declares, and the four payload streams (equal-cell
|
||||
* flags, pixel mask, encoding type, raw pixel codes) are laid out back to back
|
||||
* starting at *bit* offsets — a stream that begins at byte 23 bit 6 stays at
|
||||
* byte 23 bit 6 for its whole length.
|
||||
*
|
||||
* That has two consequences a naive reader gets wrong:
|
||||
*
|
||||
* - The reader's position has to be a bit index, not a byte cursor. Anything
|
||||
* that rounds to a byte boundary shifts every subsequent pixel code.
|
||||
* - `copy()` must capture that exact bit position while giving the copy its own
|
||||
* read counter. The DCC decoder clones the reader to walk a sub-stream and
|
||||
* then skips the original past the sub-stream's declared bit length, and it
|
||||
* compares each sub-stream's *own* counter against its declared length as a
|
||||
* sanity check — so the copy must start counting from zero, not inherit the
|
||||
* parent's total.
|
||||
*
|
||||
* Bits are consumed least-significant-bit first within each byte, which is how
|
||||
* the format was packed and what the reference implementation does.
|
||||
*/
|
||||
|
||||
/** Largest single read the format ever needs: a 32-bit header word. */
|
||||
const MAX_READ_BITS = 32
|
||||
|
||||
/**
|
||||
* A reader over a byte buffer positioned at a bit offset.
|
||||
*
|
||||
* Reads past the end of the buffer throw instead of yielding zeroes: a DCC
|
||||
* whose streams overrun its own extent is corrupt or mis-parsed, and both cases
|
||||
* must be reported rather than silently producing a plausible-looking sprite.
|
||||
*/
|
||||
export class BitReader {
|
||||
/** The bytes being read. Never copied, so clones share one buffer. */
|
||||
private readonly data: Uint8Array
|
||||
/** Current position, in bits from the start of `data`. */
|
||||
private position: number
|
||||
/** Bits consumed by *this* reader, mirroring the reference's per-stream count. */
|
||||
private read: number
|
||||
|
||||
/**
|
||||
* @param data - the buffer to read.
|
||||
* @param bitOffset - starting position in bits (the DCC direction offset is
|
||||
* stored in bytes and scaled by 8 by the caller).
|
||||
*/
|
||||
constructor(data: Uint8Array, bitOffset = 0) {
|
||||
if (!Number.isInteger(bitOffset) || bitOffset < 0) {
|
||||
throw new Error(`bit offset ${String(bitOffset)} is not a non-negative integer`)
|
||||
}
|
||||
this.data = data
|
||||
this.position = bitOffset
|
||||
this.read = 0
|
||||
}
|
||||
|
||||
/** The reader's position in bits from the start of the buffer. */
|
||||
get bitOffset(): number {
|
||||
return this.position
|
||||
}
|
||||
|
||||
/**
|
||||
* Bits consumed by this reader.
|
||||
*
|
||||
* A clone starts this count at zero, matching the reference: the DCC decoder
|
||||
* uses it to prove each sub-stream was consumed exactly to its declared
|
||||
* length.
|
||||
*
|
||||
* @returns the count.
|
||||
*/
|
||||
bitsRead(): number {
|
||||
return this.read
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one bit.
|
||||
*
|
||||
* @returns 0 or 1.
|
||||
*/
|
||||
getBit(): number {
|
||||
const position = this.position
|
||||
if (position >= this.data.length * 8) {
|
||||
throw new Error(`bit ${String(position)} is past the ${String(this.data.length)}-byte buffer`)
|
||||
}
|
||||
this.position = position + 1
|
||||
this.read += 1
|
||||
return (this.data[position >>> 3]! >>> (position & 7)) & 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `count` bits as an unsigned value, low bit first.
|
||||
*
|
||||
* @param count - 0..32 bits; a count of 0 reads nothing and returns 0, as the
|
||||
* reference does (DCC declares optional field widths as 0 to mean absent).
|
||||
* @returns the value, as an unsigned 32-bit number.
|
||||
*/
|
||||
getBits(count: number): number {
|
||||
if (count === 0) return 0
|
||||
if (count < 0 || count > MAX_READ_BITS || !Number.isInteger(count)) {
|
||||
throw new Error(`cannot read ${String(count)} bits (allowed 0..${String(MAX_READ_BITS)})`)
|
||||
}
|
||||
const data = this.data
|
||||
const start = this.position
|
||||
const end = start + count
|
||||
if (end > data.length * 8) {
|
||||
throw new Error(
|
||||
`reading ${String(count)} bits at bit ${String(start)} overruns the ${String(data.length)}-byte buffer`,
|
||||
)
|
||||
}
|
||||
let result = 0
|
||||
let shift = 0
|
||||
let position = start
|
||||
// Consume whole or partial bytes; a chunk never straddles more than one
|
||||
// byte boundary, so `shift + take <= 32` and nothing is lost to `<<`.
|
||||
while (position < end) {
|
||||
const bit = position & 7
|
||||
const take = Math.min(8 - bit, end - position)
|
||||
const chunk = (data[position >>> 3]! >>> bit) & ((1 << take) - 1)
|
||||
result |= chunk << shift
|
||||
shift += take
|
||||
position += take
|
||||
}
|
||||
this.position = end
|
||||
this.read += count
|
||||
return result >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one byte.
|
||||
*
|
||||
* @returns 0..255.
|
||||
*/
|
||||
getByte(): number {
|
||||
return this.getBits(8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a little-endian signed 32-bit word.
|
||||
*
|
||||
* @returns the value.
|
||||
*/
|
||||
getInt32(): number {
|
||||
return makeSigned(this.getBits(32), 32)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a little-endian unsigned 32-bit word.
|
||||
*
|
||||
* @returns the value.
|
||||
*/
|
||||
getUint32(): number {
|
||||
return this.getBits(32)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `count` bits and interpret them as two's-complement signed.
|
||||
*
|
||||
* @param count - the field width; 0 reads nothing and returns 0.
|
||||
* @returns the signed value.
|
||||
*/
|
||||
getSignedBits(count: number): number {
|
||||
return makeSigned(this.getBits(count), count)
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance without reading.
|
||||
*
|
||||
* Skips are unchecked, as in the reference: the DCC decoder skips past
|
||||
* sub-streams it already consumed through a clone, and the last one lands
|
||||
* exactly on the end of the payload.
|
||||
*
|
||||
* @param count - bits to skip.
|
||||
*/
|
||||
skipBits(count: number): void {
|
||||
if (!Number.isInteger(count) || count < 0) {
|
||||
throw new Error(`cannot skip ${String(count)} bits`)
|
||||
}
|
||||
this.position += count
|
||||
this.read += count
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone this reader at the current bit position.
|
||||
*
|
||||
* The clone shares the buffer, starts with the same bit offset — mid-byte
|
||||
* positions included, which is the whole point — and resets its own
|
||||
* `bitsRead()` counter to zero.
|
||||
*
|
||||
* @returns the clone.
|
||||
*/
|
||||
copy(): BitReader {
|
||||
return new BitReader(this.data, this.position)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret a bit field as two's-complement signed, matching the reference's
|
||||
* `MakeSigned` exactly: 0 bits is 0, a single bit is 0 or -1 (there is no
|
||||
* redundant sign bit to spend), and everything else is sign-extended from the
|
||||
* top bit.
|
||||
*
|
||||
* @param value - the unsigned field, as read.
|
||||
* @param bits - the field width.
|
||||
* @returns the signed value.
|
||||
*/
|
||||
function makeSigned(value: number, bits: number): number {
|
||||
if (bits === 0) return 0
|
||||
if (bits === 1) return -value
|
||||
if (((value >>> 0) & (1 << (bits - 1))) === 0) return value | 0
|
||||
// Sign extension is done by subtraction in the unsigned domain, exactly like
|
||||
// the reference, so widths above 31 (where JS bitwise ops stay 32-bit) agree.
|
||||
let result = 0xffffffff
|
||||
for (let i = 0; i < bits; i += 1) {
|
||||
if (((value >>> i) & 1) === 0) result = (result - 2 ** i) >>> 0
|
||||
}
|
||||
return result | 0
|
||||
}
|
||||
|
|
@ -0,0 +1,472 @@
|
|||
/**
|
||||
* Diablo I sprite decoders: `.cel` (plain) and `.cl2` (its compressed form).
|
||||
*
|
||||
* Both are palette-indexed sprite sheets. What differs is how the run stream
|
||||
* maps onto the frame grid:
|
||||
*
|
||||
* - **CEL** runs are *line-bounded*: every row's runs sum to exactly the frame
|
||||
* width, so the decoder consumes one row at a time and the frame height is
|
||||
* the number of rows. A run that overruns its row means corrupt input, not a
|
||||
* wrapped row, so it is rejected instead of silently wrapping (the reference
|
||||
* decoder's unsigned counter would wrap instead).
|
||||
* - **CL2** runs may *cross* row boundaries, so the stream is written flat into
|
||||
* the frame and line bookkeeping is derived from the overrun, exactly as the
|
||||
* reference decoder does.
|
||||
*
|
||||
* Neither format stores the frame width: the game supplies it per animation
|
||||
* (Diablo's own tables give, e.g., the walk width per class). A viewer can
|
||||
* recover it, which {@link detectSpriteWidth} does by requiring every frame in
|
||||
* the file to be consumed exactly.
|
||||
*/
|
||||
import { CelError, spriteWidthCandidates } from './sprite.ts'
|
||||
import type { SpriteFrame, SpriteGroup, SpriteSheet } from './sprite.ts'
|
||||
|
||||
export type { SpriteFrame, SpriteGroup, SpriteSheet } from './sprite.ts'
|
||||
export { CelError } from './sprite.ts'
|
||||
|
||||
/** Options shared by both decoders. */
|
||||
export interface DecodeOptions {
|
||||
/**
|
||||
* Frame width. Required by the formats themselves; when omitted the width is
|
||||
* auto-detected from the file (see {@link detectSpriteWidth}) for `.cel`, and
|
||||
* rejected for `.cl2`.
|
||||
*/
|
||||
width?: number | undefined
|
||||
/**
|
||||
* Per-frame widths, indexed by the frame's position within its group.
|
||||
*
|
||||
* Diablo I mixes both conventions: full-animation sheets use one width, while
|
||||
* tile and cursor sheets give every frame its own (the inventory cursor sheet
|
||||
* ships a sidecar width list, and dungeon tile sheets take theirs from the
|
||||
* tile definitions). Takes precedence over {@link width}.
|
||||
*/
|
||||
widths?: readonly number[] | undefined
|
||||
/** Cap on decoded frames, to keep a pathological file from exhausting memory. */
|
||||
maxFrames?: number | undefined
|
||||
/** Cap on one frame's pixel count. */
|
||||
maxFramePixels?: number | undefined
|
||||
}
|
||||
|
||||
/** Sentinel used by the reference decoder to mark a frame header. */
|
||||
const CEL_FRAME_HEADER_SIZE = 10
|
||||
/** Default guard: the largest sprite sheet in the game is far below this. */
|
||||
const DEFAULT_MAX_FRAMES = 20000
|
||||
/** Default guard for one frame (a 4096×4096 indexed frame). */
|
||||
const DEFAULT_MAX_FRAME_PIXELS = 1 << 24
|
||||
|
||||
/** Read a little-endian uint32. */
|
||||
function u32(data: Uint8Array, at: number): number {
|
||||
return (data[at]! | (data[at + 1]! << 8) | (data[at + 2]! << 16) | (data[at + 3]! << 24)) >>> 0
|
||||
}
|
||||
|
||||
/** Read a little-endian uint16. */
|
||||
function u16(data: Uint8Array, at: number): number {
|
||||
return data[at]! | (data[at + 1]! << 8)
|
||||
}
|
||||
|
||||
/** A frame's frame-extent table, as `[start, end)` pairs. */
|
||||
type Extents = readonly (readonly [number, number])[]
|
||||
|
||||
/**
|
||||
* Resolve a file's groups.
|
||||
*
|
||||
* A single-group file is `[count][count + 1 offsets][frames...]`; a multi-group
|
||||
* file instead opens with a group table. CEL and CL2 differ in how that table
|
||||
* is read: CEL concatenates its groups after it, CL2 stores offsets to them.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param mode - `'cel'` or `'cl2'`.
|
||||
* @returns the groups' base offsets, or a single group when there is no table.
|
||||
*/
|
||||
function readGroups(data: Uint8Array, mode: 'cel' | 'cl2'): { groupBases: number[]; single: boolean } | { error: string } {
|
||||
const first = u32(data, 0)
|
||||
if (first === 0) return { error: 'declares zero frames' }
|
||||
const lastOffsetAt = first * 4 + 4
|
||||
if (lastOffsetAt + 4 <= data.byteLength && u32(data, lastOffsetAt) === data.byteLength) {
|
||||
// Single group: the frame table's last entry is the file size.
|
||||
return { groupBases: [0], single: true }
|
||||
}
|
||||
const numGroups = Math.floor(first / 4)
|
||||
if (numGroups === 0 || numGroups > 4096) return { error: `implausible group count ${String(numGroups)}` }
|
||||
if (mode === 'cel') {
|
||||
// Groups are laid out back to back after the table; their bases are not
|
||||
// stored, so they are walked by decoding.
|
||||
return { groupBases: [], single: false }
|
||||
}
|
||||
const bases: number[] = []
|
||||
for (let group = 0; group < numGroups; group += 1) {
|
||||
const base = u32(data, group * 4)
|
||||
if (base + 4 > data.byteLength) return { error: `group ${String(group)} offset ${String(base)} is out of range` }
|
||||
bases.push(base)
|
||||
}
|
||||
return { groupBases: bases, single: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame extents of one group.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param base - the group's base offset.
|
||||
* @returns the extents, each `[start, end)` in absolute file offsets.
|
||||
*/
|
||||
function groupExtents(data: Uint8Array, base: number): Extents | { error: string } {
|
||||
const count = u32(data, base)
|
||||
if (count === 0 || count > 65535) return { error: `implausible frame count ${String(count)}` }
|
||||
const extents: [number, number][] = []
|
||||
for (let frame = 0; frame < count; frame += 1) {
|
||||
const start = u32(data, base + 4 * (frame + 1))
|
||||
const end = u32(data, base + 4 * (frame + 2))
|
||||
if (start < 0 || end < start || base + end > data.byteLength) {
|
||||
return { error: `frame ${String(frame)} extent ${String(start)}..${String(end)} is out of range` }
|
||||
}
|
||||
extents.push([base + start, base + end])
|
||||
}
|
||||
return extents
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one CEL frame.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param start - frame start offset.
|
||||
* @param end - frame end offset.
|
||||
* @param width - frame width.
|
||||
* @param maxFramePixels - pixel-count guard.
|
||||
* @returns the frame.
|
||||
*/
|
||||
function decodeCelFrame(data: Uint8Array, start: number, end: number, width: number, maxFramePixels: number): SpriteFrame | { error: string } {
|
||||
let cursor = start
|
||||
// Some frames carry a 10-byte header whose first word is its own size.
|
||||
if (cursor + 2 <= end && u16(data, cursor) === CEL_FRAME_HEADER_SIZE) cursor += CEL_FRAME_HEADER_SIZE
|
||||
const capacity = Math.min(maxFramePixels, Math.max(width * 4096, width))
|
||||
const indices = new Uint8Array(capacity)
|
||||
const mask = new Uint8Array(capacity)
|
||||
let at = 0
|
||||
let height = 0
|
||||
while (cursor < end) {
|
||||
let remaining = width
|
||||
while (remaining > 0) {
|
||||
if (cursor >= end) return { error: `row ${String(height)} ran past the frame end` }
|
||||
const control = data[cursor]!
|
||||
cursor += 1
|
||||
if (control >= 0x80) {
|
||||
remaining -= 256 - control
|
||||
} else {
|
||||
const run = control
|
||||
if (run > remaining) return { error: `row ${String(height)} overruns by ${String(run - remaining)} pixels` }
|
||||
if (cursor + run > end) return { error: `row ${String(height)} literal run is truncated` }
|
||||
if (at + run > capacity) return { error: 'frame exceeds the pixel guard' }
|
||||
indices.set(data.subarray(cursor, cursor + run), at)
|
||||
mask.fill(1, at, at + run)
|
||||
cursor += run
|
||||
at += run
|
||||
remaining -= run
|
||||
}
|
||||
}
|
||||
height += 1
|
||||
if (height * width > capacity) return { error: 'frame exceeds the pixel guard' }
|
||||
}
|
||||
if (cursor !== end) return { error: 'frame was not consumed exactly' }
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
indices: indices.subarray(0, width * height),
|
||||
mask: mask.subarray(0, width * height),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one CL2 frame, whose runs may cross row boundaries.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param start - frame start offset.
|
||||
* @param end - frame end offset.
|
||||
* @param width - frame width.
|
||||
* @param maxFramePixels - pixel-count guard.
|
||||
* @returns the frame, the column the run stream ended on, or the failure.
|
||||
*/
|
||||
function decodeCl2FrameOutcome(data: Uint8Array, start: number, end: number, width: number, maxFramePixels: number): { ok: true; frame: SpriteFrame; lastXOffset: number } | { ok: false; error: string } {
|
||||
// The frame opens with the size of its own header.
|
||||
if (start + 2 > end) return { ok: false, error: 'frame is too short for its header' }
|
||||
let cursor = start + u16(data, start)
|
||||
if (cursor > end) return { ok: false, error: 'frame header points past the frame end' }
|
||||
const capacity = Math.min(maxFramePixels, Math.max(width * 4096, width))
|
||||
const indices = new Uint8Array(capacity)
|
||||
const mask = new Uint8Array(capacity)
|
||||
let at = 0
|
||||
let height = 0
|
||||
let xOffset = 0
|
||||
while (cursor < end) {
|
||||
let remaining = width - xOffset
|
||||
while (remaining > 0) {
|
||||
if (cursor >= end) return { ok: false, error: 'runs ran past the frame end' }
|
||||
const control = data[cursor]!
|
||||
cursor += 1
|
||||
if (control < 0x80) {
|
||||
// Transparent run of `control` pixels.
|
||||
if (at + control > capacity) return { ok: false, error: 'frame exceeds the pixel guard' }
|
||||
at += control
|
||||
remaining -= control
|
||||
} else if (control <= 0xbe) {
|
||||
// Fill run: one colour repeated 0xBF - control times.
|
||||
const count = 0xbf - control
|
||||
if (cursor >= end) return { ok: false, error: 'fill run is truncated' }
|
||||
const color = data[cursor]!
|
||||
cursor += 1
|
||||
if (at + count > capacity) return { ok: false, error: 'frame exceeds the pixel guard' }
|
||||
indices.fill(color, at, at + count)
|
||||
mask.fill(1, at, at + count)
|
||||
at += count
|
||||
remaining -= count
|
||||
} else {
|
||||
// Literal run of 256 - control pixels (0xBF is 65 wide).
|
||||
const count = 256 - control
|
||||
if (cursor + count > end) return { ok: false, error: 'literal run is truncated' }
|
||||
if (at + count > capacity) return { ok: false, error: 'frame exceeds the pixel guard' }
|
||||
indices.set(data.subarray(cursor, cursor + count), at)
|
||||
mask.fill(1, at, at + count)
|
||||
cursor += count
|
||||
at += count
|
||||
remaining -= count
|
||||
}
|
||||
}
|
||||
if (remaining === 0) {
|
||||
height += 1
|
||||
xOffset = 0
|
||||
} else {
|
||||
const overrun = -remaining
|
||||
height += Math.floor(overrun / width) + 1
|
||||
xOffset = overrun % width
|
||||
}
|
||||
}
|
||||
if (cursor !== end) return { ok: false, error: 'frame was not consumed exactly' }
|
||||
const pixels = width * height
|
||||
if (pixels > capacity) return { ok: false, error: 'frame exceeds the pixel guard' }
|
||||
return {
|
||||
ok: true,
|
||||
lastXOffset: xOffset,
|
||||
frame: {
|
||||
width,
|
||||
height,
|
||||
indices: indices.subarray(0, pixels),
|
||||
mask: mask.subarray(0, pixels),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one CL2 frame, discarding the row-alignment diagnostic.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param start - frame start offset.
|
||||
* @param end - frame end offset.
|
||||
* @param width - frame width.
|
||||
* @param maxFramePixels - pixel-count guard.
|
||||
* @returns the frame or the failure.
|
||||
*/
|
||||
function decodeCl2Frame(data: Uint8Array, start: number, end: number, width: number, maxFramePixels: number): SpriteFrame | { error: string } {
|
||||
const outcome = decodeCl2FrameOutcome(data, start, end, width, maxFramePixels)
|
||||
return outcome.ok ? outcome.frame : { error: outcome.error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess which frame widths a CL2 file could have been encoded with.
|
||||
*
|
||||
* Weaker than the CEL rule, deliberately: a CL2 run stream is consumed to the
|
||||
* frame end under any width, so exactness proves nothing here. What survives is
|
||||
* *row alignment* — an encoder pads the final row, so the stream ends on a
|
||||
* column boundary for the true width. Candidates that satisfy this are
|
||||
* plausible, not proven; the game's own tables stay authoritative (see
|
||||
* `PLAYER_SPRITE_WIDTH`).
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param candidates - widths to try.
|
||||
* @returns the widths under which every frame decodes and ends on a row boundary.
|
||||
*/
|
||||
export function cl2WidthCandidates(data: Uint8Array, candidates: readonly number[]): number[] {
|
||||
const groupsInfo = readGroups(data, 'cl2')
|
||||
if ('error' in groupsInfo) return []
|
||||
const bases = groupsInfo.single ? [0] : [...groupsInfo.groupBases]
|
||||
const usable: number[] = []
|
||||
for (const width of candidates) {
|
||||
let aligned = bases.length > 0
|
||||
for (const base of bases) {
|
||||
const extents = groupExtents(data, base)
|
||||
if ('error' in extents) { aligned = false; break }
|
||||
for (const [frameStart, frameEnd] of extents) {
|
||||
const outcome = decodeCl2FrameOutcome(data, frameStart, frameEnd, width, DEFAULT_MAX_FRAME_PIXELS)
|
||||
if (!outcome.ok || outcome.lastXOffset !== 0 || outcome.frame.height === 0) { aligned = false; break }
|
||||
}
|
||||
if (!aligned) break
|
||||
}
|
||||
if (aligned) usable.push(width)
|
||||
}
|
||||
return usable
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a sprite file.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param mode - `'cel'` or `'cl2'`.
|
||||
* @param options - width override and guards.
|
||||
* @returns the decoded sheet.
|
||||
*/
|
||||
export function decodeSpriteFile(data: Uint8Array, mode: 'cel' | 'cl2', options: DecodeOptions = {}): SpriteSheet {
|
||||
const maxFrames = options.maxFrames ?? DEFAULT_MAX_FRAMES
|
||||
const maxFramePixels = options.maxFramePixels ?? DEFAULT_MAX_FRAME_PIXELS
|
||||
if (data.byteLength < 8) throw new CelError(`file is only ${String(data.byteLength)} bytes`)
|
||||
const groupsInfo = readGroups(data, mode)
|
||||
if ('error' in groupsInfo) throw new CelError(groupsInfo.error)
|
||||
|
||||
const perFrame = options.widths
|
||||
const uniform = options.width ?? (perFrame === undefined && mode === 'cel' ? detectSpriteWidth(data) : null)
|
||||
if (perFrame === undefined && uniform === null) {
|
||||
throw new CelError(mode === 'cl2'
|
||||
? 'CL2 does not store its frame width: pass the width (or per-frame widths) the game uses for this animation'
|
||||
: 'could not determine the frame width (no single width consumes the file exactly; tile and cursor sheets need per-frame widths)')
|
||||
}
|
||||
|
||||
const groups: SpriteGroup[] = []
|
||||
let frames = 0
|
||||
if (groupsInfo.single) {
|
||||
const extents = groupExtents(data, 0)
|
||||
if ('error' in extents) throw new CelError(extents.error)
|
||||
groups.push({ frames: decodeFrames(data, extents, mode, uniform, perFrame, maxFramePixels) })
|
||||
frames += extents.length
|
||||
} else if (mode === 'cl2') {
|
||||
for (const base of groupsInfo.groupBases) {
|
||||
const extents = groupExtents(data, base)
|
||||
if ('error' in extents) throw new CelError(extents.error)
|
||||
if (frames + extents.length > maxFrames) throw new CelError('file exceeds the frame guard')
|
||||
groups.push({ frames: decodeFrames(data, extents, mode, uniform, perFrame, maxFramePixels) })
|
||||
frames += extents.length
|
||||
}
|
||||
} else {
|
||||
// CEL multi-group: groups are concatenated, so each group is found by
|
||||
// decoding the previous one to its end.
|
||||
let base = Math.floor(u32(data, 0) / 4) * 4
|
||||
while (base < data.byteLength && groups.length < 4096) {
|
||||
const extents = groupExtents(data, base)
|
||||
if ('error' in extents) throw new CelError(extents.error)
|
||||
if (frames + extents.length > maxFrames) throw new CelError('file exceeds the frame guard')
|
||||
groups.push({ frames: decodeFrames(data, extents, mode, uniform, perFrame, maxFramePixels) })
|
||||
frames += extents.length
|
||||
const last = extents[extents.length - 1]
|
||||
/* v8 ignore next -- extents is non-empty by construction. */
|
||||
if (last === undefined) break
|
||||
base = last[1]
|
||||
}
|
||||
}
|
||||
return { groups, width: uniform }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a run of frames that share one width.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param extents - frame extents.
|
||||
* @param mode - `'cel'` or `'cl2'`.
|
||||
* @param width - frame width.
|
||||
* @param maxFramePixels - pixel-count guard.
|
||||
* @returns the frames.
|
||||
*/
|
||||
function decodeFrames(
|
||||
data: Uint8Array,
|
||||
extents: Extents,
|
||||
mode: 'cel' | 'cl2',
|
||||
uniform: number | null,
|
||||
perFrame: readonly number[] | undefined,
|
||||
maxFramePixels: number,
|
||||
): SpriteFrame[] {
|
||||
const frames: SpriteFrame[] = []
|
||||
for (const [start, end] of extents) {
|
||||
const width = perFrame === undefined ? uniform : perFrame[frames.length]
|
||||
if (width === undefined || width === null) {
|
||||
throw new CelError(`no width for frame ${String(frames.length)} (the width list has ${String(perFrame?.length ?? 0)} entries)`)
|
||||
}
|
||||
const frame = mode === 'cel'
|
||||
? decodeCelFrame(data, start, end, width, maxFramePixels)
|
||||
: decodeCl2Frame(data, start, end, width, maxFramePixels)
|
||||
if ('error' in frame) {
|
||||
throw new CelError(`frame ${String(frames.length)}: ${frame.error}`)
|
||||
}
|
||||
frames.push(frame)
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a `.cel` file's frame width by requiring every row to be consumed
|
||||
* exactly.
|
||||
*
|
||||
* CEL's runs are line-bounded, so a wrong width makes a row's runs overrun and
|
||||
* the file is rejected — the width that survives is the real one. This does not
|
||||
* extend to CL2, whose runs may cross rows: there the run stream is consumed
|
||||
* under any width, which is why {@link decodeSpriteFile} insists on being told.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param candidates - widths to try, smallest first.
|
||||
* @returns the smallest width that decodes the whole file, or null.
|
||||
*/
|
||||
export function detectSpriteWidth(
|
||||
data: Uint8Array,
|
||||
candidates: readonly number[] = spriteWidthCandidates(),
|
||||
): number | null {
|
||||
const groupsInfo = readGroups(data, 'cel')
|
||||
if ('error' in groupsInfo) return null
|
||||
for (const width of candidates) {
|
||||
if (decodesExactly(data, groupsInfo, width)) return width
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one width decodes every CEL frame of every group exactly.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param groupsInfo - the resolved group layout.
|
||||
* @param width - candidate width.
|
||||
* @returns true when the whole file is consumed with no leftovers.
|
||||
*/
|
||||
function decodesExactly(
|
||||
data: Uint8Array,
|
||||
groupsInfo: { groupBases: readonly number[]; single: boolean },
|
||||
width: number,
|
||||
): boolean {
|
||||
const guard = DEFAULT_MAX_FRAME_PIXELS
|
||||
const bases = groupsInfo.single ? [0] : celGroupBases(data)
|
||||
if (bases.length === 0) return false
|
||||
for (const base of bases) {
|
||||
const extents = groupExtents(data, base)
|
||||
if ('error' in extents) return false
|
||||
for (const [start, end] of extents) {
|
||||
const frame = decodeCelFrame(data, start, end, width, guard)
|
||||
if ('error' in frame) return false
|
||||
if (frame.height === 0) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a multi-group CEL file's concatenated group bases.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @returns the group bases.
|
||||
*/
|
||||
function celGroupBases(data: Uint8Array): number[] {
|
||||
const bases: number[] = []
|
||||
let base = Math.floor(u32(data, 0) / 4) * 4
|
||||
while (base + 8 <= data.byteLength && bases.length < 4096) {
|
||||
bases.push(base)
|
||||
const extents = groupExtents(data, base)
|
||||
if ('error' in extents) break
|
||||
const last = extents[extents.length - 1]
|
||||
/* v8 ignore next -- extents is non-empty by construction. */
|
||||
if (last === undefined) break
|
||||
if (last[1] <= base) break
|
||||
base = last[1]
|
||||
}
|
||||
return bases
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* Diablo II `.cof` animation-table decoder.
|
||||
*
|
||||
* A COF carries no pixels. It is the glue between an animation and the sprite
|
||||
* files that make it up: how many directions and frames the animation has, how
|
||||
* many composite layers the character or object is assembled from, which weapon
|
||||
* class each layer belongs to, and — the part that is easy to miss — the *draw
|
||||
* order* of those layers for every direction and frame, since an arm has to be
|
||||
* in front of the torso when the character faces one way and behind it when the
|
||||
* character faces the other.
|
||||
*
|
||||
* The layout is fixed-width and byte-aligned throughout, so unlike `.dcc` this
|
||||
* decoder is a plain cursor walk:
|
||||
*
|
||||
* - a 25-byte header: layer count, frames per direction, direction count, 21
|
||||
* bytes the original tool left as uninitialised garbage, and an animation
|
||||
* speed;
|
||||
* - three body bytes nobody has ever explained;
|
||||
* - one 9-byte record per layer;
|
||||
* - one byte per frame, the "animation frame" tag;
|
||||
* - one byte per (direction, frame, layer), the priority table.
|
||||
*
|
||||
* The priority bytes are *composite types*, not layer indices — the shipped
|
||||
* Sorceress walk lists types in an order that does not match record order, which
|
||||
* is exactly why a renderer must resolve them through the layer records instead
|
||||
* of using them directly. That resolution is `cofLayerOrder`.
|
||||
*/
|
||||
import { CelError } from './sprite.ts'
|
||||
|
||||
/** Fixed part of the file, up to and including the animation-speed byte. */
|
||||
const HEADER_SIZE = 25
|
||||
/** Byte offset of the animation speed inside the header. */
|
||||
const HEADER_SPEED = 24
|
||||
/** Unexplained bytes between the header and the first layer record. */
|
||||
const BODY_PREFIX_SIZE = 3
|
||||
/** Bytes per layer record: five scalar fields plus a four-byte weapon class. */
|
||||
const LAYER_SIZE = 9
|
||||
/** Offset of the weapon-class code inside a layer record. */
|
||||
const LAYER_WEAPON_CLASS = 5
|
||||
|
||||
/** One composite layer of an animation. */
|
||||
export interface CofLayer {
|
||||
/** Composite type this layer draws (which body part or equipment slot). */
|
||||
readonly type: number
|
||||
/** Shadow flag, as stored. */
|
||||
readonly shadow: number
|
||||
/** Whether the layer takes part in hit detection. */
|
||||
readonly selectable: boolean
|
||||
/** Whether the layer is drawn with transparency. */
|
||||
readonly transparent: boolean
|
||||
/** Draw-effect selector, as stored. */
|
||||
readonly drawEffect: number
|
||||
/**
|
||||
* Weapon-class code this layer applies to, e.g. `hth` for unarmed or `1hs` for
|
||||
* a one-handed sword, with the record's NUL padding stripped.
|
||||
*
|
||||
* Kept as the raw code rather than an enum: this port has no weapon-class
|
||||
* enumeration, and callers select layers by matching codes they already have
|
||||
* from the item tables.
|
||||
*/
|
||||
readonly weaponClass: string
|
||||
}
|
||||
|
||||
/** A decoded COF animation table. */
|
||||
export interface CofFile {
|
||||
/** Number of directions the animation has. */
|
||||
readonly numberOfDirections: number
|
||||
/** Number of frames per direction. */
|
||||
readonly framesPerDirection: number
|
||||
/** Number of composite layers per frame. */
|
||||
readonly numberOfLayers: number
|
||||
/** Animation speed byte; 0 means "default" (25 fps) to the original engine. */
|
||||
readonly speed: number
|
||||
/** Layer records, in file order. */
|
||||
readonly layers: readonly CofLayer[]
|
||||
/** One tag byte per frame. */
|
||||
readonly animationFrames: Uint8Array
|
||||
/**
|
||||
* Priority table: `priority[direction][frame]` lists the composite types to
|
||||
* draw, back to front. Resolve it with `cofLayerOrder`.
|
||||
*/
|
||||
readonly priority: readonly (readonly (readonly number[])[])[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a COF file.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @returns the animation table.
|
||||
*/
|
||||
export function decodeCof(data: Uint8Array): CofFile {
|
||||
const minimum = HEADER_SIZE + BODY_PREFIX_SIZE
|
||||
if (data.byteLength < minimum) {
|
||||
throw new CelError(`COF is ${String(data.byteLength)} bytes, too short for a ${String(minimum)}-byte header`)
|
||||
}
|
||||
|
||||
const numberOfLayers = data[0]!
|
||||
const framesPerDirection = data[1]!
|
||||
const numberOfDirections = data[2]!
|
||||
const speed = data[HEADER_SPEED]!
|
||||
|
||||
let offset = minimum
|
||||
const layers: CofLayer[] = []
|
||||
for (let i = 0; i < numberOfLayers; i += 1) {
|
||||
if (offset + LAYER_SIZE > data.byteLength) {
|
||||
throw new CelError(
|
||||
`COF layer ${String(i)} runs past the end of the ${String(data.byteLength)}-byte file`,
|
||||
)
|
||||
}
|
||||
layers.push({
|
||||
type: data[offset]!,
|
||||
shadow: data[offset + 1]!,
|
||||
selectable: data[offset + 2]! > 0,
|
||||
transparent: data[offset + 3]! > 0,
|
||||
drawEffect: data[offset + 4]!,
|
||||
weaponClass: readWeaponClass(data, offset + LAYER_WEAPON_CLASS),
|
||||
})
|
||||
offset += LAYER_SIZE
|
||||
}
|
||||
|
||||
if (offset + framesPerDirection > data.byteLength) {
|
||||
throw new CelError(
|
||||
`COF declares ${String(framesPerDirection)} frames but only ${String(data.byteLength - offset)} bytes remain`,
|
||||
)
|
||||
}
|
||||
const animationFrames = data.slice(offset, offset + framesPerDirection)
|
||||
offset += framesPerDirection
|
||||
|
||||
const priorityLength = numberOfDirections * framesPerDirection * numberOfLayers
|
||||
if (offset + priorityLength > data.byteLength) {
|
||||
throw new CelError(
|
||||
`COF priority table needs ${String(priorityLength)} bytes but only ${String(data.byteLength - offset)} remain`,
|
||||
)
|
||||
}
|
||||
|
||||
const priority: number[][][] = []
|
||||
let at = offset
|
||||
for (let direction = 0; direction < numberOfDirections; direction += 1) {
|
||||
const frames: number[][] = []
|
||||
for (let frame = 0; frame < framesPerDirection; frame += 1) {
|
||||
const row: number[] = []
|
||||
for (let layer = 0; layer < numberOfLayers; layer += 1) row.push(data[at + layer]!)
|
||||
at += numberOfLayers
|
||||
frames.push(row)
|
||||
}
|
||||
priority.push(frames)
|
||||
}
|
||||
|
||||
return {
|
||||
numberOfDirections,
|
||||
framesPerDirection,
|
||||
numberOfLayers,
|
||||
speed,
|
||||
layers,
|
||||
animationFrames,
|
||||
priority,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The draw order for one direction and frame, as indices into `cof.layers`.
|
||||
*
|
||||
* The priority table stores composite *types*, and a COF's layer records are not
|
||||
* obliged to be sorted by type — the shipped Sorceress walk is not — so the
|
||||
* types have to be resolved back to record indices before anything can be
|
||||
* drawn. Entries naming a type no layer declares are dropped rather than
|
||||
* guessed at: there is provably nothing to draw for them. If two layers share a
|
||||
* type, the first record wins, so a layer is never drawn twice.
|
||||
*
|
||||
* @param cof - the decoded animation table.
|
||||
* @param direction - direction index.
|
||||
* @param frame - frame index within the direction.
|
||||
* @returns layer indices, back to front.
|
||||
*/
|
||||
export function cofLayerOrder(cof: CofFile, direction: number, frame: number): number[] {
|
||||
const frames = cof.priority[direction]
|
||||
if (frames === undefined) {
|
||||
throw new CelError(
|
||||
`COF has no direction ${String(direction)} (it declares ${String(cof.numberOfDirections)})`,
|
||||
)
|
||||
}
|
||||
const row = frames[frame]
|
||||
if (row === undefined) {
|
||||
throw new CelError(
|
||||
`COF direction ${String(direction)} has no frame ${String(frame)} (it declares ${String(cof.framesPerDirection)})`,
|
||||
)
|
||||
}
|
||||
|
||||
const order: number[] = []
|
||||
const used = new Set<number>()
|
||||
for (const type of row) {
|
||||
let index = -1
|
||||
for (let i = 0; i < cof.layers.length; i += 1) {
|
||||
if (cof.layers[i]!.type === type) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (index < 0 || used.has(index)) continue
|
||||
used.add(index)
|
||||
order.push(index)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a layer record's four-byte weapon-class code.
|
||||
*
|
||||
* The field is NUL-padded and, in a few shipped files, space-padded as well, so
|
||||
* the NULs are dropped and the result trimmed — the same normalisation the
|
||||
* reference applies before mapping the code to its weapon-class enumeration.
|
||||
*
|
||||
* @param data - the file.
|
||||
* @param at - offset of the four-byte code.
|
||||
* @returns the code, e.g. `hth`, possibly empty.
|
||||
*/
|
||||
function readWeaponClass(data: Uint8Array, at: number): string {
|
||||
let text = ''
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const byte = data[at + i]!
|
||||
if (byte !== 0) text += String.fromCharCode(byte)
|
||||
}
|
||||
return text.trim()
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Diablo II `.dc6` sprite decoder.
|
||||
*
|
||||
* DC6 is Diablo II's replacement for Diablo I's CEL: a multi-direction,
|
||||
* multi-frame sheet of palette-indexed frames, stored as one run stream per
|
||||
* frame. Two properties are easy to get wrong and are worth stating outright,
|
||||
* because both independent reference implementations agree on them:
|
||||
*
|
||||
* - **Rows are stored bottom-up.** The first decoded scanline is the frame's
|
||||
* last row, so the stream must be written starting at `height - 1` and
|
||||
* counting down. Filling rows top-down yields a vertically mirrored sprite.
|
||||
* - **The run alphabet is three-way**, not two: `0x80` ends a scanline, any
|
||||
* other byte with the high bit set is a *transparent* run of `byte & 0x7f`
|
||||
* pixels, and a byte below `0x80` introduces that many literal pixels. A
|
||||
* decoder that treats `0x80` as "transparent run of zero" desynchronises the
|
||||
* rest of the frame.
|
||||
*
|
||||
* Index 0 is transparent by convention (the encoder leaves the palette's first
|
||||
* entry unused), which is why frames carry a mask rather than relying on a
|
||||
* sentinel index.
|
||||
*/
|
||||
import { CelError } from './sprite.ts'
|
||||
import type { SpriteFrame, SpriteGroup, SpriteSheet } from './sprite.ts'
|
||||
|
||||
/** Byte offset of the file header, and its size. */
|
||||
const FILE_HEADER_SIZE = 24
|
||||
/** Byte offset of a frame header inside its frame, and its size. */
|
||||
const FRAME_HEADER_SIZE = 32
|
||||
/** Byte count of the per-frame terminator written after the run stream. */
|
||||
const FRAME_TERMINATOR_SIZE = 3
|
||||
/** Scanline terminator in the run alphabet. */
|
||||
const END_OF_SCANLINE = 0x80
|
||||
/** Mask extracting a transparent run's length. */
|
||||
const RUN_LENGTH_MASK = 0x7f
|
||||
|
||||
/** The DC6 file header. */
|
||||
export interface Dc6Header {
|
||||
/** Format version (6 for the shipped format). */
|
||||
readonly version: number
|
||||
/** Flag word (`1` serialised, `4` 24-bit). */
|
||||
readonly flags: number
|
||||
/** Encoding word as stored. */
|
||||
readonly encoding: number
|
||||
/** Number of directions. */
|
||||
readonly directions: number
|
||||
/** Frames per direction. */
|
||||
readonly framesPerDirection: number
|
||||
}
|
||||
|
||||
/** One decoded frame plus the fields the renderer needs for placement. */
|
||||
export interface Dc6Frame extends SpriteFrame {
|
||||
/** Frame anchor x, as stored. */
|
||||
readonly offsetX: number
|
||||
/** Frame anchor y, as stored. */
|
||||
readonly offsetY: number
|
||||
/** The frame's own serial number in the sheet. */
|
||||
readonly index: number
|
||||
}
|
||||
|
||||
/** A decoded DC6 sheet, grouped by direction. */
|
||||
export interface Dc6Sheet extends SpriteSheet {
|
||||
/** The file header. */
|
||||
readonly header: Dc6Header
|
||||
/** Frames grouped by direction, each carrying its own size. */
|
||||
readonly groups: readonly { readonly frames: readonly Dc6Frame[] }[]
|
||||
}
|
||||
|
||||
/** Guard against a corrupt header demanding a huge allocation. */
|
||||
const MAX_FRAMES = 4096
|
||||
/** Guard on one frame's pixel count. */
|
||||
const MAX_FRAME_PIXELS = 1 << 24
|
||||
|
||||
/**
|
||||
* Read a little-endian uint32.
|
||||
*
|
||||
* @param data - the buffer.
|
||||
* @param at - byte offset.
|
||||
* @returns the value.
|
||||
*/
|
||||
function u32(data: Uint8Array, at: number): number {
|
||||
return (data[at]! | (data[at + 1]! << 8) | (data[at + 2]! << 16) | (data[at + 3]! << 24)) >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a little-endian int32.
|
||||
*
|
||||
* @param data - the buffer.
|
||||
* @param at - byte offset.
|
||||
* @returns the value.
|
||||
*/
|
||||
function i32(data: Uint8Array, at: number): number {
|
||||
return (u32(data, at) | 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a DC6 file.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @returns the decoded sheet.
|
||||
*/
|
||||
export function decodeDc6(data: Uint8Array): Dc6Sheet {
|
||||
if (data.byteLength < FILE_HEADER_SIZE) {
|
||||
throw new CelError(`DC6 is ${String(data.byteLength)} bytes, too short for a header`)
|
||||
}
|
||||
const header: Dc6Header = {
|
||||
version: i32(data, 0x00),
|
||||
flags: u32(data, 0x04),
|
||||
encoding: u32(data, 0x08),
|
||||
directions: i32(data, 0x10),
|
||||
framesPerDirection: i32(data, 0x14),
|
||||
}
|
||||
if (header.directions <= 0 || header.framesPerDirection <= 0) {
|
||||
throw new CelError(`DC6 declares ${String(header.directions)} directions and ${String(header.framesPerDirection)} frames per direction`)
|
||||
}
|
||||
const total = header.directions * header.framesPerDirection
|
||||
if (total > MAX_FRAMES) throw new CelError(`DC6 declares ${String(total)} frames, above the ${String(MAX_FRAMES)} guard`)
|
||||
|
||||
const pointers = new Array<number>(total)
|
||||
for (let index = 0; index < total; index += 1) {
|
||||
pointers[index] = u32(data, FILE_HEADER_SIZE + index * 4)
|
||||
}
|
||||
|
||||
const groups: { frames: Dc6Frame[] }[] = []
|
||||
for (let direction = 0; direction < header.directions; direction += 1) {
|
||||
const frames: Dc6Frame[] = []
|
||||
for (let frameIndex = 0; frameIndex < header.framesPerDirection; frameIndex += 1) {
|
||||
const index = direction * header.framesPerDirection + frameIndex
|
||||
// The last frame has no successor pointer: it runs to the end of file.
|
||||
const start = pointers[index]!
|
||||
const end = index + 1 < total ? pointers[index + 1]! : data.byteLength
|
||||
if (start < FILE_HEADER_SIZE || end > data.byteLength || end < start) {
|
||||
throw new CelError(`frame ${String(index)} extent ${String(start)}..${String(end)} is out of range`)
|
||||
}
|
||||
frames.push(decodeFrame(data, start, end, index))
|
||||
}
|
||||
groups.push({ frames })
|
||||
}
|
||||
|
||||
return { header, groups, width: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one frame.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param start - frame start offset.
|
||||
* @param end - frame end offset (exclusive).
|
||||
* @param index - the frame's serial number.
|
||||
* @returns the decoded frame.
|
||||
*/
|
||||
function decodeFrame(data: Uint8Array, start: number, end: number, index: number): Dc6Frame {
|
||||
if (start + FRAME_HEADER_SIZE > end) {
|
||||
throw new CelError(`frame ${String(index)} is too short for a frame header`)
|
||||
}
|
||||
const width = i32(data, start + 0x04)
|
||||
const height = i32(data, start + 0x08)
|
||||
const offsetX = i32(data, start + 0x0c)
|
||||
const offsetY = i32(data, start + 0x10)
|
||||
const length = u32(data, start + 0x1c)
|
||||
if (width < 0 || height < 0 || width * height > MAX_FRAME_PIXELS) {
|
||||
throw new CelError(`frame ${String(index)} has an implausible size ${String(width)}x${String(height)}`)
|
||||
}
|
||||
const indices = new Uint8Array(width * height)
|
||||
const mask = new Uint8Array(width * height)
|
||||
const streamEnd = Math.min(end, start + FRAME_HEADER_SIZE + length)
|
||||
let cursor = start + FRAME_HEADER_SIZE
|
||||
// Rows land bottom-up: the first scanline written is the frame's last row.
|
||||
let x = 0
|
||||
let y = height - 1
|
||||
let complete = false
|
||||
while (cursor < streamEnd && y >= 0) {
|
||||
const control = data[cursor]!
|
||||
cursor += 1
|
||||
if (control === END_OF_SCANLINE) {
|
||||
if (y === 0) { complete = true; break }
|
||||
y -= 1
|
||||
x = 0
|
||||
} else if ((control & END_OF_SCANLINE) !== 0) {
|
||||
x += control & RUN_LENGTH_MASK
|
||||
} else {
|
||||
if (cursor + control > streamEnd) {
|
||||
throw new CelError(`frame ${String(index)} literal run of ${String(control)} is truncated`)
|
||||
}
|
||||
const rowStart = y * width
|
||||
for (let i = 0; i < control; i += 1) {
|
||||
const at = rowStart + x + i
|
||||
// A run may nominally overrun a short row (padding in the last
|
||||
// scanline); drop those pixels rather than creeping into the next row.
|
||||
if (x + i >= width) break
|
||||
const value = data[cursor + i]!
|
||||
indices[at] = value
|
||||
// Index 0 is the transparent entry by convention.
|
||||
mask[at] = value === 0 ? 0 : 1
|
||||
}
|
||||
cursor += control
|
||||
x += control
|
||||
}
|
||||
}
|
||||
if (!complete) {
|
||||
// The reference decoders stop at the final end-of-scanline; a stream that
|
||||
// ends without one is still usable, so this is reported by leaving the
|
||||
// remaining rows transparent rather than failing the whole sheet.
|
||||
// (Frames with height 1 legitimately end at `y === 0` handled above.)
|
||||
}
|
||||
return { width, height, indices, mask, offsetX, offsetY, index }
|
||||
}
|
||||
|
||||
/** A decoded frame with its placement fields, as the atlas packer wants it. */
|
||||
export type { SpriteGroup }
|
||||
|
|
@ -0,0 +1,820 @@
|
|||
/**
|
||||
* Diablo II `.dcc` sprite decoder.
|
||||
*
|
||||
* A DCC is a *cell-compressed* sprite sheet: instead of storing each frame as a
|
||||
* run stream, the format quantises the art to a 4x4 grid and stores, per frame,
|
||||
* a list of pixel codes per cell plus a flag telling the decoder when a cell is
|
||||
* identical to the same slot in an earlier frame. Decoding therefore happens in
|
||||
* three passes, and the order matters:
|
||||
*
|
||||
* 1. A direction begins with a header declaring the *bit widths* of the
|
||||
* per-frame header fields (through a small lookup table, because the format's
|
||||
* authors only needed a dozen widths), then the frame headers, then the table
|
||||
* describing the direction's padded canvas.
|
||||
* 2. The payload is four independent bit streams layered over each other —
|
||||
* equal-cell flags, pixel mask, encoding type, raw pixel codes — followed by
|
||||
* the pixel-code/displacement stream. Their starts are *bit* offsets, not
|
||||
* byte offsets, so they are read through clones of a single reader whose bit
|
||||
* position inside a byte is preserved exactly.
|
||||
* 3. Cells are decoded once per (frame, cell) position into a shared pixel
|
||||
* buffer, then blitted; frames that reuse a cell copy the previous frame's
|
||||
* pixels instead of carrying their own codes.
|
||||
*
|
||||
* Two conventions this module keeps deliberately:
|
||||
*
|
||||
* - A frame's `frame.width`/`frame.height` is the *direction's* canvas, not the
|
||||
* frame's own art rectangle. The direction box is the bounding box of every
|
||||
* frame in that direction, so all frames of a direction share dimensions and
|
||||
* compositing is a straight overlay. The frame's own rectangle is exposed as
|
||||
* `width`/`height`/`offsetX`/`offsetY`.
|
||||
* - Palette index 0 is transparent, exactly as in `.dc6`; `mask` records which
|
||||
* pixels are opaque so a renderer never has to test a sentinel index.
|
||||
*
|
||||
* Frames flagged bottom-up are decoded and then flipped vertically — see
|
||||
* `flipFrameRows`, which documents the one place the reference gives no answer
|
||||
* because it panics instead of implementing the case.
|
||||
*/
|
||||
import { BitReader } from './bitstream.ts'
|
||||
import { CelError } from './sprite.ts'
|
||||
import type { SpriteFrame } from './sprite.ts'
|
||||
|
||||
/** Every DCC starts with this byte; anything else is mislabelled. */
|
||||
const SIGNATURE = 0x74
|
||||
/** Direction offsets are stored in bytes but used as bit offsets. */
|
||||
const DIRECTION_OFFSET_SCALE = 8
|
||||
/** The pixel grid is quantised to 4x4 cells; no cell is larger than 4 pixels. */
|
||||
const CELL_SIZE = 4
|
||||
/**
|
||||
* Bit widths are not stored literally: the direction header holds a 4-bit index
|
||||
* into this table. It tops out at 32, which bounds every header field.
|
||||
*/
|
||||
const BIT_WIDTH_TABLE = [0, 1, 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 26, 28, 30, 32]
|
||||
/**
|
||||
* Number of set bits in each 4-bit pixel mask, i.e. how many pixel codes the
|
||||
* cell stores. A mask of 0 means every code was inherited from an earlier frame.
|
||||
*/
|
||||
const PIXEL_MASK_POPCOUNT = [0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4]
|
||||
/** Guard on the declared direction count. */
|
||||
const MAX_DIRECTIONS = 256
|
||||
/** Guard on the declared frames per direction. */
|
||||
const MAX_FRAMES_PER_DIRECTION = 4096
|
||||
/** Guard on one direction canvas, in pixels. */
|
||||
const MAX_PIXELS = 1 << 24
|
||||
|
||||
/** The padded canvas coordinates of a direction or of one frame. */
|
||||
export interface DccBox {
|
||||
/** Left edge, in sprite space. */
|
||||
readonly left: number
|
||||
/** Top edge, in sprite space. */
|
||||
readonly top: number
|
||||
/** Width in pixels. */
|
||||
readonly width: number
|
||||
/** Height in pixels. */
|
||||
readonly height: number
|
||||
}
|
||||
|
||||
/** One decoded frame: its own art rectangle plus the direction-sized canvas. */
|
||||
export interface DccFrame {
|
||||
/** The frame's own width, as stored in the header. */
|
||||
readonly width: number
|
||||
/** The frame's own height, as stored in the header. */
|
||||
readonly height: number
|
||||
/** Anchor x of the frame inside the direction canvas. */
|
||||
readonly offsetX: number
|
||||
/** Anchor y of the frame inside the direction canvas. */
|
||||
readonly offsetY: number
|
||||
/** The decoded canvas; its dimensions are the direction's box size. */
|
||||
readonly frame: SpriteFrame
|
||||
/**
|
||||
* Whether the file stored this frame's rows bottom-up, so they were flipped.
|
||||
*
|
||||
* Exposed because the reference implementation refuses this case outright: the
|
||||
* only way to measure how much of a data set depends on the flip judgement is
|
||||
* to count the frames that took that path after decoding.
|
||||
*/
|
||||
readonly bottomUp: boolean
|
||||
}
|
||||
|
||||
/** Every frame of one facing direction, drawn on a shared canvas. */
|
||||
export interface DccDirection {
|
||||
/** Bounding box of all frames in this direction. */
|
||||
readonly box: DccBox
|
||||
/** Frames in animation order. */
|
||||
readonly frames: readonly DccFrame[]
|
||||
}
|
||||
|
||||
/** A decoded DCC file. */
|
||||
export interface DccFile {
|
||||
/** Format version byte. */
|
||||
readonly version: number
|
||||
/** Directions, in file order. */
|
||||
readonly directions: readonly DccDirection[]
|
||||
}
|
||||
|
||||
/** A cell rectangle in direction-canvas coordinates. */
|
||||
interface CellRect {
|
||||
width: number
|
||||
height: number
|
||||
xOffset: number
|
||||
yOffset: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A cell of the direction grid, which additionally remembers the geometry of the
|
||||
* last frame cell that occupied it — the memory that makes equal-cell copying
|
||||
* possible.
|
||||
*/
|
||||
interface BufferCell extends CellRect {
|
||||
lastWidth: number
|
||||
lastHeight: number
|
||||
lastXOffset: number
|
||||
lastYOffset: number
|
||||
}
|
||||
|
||||
/** One frame's header fields and its own cell decomposition. */
|
||||
interface FrameState {
|
||||
box: DccBox
|
||||
width: number
|
||||
height: number
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
bottomUp: boolean
|
||||
horizontalCellCount: number
|
||||
verticalCellCount: number
|
||||
cells: CellRect[]
|
||||
}
|
||||
|
||||
/** One entry of the shared pixel buffer: four palette codes plus its owner. */
|
||||
interface PixelBufferEntry {
|
||||
value: [number, number, number, number]
|
||||
frame: number
|
||||
cellIndex: number
|
||||
}
|
||||
|
||||
/** The per-direction decode state, threaded through the three passes. */
|
||||
interface DirectionState {
|
||||
box: DccBox
|
||||
frames: FrameState[]
|
||||
cells: BufferCell[]
|
||||
horizontalCellCount: number
|
||||
verticalCellCount: number
|
||||
equalCellsBits: number
|
||||
pixelMaskBits: number
|
||||
encodingTypeBits: number
|
||||
rawPixelBits: number
|
||||
}
|
||||
|
||||
/** The seven per-frame header field widths a direction declares. */
|
||||
interface FieldWidths {
|
||||
variable0: number
|
||||
width: number
|
||||
height: number
|
||||
xOffset: number
|
||||
yOffset: number
|
||||
optionalData: number
|
||||
codedBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a DCC file.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @returns the decoded directions.
|
||||
*/
|
||||
export function decodeDcc(data: Uint8Array): DccFile {
|
||||
const reader = new BitReader(data, 0)
|
||||
const signature = reader.getByte()
|
||||
if (signature !== SIGNATURE) {
|
||||
throw new CelError(
|
||||
`DCC signature 0x${signature.toString(16)} is not 0x${SIGNATURE.toString(16)} (mislabelled file?)`,
|
||||
)
|
||||
}
|
||||
const version = reader.getByte()
|
||||
const directionCount = reader.getByte()
|
||||
const framesPerDirection = reader.getInt32()
|
||||
const serialised = reader.getInt32()
|
||||
if (serialised !== 1) {
|
||||
throw new CelError(`DCC header word 2 is ${String(serialised)}, the format requires 1`)
|
||||
}
|
||||
reader.getUint32() // total size coded: the per-direction offsets are authoritative
|
||||
|
||||
if (directionCount < 1 || directionCount > MAX_DIRECTIONS) {
|
||||
throw new CelError(`DCC declares ${String(directionCount)} directions (allowed 1..${String(MAX_DIRECTIONS)})`)
|
||||
}
|
||||
if (framesPerDirection < 1 || framesPerDirection > MAX_FRAMES_PER_DIRECTION) {
|
||||
throw new CelError(
|
||||
`DCC declares ${String(framesPerDirection)} frames per direction (allowed 1..${String(MAX_FRAMES_PER_DIRECTION)})`,
|
||||
)
|
||||
}
|
||||
|
||||
const offsets: number[] = []
|
||||
for (let i = 0; i < directionCount; i += 1) {
|
||||
const byteOffset = reader.getInt32()
|
||||
if (byteOffset < 0 || byteOffset * DIRECTION_OFFSET_SCALE >= data.length * 8) {
|
||||
throw new CelError(`DCC direction ${String(i)} offset ${String(byteOffset)} is outside the file`)
|
||||
}
|
||||
offsets.push(byteOffset * DIRECTION_OFFSET_SCALE)
|
||||
}
|
||||
|
||||
const directions: DccDirection[] = []
|
||||
for (let i = 0; i < directionCount; i += 1) {
|
||||
directions.push(decodeDirection(data, offsets[i]!, framesPerDirection, i))
|
||||
}
|
||||
|
||||
return { version, directions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one direction, translating any raw reader failure into an error that
|
||||
* names the direction.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param bitOffset - the direction's start, in bits.
|
||||
* @param framesPerDirection - the file-level frame count.
|
||||
* @param index - the direction index.
|
||||
* @returns the decoded direction.
|
||||
*/
|
||||
function decodeDirection(
|
||||
data: Uint8Array,
|
||||
bitOffset: number,
|
||||
framesPerDirection: number,
|
||||
index: number,
|
||||
): DccDirection {
|
||||
const where = `DCC direction ${String(index)}`
|
||||
try {
|
||||
return decodeDirectionInner(data, bitOffset, framesPerDirection, where)
|
||||
} catch (err) {
|
||||
throw err instanceof CelError ? err : new CelError(`${where}: ${messageOf(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The direction decode proper: headers, then payload, then cell and frame
|
||||
* assembly, in the order the format requires.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param bitOffset - the direction's start, in bits.
|
||||
* @param framesPerDirection - the file-level frame count.
|
||||
* @param where - error-message prefix.
|
||||
* @returns the decoded direction.
|
||||
*/
|
||||
function decodeDirectionInner(
|
||||
data: Uint8Array,
|
||||
bitOffset: number,
|
||||
framesPerDirection: number,
|
||||
where: string,
|
||||
): DccDirection {
|
||||
const reader = new BitReader(data, bitOffset)
|
||||
reader.getUint32() // out size coded, unused
|
||||
const compressionFlags = reader.getBits(2)
|
||||
const widths: FieldWidths = {
|
||||
variable0: BIT_WIDTH_TABLE[reader.getBits(4)]!,
|
||||
width: BIT_WIDTH_TABLE[reader.getBits(4)]!,
|
||||
height: BIT_WIDTH_TABLE[reader.getBits(4)]!,
|
||||
xOffset: BIT_WIDTH_TABLE[reader.getBits(4)]!,
|
||||
yOffset: BIT_WIDTH_TABLE[reader.getBits(4)]!,
|
||||
optionalData: BIT_WIDTH_TABLE[reader.getBits(4)]!,
|
||||
codedBytes: BIT_WIDTH_TABLE[reader.getBits(4)]!,
|
||||
}
|
||||
|
||||
const frames: FrameState[] = []
|
||||
let minX = Number.MAX_SAFE_INTEGER
|
||||
let minY = Number.MAX_SAFE_INTEGER
|
||||
let maxX = -Number.MAX_SAFE_INTEGER
|
||||
let maxY = -Number.MAX_SAFE_INTEGER
|
||||
for (let i = 0; i < framesPerDirection; i += 1) {
|
||||
const frame = readFrameHeader(reader, widths, i, where)
|
||||
minX = Math.min(minX, frame.box.left)
|
||||
minY = Math.min(minY, frame.box.top)
|
||||
maxX = Math.max(maxX, frame.box.left + frame.box.width)
|
||||
maxY = Math.max(maxY, frame.box.top + frame.box.height)
|
||||
frames.push(frame)
|
||||
}
|
||||
const box: DccBox = { left: minX, top: minY, width: maxX - minX, height: maxY - minY }
|
||||
if (box.width <= 0 || box.height <= 0 || box.width * box.height > MAX_PIXELS) {
|
||||
throw new CelError(`${where}: canvas ${String(box.width)}x${String(box.height)} is implausible`)
|
||||
}
|
||||
|
||||
if (widths.optionalData > 0) {
|
||||
throw new CelError(
|
||||
`${where}: frames declare ${String(widths.optionalData)} bits of optional data, which this decoder does not implement`,
|
||||
)
|
||||
}
|
||||
|
||||
// The declared sizes are *bit* counts. Each stream is walked through a clone
|
||||
// of the reader while the original skips past it, so a stream that starts
|
||||
// mid-byte keeps its bit offset — rounding here would shift every code.
|
||||
const equalCellsBits = (compressionFlags & 0x2) !== 0 ? reader.getBits(20) : 0
|
||||
const pixelMaskBits = reader.getBits(20)
|
||||
const encodingTypeBits = (compressionFlags & 0x1) !== 0 ? reader.getBits(20) : 0
|
||||
const rawPixelBits = (compressionFlags & 0x1) !== 0 ? reader.getBits(20) : 0
|
||||
|
||||
// The palette table is one validity bit per entry; the valid entries are
|
||||
// remapped onto a dense set of codes that the pixel stream then indexes.
|
||||
const palette = new Uint8Array(256)
|
||||
let paletteCount = 0
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
if (reader.getBit() !== 0) {
|
||||
palette[paletteCount] = i
|
||||
paletteCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
const equalCells = reader.copy()
|
||||
reader.skipBits(equalCellsBits)
|
||||
const pixelMask = reader.copy()
|
||||
reader.skipBits(pixelMaskBits)
|
||||
const encodingType = reader.copy()
|
||||
reader.skipBits(encodingTypeBits)
|
||||
const rawPixelCodes = reader.copy()
|
||||
reader.skipBits(rawPixelBits)
|
||||
const pixelCodes = reader.copy()
|
||||
|
||||
const state: DirectionState = {
|
||||
box,
|
||||
frames,
|
||||
cells: [],
|
||||
horizontalCellCount: 0,
|
||||
verticalCellCount: 0,
|
||||
equalCellsBits,
|
||||
pixelMaskBits,
|
||||
encodingTypeBits,
|
||||
rawPixelBits,
|
||||
}
|
||||
|
||||
calculateCells(state)
|
||||
for (const frame of frames) recalculateCells(state, frame)
|
||||
|
||||
const buffer = fillPixelBuffer(
|
||||
state,
|
||||
palette,
|
||||
pixelCodes,
|
||||
equalCells,
|
||||
pixelMask,
|
||||
encodingType,
|
||||
rawPixelCodes,
|
||||
)
|
||||
const canvases = generateFrames(state, buffer, pixelCodes)
|
||||
|
||||
verifyStream(where, 'equal-cells', equalCells, equalCellsBits)
|
||||
verifyStream(where, 'pixel-mask', pixelMask, pixelMaskBits)
|
||||
verifyStream(where, 'encoding-type', encodingType, encodingTypeBits)
|
||||
verifyStream(where, 'raw-pixel-codes', rawPixelCodes, rawPixelBits)
|
||||
|
||||
const decoded: DccFrame[] = []
|
||||
for (let i = 0; i < frames.length; i += 1) {
|
||||
const frame = frames[i]!
|
||||
const pixels = canvases[i]!
|
||||
if (frame.bottomUp) flipFrameRows(pixels, box, frame.box)
|
||||
decoded.push({
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
offsetX: frame.offsetX,
|
||||
offsetY: frame.offsetY,
|
||||
frame: toSpriteFrame(pixels, box.width, box.height),
|
||||
bottomUp: frame.bottomUp,
|
||||
})
|
||||
}
|
||||
|
||||
return { box, frames: decoded }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one frame header.
|
||||
*
|
||||
* The box is derived from the anchor: a frame is placed top-down relative to its
|
||||
* `yOffset`, so its top edge sits `height - 1` rows above it. Bottom-up frames
|
||||
* get the same box — the flag describes storage order, not placement, and the
|
||||
* reference computes no alternative — and are flipped after decoding instead.
|
||||
*
|
||||
* @param reader - the direction reader, positioned at the frame header.
|
||||
* @param widths - the direction's declared field widths.
|
||||
* @param index - the frame index.
|
||||
* @param where - error-message prefix.
|
||||
* @returns the frame header fields.
|
||||
*/
|
||||
function readFrameHeader(reader: BitReader, widths: FieldWidths, index: number, where: string): FrameState {
|
||||
const at = `${where} frame ${String(index)}`
|
||||
reader.getBits(widths.variable0) // per-frame scratch value, never used
|
||||
const width = reader.getBits(widths.width)
|
||||
const height = reader.getBits(widths.height)
|
||||
const offsetX = reader.getSignedBits(widths.xOffset)
|
||||
const offsetY = reader.getSignedBits(widths.yOffset)
|
||||
reader.getBits(widths.optionalData) // must be zero-length; enforced after the frame headers
|
||||
reader.getBits(widths.codedBytes) // coded byte count, only meaningful to the encoder
|
||||
const bottomUp = reader.getBit() === 1
|
||||
|
||||
if (width * height > MAX_PIXELS) {
|
||||
throw new CelError(`${at}: implausible size ${String(width)}x${String(height)}`)
|
||||
}
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new CelError(`${at}: empty frame ${String(width)}x${String(height)}`)
|
||||
}
|
||||
|
||||
return {
|
||||
box: { left: offsetX, top: offsetY - height + 1, width, height },
|
||||
width,
|
||||
height,
|
||||
offsetX,
|
||||
offsetY,
|
||||
bottomUp,
|
||||
horizontalCellCount: 0,
|
||||
verticalCellCount: 0,
|
||||
cells: [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompose the direction canvas into the 4x4-aligned cell grid that every frame
|
||||
* cell is addressed against.
|
||||
*
|
||||
* @param state - the direction state, updated in place.
|
||||
*/
|
||||
function calculateCells(state: DirectionState): void {
|
||||
const { box } = state
|
||||
const horizontal = 1 + Math.trunc((box.width - 1) / CELL_SIZE)
|
||||
const vertical = 1 + Math.trunc((box.height - 1) / CELL_SIZE)
|
||||
state.horizontalCellCount = horizontal
|
||||
state.verticalCellCount = vertical
|
||||
|
||||
const cellWidths = new Array<number>(horizontal).fill(CELL_SIZE)
|
||||
cellWidths[horizontal - 1] = box.width - CELL_SIZE * (horizontal - 1)
|
||||
const cellHeights = new Array<number>(vertical).fill(CELL_SIZE)
|
||||
cellHeights[vertical - 1] = box.height - CELL_SIZE * (vertical - 1)
|
||||
|
||||
const cells: BufferCell[] = []
|
||||
for (let y = 0; y < vertical; y += 1) {
|
||||
for (let x = 0; x < horizontal; x += 1) {
|
||||
cells.push({
|
||||
width: cellWidths[x]!,
|
||||
height: cellHeights[y]!,
|
||||
xOffset: x * CELL_SIZE,
|
||||
yOffset: y * CELL_SIZE,
|
||||
lastWidth: -1,
|
||||
lastHeight: -1,
|
||||
lastXOffset: 0,
|
||||
lastYOffset: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
state.cells = cells
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompose one frame into cells aligned to the *direction's* grid.
|
||||
*
|
||||
* A frame's art rectangle generally does not start on the direction's 4-pixel
|
||||
* grid, so the first column and row are truncated to whatever remains of the
|
||||
* cell they begin inside. That is what lets one pixel-buffer slot index serve
|
||||
* every frame in the direction.
|
||||
*
|
||||
* @param state - the direction state.
|
||||
* @param frame - the frame to decompose.
|
||||
*/
|
||||
function recalculateCells(state: DirectionState, frame: FrameState): void {
|
||||
const { box } = state
|
||||
const firstWidth = CELL_SIZE - ((frame.box.left - box.left) % CELL_SIZE)
|
||||
const firstHeight = CELL_SIZE - ((frame.box.top - box.top) % CELL_SIZE)
|
||||
|
||||
const horizontal = cellSpanCount(frame.width, firstWidth)
|
||||
const vertical = cellSpanCount(frame.height, firstHeight)
|
||||
frame.horizontalCellCount = horizontal
|
||||
frame.verticalCellCount = vertical
|
||||
|
||||
const cellWidths = new Array<number>(horizontal).fill(CELL_SIZE)
|
||||
if (horizontal === 1) {
|
||||
cellWidths[0] = frame.width
|
||||
} else {
|
||||
cellWidths[0] = firstWidth
|
||||
cellWidths[horizontal - 1] = frame.width - firstWidth - CELL_SIZE * (horizontal - 2)
|
||||
}
|
||||
const cellHeights = new Array<number>(vertical).fill(CELL_SIZE)
|
||||
if (vertical === 1) {
|
||||
cellHeights[0] = frame.height
|
||||
} else {
|
||||
cellHeights[0] = firstHeight
|
||||
cellHeights[vertical - 1] = frame.height - firstHeight - CELL_SIZE * (vertical - 2)
|
||||
}
|
||||
|
||||
const cells: CellRect[] = []
|
||||
let yOffset = frame.box.top - box.top
|
||||
for (let y = 0; y < vertical; y += 1) {
|
||||
let xOffset = frame.box.left - box.left
|
||||
for (let x = 0; x < horizontal; x += 1) {
|
||||
cells.push({ width: cellWidths[x]!, height: cellHeights[y]!, xOffset, yOffset })
|
||||
xOffset += cellWidths[x]!
|
||||
}
|
||||
yOffset += cellHeights[y]!
|
||||
}
|
||||
frame.cells = cells
|
||||
}
|
||||
|
||||
/**
|
||||
* How many cells a span of `total` pixels needs when its first cell is already
|
||||
* `first` pixels wide.
|
||||
*
|
||||
* @param total - the span length.
|
||||
* @param first - the truncated first cell's length.
|
||||
* @returns the cell count.
|
||||
*/
|
||||
function cellSpanCount(total: number, first: number): number {
|
||||
if (total - first <= 1) return 1
|
||||
const remaining = total - first - 1
|
||||
return 2 + Math.trunc(remaining / CELL_SIZE) - (remaining % CELL_SIZE === 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the shared pixel buffer: one entry per (frame, cell) that carries pixel
|
||||
* codes, in stream order.
|
||||
*
|
||||
* Four bit streams drive this pass. For every cell the decoder reads an
|
||||
* equal-cell flag (only when the slot has been touched before), then a 4-bit
|
||||
* mask saying which of the cell's four codes are supplied, then either a raw
|
||||
* 8-bit code or a 4-bit displacement from the previous code, run-length extended
|
||||
* by 15s. A cell flagged equal contributes no entry at all: the frame generator
|
||||
* copies the previous occupant of that grid slot instead.
|
||||
*
|
||||
* @param state - the direction state.
|
||||
* @param palette - the direction's code → palette index table.
|
||||
* @param codes - the pixel-code/displacement stream.
|
||||
* @param equalCells - the equal-cell flag stream.
|
||||
* @param pixelMask - the per-cell mask stream.
|
||||
* @param encodingType - the per-cell encoding selector stream.
|
||||
* @param rawPixelCodes - the raw 8-bit pixel stream.
|
||||
* @returns the buffer, dense from index 0.
|
||||
*/
|
||||
function fillPixelBuffer(
|
||||
state: DirectionState,
|
||||
palette: Uint8Array,
|
||||
codes: BitReader,
|
||||
equalCells: BitReader,
|
||||
pixelMask: BitReader,
|
||||
encodingType: BitReader,
|
||||
rawPixelCodes: BitReader,
|
||||
): PixelBufferEntry[] {
|
||||
let maxCellX = 0
|
||||
let maxCellY = 0
|
||||
for (const frame of state.frames) {
|
||||
maxCellX += frame.horizontalCellCount
|
||||
maxCellY += frame.verticalCellCount
|
||||
}
|
||||
const buffer: PixelBufferEntry[] = new Array<PixelBufferEntry>(maxCellX * maxCellY)
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
buffer[i] = { value: [0, 0, 0, 0], frame: -1, cellIndex: -1 }
|
||||
}
|
||||
const slots = new Array<PixelBufferEntry | null>(state.horizontalCellCount * state.verticalCellCount)
|
||||
slots.fill(null)
|
||||
|
||||
let entryIndex = -1
|
||||
for (let frameIndex = 0; frameIndex < state.frames.length; frameIndex += 1) {
|
||||
const frame = state.frames[frameIndex]!
|
||||
const originCellX = Math.trunc((frame.box.left - state.box.left) / CELL_SIZE)
|
||||
const originCellY = Math.trunc((frame.box.top - state.box.top) / CELL_SIZE)
|
||||
|
||||
for (let cellY = 0; cellY < frame.verticalCellCount; cellY += 1) {
|
||||
const gridY = cellY + originCellY
|
||||
for (let cellX = 0; cellX < frame.horizontalCellCount; cellX += 1) {
|
||||
const gridCell = originCellX + cellX + gridY * state.horizontalCellCount
|
||||
const previous = slots[gridCell]!
|
||||
let mask: number
|
||||
if (previous === null) {
|
||||
mask = 0xf // nothing to inherit from, so every code is supplied
|
||||
} else {
|
||||
if (state.equalCellsBits > 0 && equalCells.getBit() !== 0) continue // repeats: no entry
|
||||
mask = pixelMask.getBits(4)
|
||||
}
|
||||
|
||||
let lastPixel = 0
|
||||
const pixelStack: [number, number, number, number] = [0, 0, 0, 0]
|
||||
const pixelCount = PIXEL_MASK_POPCOUNT[mask]!
|
||||
const encoded = pixelCount !== 0 && state.encodingTypeBits > 0 ? encodingType.getBit() : 0
|
||||
let decoded = 0
|
||||
for (let i = 0; i < pixelCount; i += 1) {
|
||||
let value: number
|
||||
if (encoded !== 0) {
|
||||
value = rawPixelCodes.getBits(8)
|
||||
} else {
|
||||
let displacement = codes.getBits(4)
|
||||
value = lastPixel + displacement
|
||||
while (displacement === 15) {
|
||||
displacement = codes.getBits(4)
|
||||
value += displacement
|
||||
}
|
||||
}
|
||||
if (value === lastPixel) {
|
||||
pixelStack[i] = 0 // zero displacement means "nothing here", ending the run
|
||||
break
|
||||
}
|
||||
pixelStack[i] = value
|
||||
lastPixel = value
|
||||
decoded += 1
|
||||
}
|
||||
|
||||
entryIndex += 1
|
||||
const entry = buffer[entryIndex]
|
||||
if (entry === undefined) {
|
||||
throw new CelError(
|
||||
`pixel buffer overflow at grid cell ${String(gridCell)} (capacity ${String(buffer.length)})`,
|
||||
)
|
||||
}
|
||||
// Codes are stored reversed: the last decoded code belongs to the cell's
|
||||
// first pixel, and the mask's lowest set bit picks the next in line.
|
||||
let code = decoded - 1
|
||||
for (let i = 0; i < CELL_SIZE; i += 1) {
|
||||
if ((mask & (1 << i)) !== 0) {
|
||||
entry.value[i] = code >= 0 ? pixelStack[code]! & 0xff : 0
|
||||
code -= 1
|
||||
} else {
|
||||
entry.value[i] = previous === null ? 0 : previous.value[i]!
|
||||
}
|
||||
}
|
||||
slots[gridCell] = entry
|
||||
entry.frame = frameIndex
|
||||
entry.cellIndex = cellX + cellY * frame.horizontalCellCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Buffer values are indices into the direction's dense palette table, not
|
||||
// palette entries; resolve them once so the frame pass can use them directly.
|
||||
for (let i = 0; i <= entryIndex; i += 1) {
|
||||
const entry = buffer[i]!
|
||||
for (let x = 0; x < CELL_SIZE; x += 1) entry.value[x] = palette[entry.value[x]!]!
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Blit the pixel buffer into one canvas per frame.
|
||||
*
|
||||
* Each (frame, cell) either consumes the next buffer entry — writing it into a
|
||||
* shared scratch canvas, then copying it into that frame's canvas — or, when the
|
||||
* next entry belongs to another frame, reproduces the previous occupant of the
|
||||
* slot: by copying its pixels when the geometry matches, or by leaving the slot
|
||||
* transparent when it does not, since the old pixels would not fit.
|
||||
*
|
||||
* @param state - the direction state.
|
||||
* @param buffer - the pixel buffer from `fillPixelBuffer`.
|
||||
* @param codes - the pixel-code stream, still positioned after the buffer pass.
|
||||
* @returns one canvas per frame, direction-box sized.
|
||||
*/
|
||||
function generateFrames(state: DirectionState, buffer: PixelBufferEntry[], codes: BitReader): Uint8Array[] {
|
||||
const stride = state.box.width
|
||||
const scratch = new Uint8Array(stride * state.box.height)
|
||||
for (const cell of state.cells) {
|
||||
cell.lastWidth = -1
|
||||
cell.lastHeight = -1
|
||||
}
|
||||
|
||||
const canvases: Uint8Array[] = []
|
||||
let entryIndex = 0
|
||||
for (let frameIndex = 0; frameIndex < state.frames.length; frameIndex += 1) {
|
||||
const frame = state.frames[frameIndex]!
|
||||
const canvas = new Uint8Array(stride * state.box.height)
|
||||
for (let c = 0; c < frame.cells.length; c += 1) {
|
||||
const cell = frame.cells[c]!
|
||||
const gridX = Math.trunc(cell.xOffset / CELL_SIZE)
|
||||
const gridY = Math.trunc(cell.yOffset / CELL_SIZE)
|
||||
const slot = state.cells[gridX + gridY * state.horizontalCellCount]!
|
||||
const entry = buffer[entryIndex]
|
||||
if (entry === undefined) {
|
||||
throw new CelError(`frame ${String(frameIndex)} cell ${String(c)}: pixel buffer underrun`)
|
||||
}
|
||||
|
||||
if (entry.frame !== frameIndex || entry.cellIndex !== c) {
|
||||
if (cell.width !== slot.lastWidth || cell.height !== slot.lastHeight) {
|
||||
// The previous occupant had a different shape, so only the scratch
|
||||
// canvas can be cleared; this frame's cell stays transparent.
|
||||
for (let y = 0; y < cell.height; y += 1) {
|
||||
const row = cell.xOffset + (y + cell.yOffset) * stride
|
||||
for (let x = 0; x < cell.width; x += 1) scratch[row + x] = 0
|
||||
}
|
||||
} else {
|
||||
for (let y = 0; y < cell.height; y += 1) {
|
||||
const from = slot.lastXOffset + (y + slot.lastYOffset) * stride
|
||||
const to = cell.xOffset + (y + cell.yOffset) * stride
|
||||
for (let x = 0; x < cell.width; x += 1) scratch[to + x] = scratch[from + x]!
|
||||
}
|
||||
blit(scratch, canvas, cell, stride)
|
||||
}
|
||||
} else {
|
||||
if (entry.value[0] === entry.value[1]) {
|
||||
// One flat colour for the whole cell, so no per-pixel codes follow.
|
||||
for (let y = 0; y < cell.height; y += 1) {
|
||||
const row = cell.xOffset + (y + cell.yOffset) * stride
|
||||
for (let x = 0; x < cell.width; x += 1) scratch[row + x] = entry.value[0]!
|
||||
}
|
||||
} else {
|
||||
// One bit per pixel when only two codes survive, two bits when there
|
||||
// are three or four (the fourth is the "nothing here" code).
|
||||
const bits = entry.value[1] !== entry.value[2] ? 2 : 1
|
||||
for (let y = 0; y < cell.height; y += 1) {
|
||||
const row = cell.xOffset + (y + cell.yOffset) * stride
|
||||
for (let x = 0; x < cell.width; x += 1) scratch[row + x] = entry.value[codes.getBits(bits)]!
|
||||
}
|
||||
}
|
||||
blit(scratch, canvas, cell, stride)
|
||||
entryIndex += 1
|
||||
}
|
||||
|
||||
slot.lastWidth = cell.width
|
||||
slot.lastHeight = cell.height
|
||||
slot.lastXOffset = cell.xOffset
|
||||
slot.lastYOffset = cell.yOffset
|
||||
}
|
||||
canvases.push(canvas)
|
||||
}
|
||||
return canvases
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy one cell rectangle from the scratch canvas into a frame canvas.
|
||||
*
|
||||
* @param from - the scratch canvas.
|
||||
* @param to - the frame canvas.
|
||||
* @param cell - the cell rectangle.
|
||||
* @param stride - canvas width in pixels.
|
||||
*/
|
||||
function blit(from: Uint8Array, to: Uint8Array, cell: CellRect, stride: number): void {
|
||||
for (let y = 0; y < cell.height; y += 1) {
|
||||
const row = cell.xOffset + (y + cell.yOffset) * stride
|
||||
for (let x = 0; x < cell.width; x += 1) to[row + x] = from[row + x]!
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip a frame's pixel rows inside the direction canvas.
|
||||
*
|
||||
* The reference implementation panics with "bottom up frames are not
|
||||
* implemented", so this path has no second opinion to check it against. The
|
||||
* flag's documented meaning is that the frame's pixel rows are stored
|
||||
* bottom-up, so the frame's own art rectangle — not the whole direction canvas —
|
||||
* is mirrored about its horizontal centre line, with the box computed exactly as
|
||||
* for a top-down frame. Decoding never *skips* such a frame: the only cost of
|
||||
* getting the interpretation wrong is a vertically mirrored frame, which is why
|
||||
* the flag is also exposed on the decoded frame for counting.
|
||||
*
|
||||
* @param pixels - the direction-sized canvas, modified in place.
|
||||
* @param directionBox - the direction's box, for offsetting the frame rectangle.
|
||||
* @param frameBox - the frame's rectangle.
|
||||
*/
|
||||
function flipFrameRows(pixels: Uint8Array, directionBox: DccBox, frameBox: DccBox): void {
|
||||
const top = frameBox.top - directionBox.top
|
||||
const left = frameBox.left - directionBox.left
|
||||
for (let y = 0; y < Math.trunc(frameBox.height / 2); y += 1) {
|
||||
const upper = (top + y) * directionBox.width + left
|
||||
const lower = (top + frameBox.height - 1 - y) * directionBox.width + left
|
||||
for (let x = 0; x < frameBox.width; x += 1) {
|
||||
const swap = pixels[upper + x]!
|
||||
pixels[upper + x] = pixels[lower + x]!
|
||||
pixels[lower + x] = swap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a decoded canvas in the project's palette-indexed sprite shape, tagging
|
||||
* index 0 as transparent the same way the DC6 decoder does.
|
||||
*
|
||||
* @param pixels - the canvas, which becomes the index buffer.
|
||||
* @param width - canvas width.
|
||||
* @param height - canvas height.
|
||||
* @returns the sprite frame.
|
||||
*/
|
||||
function toSpriteFrame(pixels: Uint8Array, width: number, height: number): SpriteFrame {
|
||||
const mask = new Uint8Array(pixels.length)
|
||||
for (let i = 0; i < pixels.length; i += 1) mask[i] = pixels[i] === 0 ? 0 : 1
|
||||
return { width, height, indices: pixels, mask }
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a sub-stream was consumed exactly to its declared bit length.
|
||||
*
|
||||
* This is the format's own consistency check: the declared sizes and the actual
|
||||
* content have to agree, and a mismatch means the decode drifted, which would
|
||||
* otherwise surface only as a subtly wrong sprite.
|
||||
*
|
||||
* @param where - error-message prefix.
|
||||
* @param name - the stream name.
|
||||
* @param reader - the sub-stream reader.
|
||||
* @param declared - the declared bit length.
|
||||
*/
|
||||
function verifyStream(where: string, name: string, reader: BitReader, declared: number): void {
|
||||
const consumed = reader.bitsRead()
|
||||
if (consumed !== declared) {
|
||||
throw new CelError(
|
||||
`${where}: ${name} stream consumed ${String(consumed)} bits, header declares ${String(declared)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a message from an unknown thrown value.
|
||||
*
|
||||
* @param err - the thrown value.
|
||||
* @returns the message.
|
||||
*/
|
||||
function messageOf(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
/**
|
||||
* Diablo II `.ds1` map decoder.
|
||||
*
|
||||
* A DS1 is a map *layout*: a grid of tiles, each slot holding one reference per
|
||||
* layer (four wall layers, four orientation layers, two floor layers, a shadow
|
||||
* layer and a substitution layer, depending on the version). A reference is
|
||||
* only `(style, sequence)` into a DT1 library — the pixels come from there —
|
||||
* so DS1 and DT1 are useless apart and are decoded as a pair.
|
||||
*
|
||||
* The file is a versioned sequence of optional sections, which is the whole
|
||||
* difficulty: which fields exist depends on a version number, and the layer
|
||||
* streams are interleaved in a fixed order that also has a legacy shape for
|
||||
* versions below 4. Both are encoded here exactly as the reference decoder
|
||||
* reads them, including the pre-7 orientation lookup table.
|
||||
*/
|
||||
import { CelError } from './sprite.ts'
|
||||
|
||||
/** Pre-7 maps store orientations through this lookup rather than directly. */
|
||||
const LEGACY_DIRECTION_LOOKUP = [
|
||||
0x00, 0x01, 0x02, 0x01, 0x02, 0x03, 0x03, 0x05, 0x05, 0x06,
|
||||
0x06, 0x07, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10, 0x11, 0x12, 0x14,
|
||||
] as const
|
||||
|
||||
/** Version at which the act field exists. */
|
||||
const V_ACT = 8
|
||||
/** Version at which the substitution type exists. */
|
||||
const V_SUBSTITUTION_LAYERS = 10
|
||||
/** Version at which the embedded file list exists. */
|
||||
const V_FILES = 3
|
||||
/** Versions 9..13 carry eight unknown bytes. */
|
||||
const V_UNKNOWN_LOW = 9
|
||||
const V_UNKNOWN_HIGH = 13
|
||||
/** Version at which floor layers exist (below it there is exactly one). */
|
||||
const V_FLOORS = 4
|
||||
/** Version at which wall layers exist. */
|
||||
const V_WALLS = 16
|
||||
/** Version at which NPC paths exist. */
|
||||
const V_NPCS = 14
|
||||
/** Below this version the orientation field goes through the lookup. */
|
||||
const V_DIRECT_ORIENTATION = 7
|
||||
|
||||
/** One wall cell: a tile reference plus the wall's own fields. */
|
||||
export interface Ds1Wall {
|
||||
/** Palette/tile property byte. */
|
||||
readonly prop1: number
|
||||
/** Sequence within the style (0..63). */
|
||||
readonly sequence: number
|
||||
/** Style index within the DT1 library. */
|
||||
readonly style: number
|
||||
/** Orientation index (a DT1 `direction`). */
|
||||
readonly type: number
|
||||
/** Unnamed field, as stored. */
|
||||
readonly unknown1: number
|
||||
/** Unnamed field, as stored. */
|
||||
readonly unknown2: number
|
||||
/** The cell is hidden. */
|
||||
readonly hidden: boolean
|
||||
}
|
||||
|
||||
/** One floor or shadow cell. */
|
||||
export interface Ds1Floor {
|
||||
/** Palette/tile property byte. */
|
||||
readonly prop1: number
|
||||
/** Sequence within the style (0..63). */
|
||||
readonly sequence: number
|
||||
/** Style index within the DT1 library. */
|
||||
readonly style: number
|
||||
/** Unnamed field, as stored. */
|
||||
readonly unknown1: number
|
||||
/** Unnamed field, as stored. */
|
||||
readonly unknown2: number
|
||||
/** The cell is hidden. */
|
||||
readonly hidden: boolean
|
||||
}
|
||||
|
||||
/** One substitution cell (raw value; its meaning is map-specific). */
|
||||
export interface Ds1Substitution {
|
||||
/** The raw 32-bit layer value. */
|
||||
readonly value: number
|
||||
}
|
||||
|
||||
/** Everything one map cell carries. */
|
||||
export interface Ds1Cell {
|
||||
/** Wall layers, outermost first. */
|
||||
readonly walls: readonly Ds1Wall[]
|
||||
/** Floor layers, one per declared floor layer. */
|
||||
readonly floors: readonly Ds1Floor[]
|
||||
/** Shadow layers (one in shipped maps). */
|
||||
readonly shadows: readonly Ds1Floor[]
|
||||
/** Substitution layers. */
|
||||
readonly substitutions: readonly Ds1Substitution[]
|
||||
}
|
||||
|
||||
/** One placed object (a door, a shrine, a chest, ...). */
|
||||
export interface Ds1Object {
|
||||
/** Object type index into the act's object table. */
|
||||
readonly type: number
|
||||
/** Object id (the specific variant). */
|
||||
readonly id: number
|
||||
/** Tile x. */
|
||||
readonly x: number
|
||||
/** Tile y. */
|
||||
readonly y: number
|
||||
/** Raw flags word. */
|
||||
readonly flags: number
|
||||
}
|
||||
|
||||
/** A decoded DS1 map. */
|
||||
export interface Ds1 {
|
||||
/** File version. */
|
||||
readonly version: number
|
||||
/** Map width in tiles. */
|
||||
readonly width: number
|
||||
/** Map height in tiles. */
|
||||
readonly height: number
|
||||
/** Act (1..5). */
|
||||
readonly act: number
|
||||
/** Substitution layer type (0, 1 or 2). */
|
||||
readonly substitutionType: number
|
||||
/** Number of wall layers. */
|
||||
readonly wallLayers: number
|
||||
/** Number of floor layers. */
|
||||
readonly floorLayers: number
|
||||
/** Cells, row-major: `cells[y][x]`. */
|
||||
readonly cells: readonly (readonly Ds1Cell[])[]
|
||||
/** Placed objects. */
|
||||
readonly objects: readonly Ds1Object[]
|
||||
/** Byte offset where NPC paths begin (not decoded). */
|
||||
readonly npcPathOffset: number | null
|
||||
}
|
||||
|
||||
/** A cursor over the file, so the versioned section walk reads linearly. */
|
||||
class Cursor {
|
||||
private at = 0
|
||||
private readonly data: Uint8Array
|
||||
|
||||
/**
|
||||
* @param data - the buffer to walk.
|
||||
*/
|
||||
constructor(data: Uint8Array) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
/** Current byte offset. */
|
||||
get offset(): number { return this.at }
|
||||
|
||||
/**
|
||||
* Read `count` bytes and advance.
|
||||
*
|
||||
* @param count - byte count.
|
||||
* @returns the bytes.
|
||||
*/
|
||||
take(count: number): Uint8Array {
|
||||
if (count < 0 || this.at + count > this.data.byteLength) {
|
||||
throw new CelError(`DS1 section runs past the file end at offset ${String(this.at)}`)
|
||||
}
|
||||
const slice = this.data.subarray(this.at, this.at + count)
|
||||
this.at += count
|
||||
return slice
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a little-endian int32 and advance.
|
||||
*
|
||||
* @returns the value.
|
||||
*/
|
||||
int32(): number {
|
||||
const bytes = this.take(4)
|
||||
return (bytes[0]! | (bytes[1]! << 8) | (bytes[2]! << 16) | (bytes[3]! << 24)) | 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a little-endian uint32 and advance.
|
||||
*
|
||||
* @returns the value.
|
||||
*/
|
||||
uint32(): number {
|
||||
const bytes = this.take(4)
|
||||
return (bytes[0]! | (bytes[1]! << 8) | (bytes[2]! << 16) | (bytes[3]! << 24)) >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a NUL-terminated ASCII string and advance.
|
||||
*
|
||||
* @returns the string.
|
||||
*/
|
||||
cstring(): string {
|
||||
let out = ''
|
||||
for (;;) {
|
||||
const byte = this.take(1)[0]!
|
||||
if (byte === 0) return out
|
||||
out += String.fromCharCode(byte)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a DS1 map.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @returns the decoded map.
|
||||
*/
|
||||
export function decodeDs1(data: Uint8Array): Ds1 {
|
||||
if (data.byteLength < 12) throw new CelError(`DS1 is ${String(data.byteLength)} bytes, too short for a header`)
|
||||
const cursor = new Cursor(data)
|
||||
const version = cursor.int32()
|
||||
// Stored width/height are one less than the tile count.
|
||||
const width = cursor.int32() + 1
|
||||
const height = cursor.int32() + 1
|
||||
if (width <= 0 || height <= 0 || width * height > (1 << 22)) {
|
||||
throw new CelError(`DS1 declares an implausible size ${String(width)}x${String(height)}`)
|
||||
}
|
||||
|
||||
let act = 1
|
||||
if (version >= V_ACT) act = cursor.int32()
|
||||
|
||||
let substitutionType = 0
|
||||
let substitutionLayers = 0
|
||||
if (version >= V_SUBSTITUTION_LAYERS) {
|
||||
substitutionType = cursor.int32()
|
||||
if (substitutionType === 1 || substitutionType === 2) substitutionLayers = 1
|
||||
}
|
||||
|
||||
const files: string[] = []
|
||||
if (version >= V_FILES) {
|
||||
const count = cursor.int32()
|
||||
if (count < 0 || count > 1024) throw new CelError(`DS1 declares ${String(count)} embedded file names`)
|
||||
for (let index = 0; index < count; index += 1) files.push(cursor.cstring())
|
||||
}
|
||||
|
||||
if (version >= V_UNKNOWN_LOW && version <= V_UNKNOWN_HIGH) cursor.take(8)
|
||||
|
||||
let wallLayers = 0
|
||||
let floorLayers = 0
|
||||
if (version >= V_FLOORS) {
|
||||
wallLayers = cursor.int32()
|
||||
if (version >= V_WALLS) floorLayers = cursor.int32()
|
||||
else floorLayers = 1
|
||||
}
|
||||
if (wallLayers < 0 || wallLayers > 4 || floorLayers < 0 || floorLayers > 2) {
|
||||
throw new CelError(`DS1 declares ${String(wallLayers)} wall and ${String(floorLayers)} floor layers`)
|
||||
}
|
||||
const shadowLayers = 1
|
||||
|
||||
const cells: Ds1Cell[][] = []
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row: Ds1Cell[] = []
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
row.push({
|
||||
walls: Array.from({ length: wallLayers }, () => emptyWall()),
|
||||
floors: Array.from({ length: floorLayers }, () => emptyFloor()),
|
||||
shadows: Array.from({ length: shadowLayers }, () => emptyFloor()),
|
||||
substitutions: Array.from({ length: substitutionLayers }, () => ({ value: 0 })),
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
|
||||
for (const layer of layerOrder(version, wallLayers, floorLayers, shadowLayers, substitutionLayers)) {
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row = cells[y]!
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const bits = cursor.uint32()
|
||||
applyLayer(row[x]!, layer, bits, version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const objects: Ds1Object[] = []
|
||||
if (version >= 2) {
|
||||
const count = cursor.int32()
|
||||
if (count < 0 || count > (1 << 20)) throw new CelError(`DS1 declares ${String(count)} objects`)
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
objects.push({
|
||||
type: cursor.int32(),
|
||||
id: cursor.int32(),
|
||||
x: cursor.int32(),
|
||||
y: cursor.int32(),
|
||||
flags: cursor.int32(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Substitution groups, NPC paths and NPC extra data follow; they are not
|
||||
// needed to draw or walk a map, so their offset is recorded instead of being
|
||||
// guessed at.
|
||||
const npcPathOffset = version >= V_NPCS ? cursor.offset : null
|
||||
|
||||
return {
|
||||
version, width, height, act, substitutionType,
|
||||
wallLayers, floorLayers, cells, objects, npcPathOffset,
|
||||
}
|
||||
}
|
||||
|
||||
/** A blank wall cell. */
|
||||
function emptyWall(): Ds1Wall {
|
||||
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||||
}
|
||||
|
||||
/** A blank floor/shadow cell. */
|
||||
function emptyFloor(): Ds1Floor {
|
||||
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||||
}
|
||||
|
||||
/** Wall layer indices, matching the file's fixed order. */
|
||||
const WALL_LAYERS = [0, 1, 2, 3] as const
|
||||
/** Floor layer indices. */
|
||||
const FLOOR_LAYERS = [0, 1] as const
|
||||
|
||||
/**
|
||||
* The order the layer streams appear in the file.
|
||||
*
|
||||
* Below version 4 every type had exactly one layer and the streams follow a
|
||||
* legacy order; from 4 on, wall and orientation layers alternate per wall
|
||||
* layer, then floors, then shadow, then substitutions.
|
||||
*
|
||||
* @param version - file version.
|
||||
* @param wallLayers - declared wall layers.
|
||||
* @param floorLayers - declared floor layers.
|
||||
* @param shadowLayers - shadow layers.
|
||||
* @param substitutionLayers - substitution layers.
|
||||
* @returns the layer stream descriptors in file order.
|
||||
*/
|
||||
function layerOrder(
|
||||
version: number,
|
||||
wallLayers: number,
|
||||
floorLayers: number,
|
||||
shadowLayers: number,
|
||||
substitutionLayers: number,
|
||||
): { kind: 'wall' | 'orientation' | 'floor' | 'shadow' | 'substitution'; index: number }[] {
|
||||
if (version < V_FLOORS) {
|
||||
return [
|
||||
{ kind: 'wall', index: 0 },
|
||||
{ kind: 'floor', index: 0 },
|
||||
{ kind: 'orientation', index: 0 },
|
||||
{ kind: 'substitution', index: 0 },
|
||||
{ kind: 'shadow', index: 0 },
|
||||
]
|
||||
}
|
||||
const order: { kind: 'wall' | 'orientation' | 'floor' | 'shadow' | 'substitution'; index: number }[] = []
|
||||
for (let index = 0; index < wallLayers; index += 1) {
|
||||
order.push({ kind: 'wall', index }, { kind: 'orientation', index })
|
||||
}
|
||||
for (let index = 0; index < floorLayers; index += 1) order.push({ kind: 'floor', index })
|
||||
for (let index = 0; index < shadowLayers; index += 1) order.push({ kind: 'shadow', index })
|
||||
for (let index = 0; index < substitutionLayers; index += 1) order.push({ kind: 'substitution', index })
|
||||
return order
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one 32-bit layer value to its cell.
|
||||
*
|
||||
* Cells are small mutable records for exactly this reason: the layer streams
|
||||
* arrive interleaved, so a cell is assembled from several passes.
|
||||
*
|
||||
* @param cell - the cell to update.
|
||||
* @param layer - which layer this value belongs to.
|
||||
* @param bits - the raw 32-bit value.
|
||||
* @param version - file version (older maps fold orientations).
|
||||
*/
|
||||
function applyLayer(
|
||||
cell: Ds1Cell,
|
||||
layer: { kind: 'wall' | 'orientation' | 'floor' | 'shadow' | 'substitution'; index: number },
|
||||
bits: number,
|
||||
version: number,
|
||||
): void {
|
||||
const prop1 = bits & 0x000000ff
|
||||
const sequence = (bits & 0x00003f00) >>> 8
|
||||
const unknown1 = (bits & 0x000fc000) >>> 14
|
||||
const style = (bits & 0x03f00000) >>> 20
|
||||
const unknown2 = (bits & 0x7c000000) >>> 26
|
||||
const hidden = ((bits & 0x80000000) >>> 31) > 0
|
||||
switch (layer.kind) {
|
||||
case 'wall': {
|
||||
const wall = cell.walls[layer.index]
|
||||
if (wall === undefined) return
|
||||
// Walls are mutable during assembly; the exported type is read-only.
|
||||
Object.assign(wall, { prop1, sequence, unknown1, style, unknown2, hidden })
|
||||
return
|
||||
}
|
||||
case 'orientation': {
|
||||
const wall = cell.walls[layer.index]
|
||||
if (wall === undefined) return
|
||||
let type = bits & 0x000000ff
|
||||
if (version < V_DIRECT_ORIENTATION && type < LEGACY_DIRECTION_LOOKUP.length) {
|
||||
type = LEGACY_DIRECTION_LOOKUP[type]!
|
||||
}
|
||||
Object.assign(wall, { type })
|
||||
return
|
||||
}
|
||||
case 'floor': {
|
||||
const floor = cell.floors[layer.index]
|
||||
if (floor !== undefined) Object.assign(floor, { prop1, sequence, unknown1, style, unknown2, hidden })
|
||||
return
|
||||
}
|
||||
case 'shadow': {
|
||||
const shadow = cell.shadows[layer.index]
|
||||
if (shadow !== undefined) Object.assign(shadow, { prop1, sequence, unknown1, style, unknown2, hidden })
|
||||
return
|
||||
}
|
||||
case 'substitution': {
|
||||
const substitution = cell.substitutions[layer.index]
|
||||
if (substitution !== undefined) Object.assign(substitution, { value: bits })
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- the layer union is closed above. */
|
||||
default: return
|
||||
}
|
||||
}
|
||||
|
||||
/** Reserved for the DT1 pairing helpers that follow in the renderer. */
|
||||
export { WALL_LAYERS, FLOOR_LAYERS }
|
||||
|
|
@ -0,0 +1,515 @@
|
|||
/**
|
||||
* Diablo II `.dt1` tile decoder.
|
||||
*
|
||||
* A DT1 is the tile library a DS1 map references: every tile carries a
|
||||
* 96-byte record (size, orientation, material, 25 sub-tile collision flags)
|
||||
* and a list of 20-byte block headers pointing at pixel data. Two block
|
||||
* encodings exist and both are implemented here:
|
||||
*
|
||||
* - **RLE (format 0)**: a stream of `(skip, count)` byte pairs. `skip` advances
|
||||
* the column cursor, `count` literal indices follow, and the pair `(0, 0)`
|
||||
* ends the row. Rows advance downward; unlike DC6 there is no bottom-up
|
||||
* reversal.
|
||||
* - **Isometric (format 1)**: exactly 256 bytes laid onto the diamond of a
|
||||
* 32-wide tile through the fixed `xjump`/`nbpix` tables below — 15 rows of
|
||||
* increasing then decreasing run length. Nothing in the file describes those
|
||||
* tables; they are part of the format.
|
||||
*
|
||||
* Collision lives in the sub-tile flags (a 5×5 grid per tile), which is what
|
||||
* makes a walkable map possible without any extra data.
|
||||
*/
|
||||
import { CelError } from './sprite.ts'
|
||||
|
||||
/** Bytes of the fixed header before the tile count. */
|
||||
const HEADER_PREFIX = 8
|
||||
/** Bytes of unknown data between the version and the tile count. */
|
||||
const HEADER_UNKNOWN = 260
|
||||
/** Bytes per tile record. */
|
||||
const TILE_RECORD_SIZE = 96
|
||||
/** Pixel height of one block's covered area, used to size tile bitmaps. */
|
||||
const BLOCK_PIXEL_HEIGHT = 32
|
||||
/** Bytes per block header. */
|
||||
const BLOCK_HEADER_SIZE = 20
|
||||
/** Sub-tile grid edge (5×5 collision flags per tile). */
|
||||
const SUB_TILE_GRID = 5
|
||||
/** Bytes of one isometric block. */
|
||||
const ISOMETRIC_BLOCK_SIZE = 256
|
||||
/** Row offsets of the isometric diamond, one entry per row. */
|
||||
const ISO_JUMP = [14, 12, 10, 8, 6, 4, 2, 0, 2, 4, 6, 8, 10, 12, 14] as const
|
||||
/** Row run lengths of the isometric diamond, one entry per row. */
|
||||
const ISO_RUN = [4, 8, 12, 16, 20, 24, 28, 32, 28, 24, 20, 16, 12, 8, 4] as const
|
||||
|
||||
/** A tile's collision flags, decoded from one of its 25 sub-tile bytes. */
|
||||
export interface SubTileFlags {
|
||||
/** Blocks walking (all units). */
|
||||
readonly blockWalk: boolean
|
||||
/** Blocks line of sight. */
|
||||
readonly blockLos: boolean
|
||||
/** Blocks jumping/leaping. */
|
||||
readonly blockJump: boolean
|
||||
/** Blocks the player specifically (but not monsters). */
|
||||
readonly blockPlayerWalk: boolean
|
||||
/** Blocks light. */
|
||||
readonly blockLight: boolean
|
||||
/** Raw byte, for the bits that are not named. */
|
||||
readonly raw: number
|
||||
}
|
||||
|
||||
/** One block of a tile: its placement inside the tile and its pixels. */
|
||||
export interface Dt1Block {
|
||||
/** Destination x within the tile. */
|
||||
readonly x: number
|
||||
/** Destination y within the tile. */
|
||||
readonly y: number
|
||||
/** Block grid position, as stored. */
|
||||
readonly gridX: number
|
||||
/** Block grid position, as stored. */
|
||||
readonly gridY: number
|
||||
/** Encoding: 0 run-length, 1 isometric. */
|
||||
readonly format: number
|
||||
/** Palette indices, laid out at tile size (tile width × |tile height|). */
|
||||
readonly pixels: Uint8Array
|
||||
}
|
||||
|
||||
/** A block header as stored (20 bytes). */
|
||||
interface BlockHeader {
|
||||
/** Destination x within the tile. */
|
||||
readonly x: number
|
||||
/** Destination y within the tile. */
|
||||
readonly y: number
|
||||
/** Sub-tile grid column (floor tiles only). */
|
||||
readonly gridX: number
|
||||
/** Sub-tile grid row (floor tiles only). */
|
||||
readonly gridY: number
|
||||
/** Encoding: 0 run-length, 1 isometric. */
|
||||
readonly format: number
|
||||
/** Encoded byte count. */
|
||||
readonly length: number
|
||||
/** Offset of the encoded data, as stored. */
|
||||
readonly fileOffset: number
|
||||
/** Offset of the block header that carries it. */
|
||||
readonly headerOffset: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve where a block's encoded bytes actually live.
|
||||
*
|
||||
* The community format documentation calls the field "offset in file", while the
|
||||
* working reference implementation reads it relative to the block header. Both
|
||||
* readings occur in the wild, so the decoder tries the reference's reading
|
||||
* first and falls back to the absolute one when that range is unusable — and a
|
||||
* file that satisfies neither is reported rather than decoded into noise.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param block - the block header.
|
||||
* @returns the absolute offset of the encoded data.
|
||||
*/
|
||||
function resolveBlockDataOffset(data: Uint8Array, block: BlockHeader): number[] {
|
||||
const relative = block.headerOffset + block.fileOffset
|
||||
const absolute = block.fileOffset
|
||||
const fits = (offset: number): boolean =>
|
||||
offset >= 0 && offset + block.length <= data.byteLength && block.length >= 0
|
||||
const candidates = [relative, absolute].filter((offset, index, all) => fits(offset) && all.indexOf(offset) === index)
|
||||
if (candidates.length === 0) {
|
||||
throw new CelError(
|
||||
`block data at ${String(relative)} (relative) or ${String(absolute)} (absolute) does not fit the file`,
|
||||
)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
/** One tile of the library. */
|
||||
export interface Dt1Tile {
|
||||
/** Orientation/direction index. */
|
||||
readonly direction: number
|
||||
/** Tile height, negative for roofs. */
|
||||
readonly height: number
|
||||
/** Tile width. */
|
||||
readonly width: number
|
||||
/** Tile type (the `type` half of a DS1 tile reference). */
|
||||
readonly type: number
|
||||
/** Tile style (the `style` half of a DS1 tile reference). */
|
||||
readonly style: number
|
||||
/** Sequence within the style. */
|
||||
readonly sequence: number
|
||||
/** Material bit field (water, wood, stone, ...). */
|
||||
readonly materialFlags: number
|
||||
/**
|
||||
* Roof height, at file offset +4.
|
||||
*
|
||||
* OpenDiablo2 reads this field and uses it for one thing: a roof tile's
|
||||
* `YAdjust` is `-roofHeight` instead of `minBlockY + 80`
|
||||
* (`d2maprenderer/tile_cache.go`). Kept here so the roof layer can be drawn
|
||||
* with the engine's own offset when it is wired up.
|
||||
*/
|
||||
readonly roofHeight: number
|
||||
/**
|
||||
* Variant weight inside one `style:sequence` group, at file offset +32.
|
||||
*
|
||||
* A DT1 ships several tiles for the same `style`/`sequence` and the engine
|
||||
* picks one **per cell** by weighted random using this field as the weight
|
||||
* (OpenDiablo2 `getRandomTile`, seeded from the map seed and the cell's x/y).
|
||||
* Without it every cell of a floor would draw the same variant.
|
||||
*/
|
||||
readonly rarityFrameIndex: number
|
||||
/** 25 sub-tile collision flag sets, row-major on a 5×5 grid. */
|
||||
readonly subTileFlags: readonly SubTileFlags[]
|
||||
/**
|
||||
* Most negative block `y` in this tile.
|
||||
*
|
||||
* Blocks are placed with this shift applied (see {@link Dt1Tile.blocks}), and
|
||||
* the value is also what Diablo II's renderer adds to a wall's cell position
|
||||
* (`YAdjust = minBlockY + 80`): a wall whose art extends above its cell must
|
||||
* be pushed back down by exactly the amount the art was shifted. Floors ignore
|
||||
* it, which is why only walls look wrong when it is dropped.
|
||||
*/
|
||||
readonly minBlockY: number
|
||||
/**
|
||||
* Height of the decoded bitmap, in pixels.
|
||||
*
|
||||
* Usually `|height|`, but a wall's art can legitimately extend past the
|
||||
* declared height (the reference renderer allocates
|
||||
* `max(|height|, maxBlockY + 32 - minBlockY)` for exactly this reason). Using
|
||||
* the declared height alone clips those tiles — two references in Act 5's town
|
||||
* hit that case.
|
||||
*/
|
||||
readonly bitmapHeight: number
|
||||
/** The tile's blocks, in file order. */
|
||||
readonly blocks: readonly Dt1Block[]
|
||||
}
|
||||
|
||||
/** A decoded DT1 file. */
|
||||
export interface Dt1 {
|
||||
/**
|
||||
* Structural surprises that did not prevent decoding (empty for a
|
||||
* well-formed library). Real Blizzard files are expected to leave this empty;
|
||||
* a non-empty list means a field's meaning differs from the documented one.
|
||||
*/
|
||||
readonly warnings: readonly string[]
|
||||
/** Major version, as stored (7 for shipped files). */
|
||||
readonly versionMajor: number
|
||||
/** Minor version, as stored (6 for shipped files). */
|
||||
readonly versionMinor: number
|
||||
/** The tile library. */
|
||||
readonly tiles: readonly Dt1Tile[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a little-endian uint32.
|
||||
*
|
||||
* @param data - the buffer.
|
||||
* @param at - byte offset.
|
||||
* @returns the value.
|
||||
*/
|
||||
function u32(data: Uint8Array, at: number): number {
|
||||
return (data[at]! | (data[at + 1]! << 8) | (data[at + 2]! << 16) | (data[at + 3]! << 24)) >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a little-endian int32.
|
||||
*
|
||||
* @param data - the buffer.
|
||||
* @param at - byte offset.
|
||||
* @returns the value.
|
||||
*/
|
||||
function i32(data: Uint8Array, at: number): number {
|
||||
return u32(data, at) | 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a little-endian uint16.
|
||||
*
|
||||
* @param data - the buffer.
|
||||
* @param at - byte offset.
|
||||
* @returns the value.
|
||||
*/
|
||||
function u16(data: Uint8Array, at: number): number {
|
||||
return data[at]! | (data[at + 1]! << 8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a little-endian int16.
|
||||
*
|
||||
* @param data - the buffer.
|
||||
* @param at - byte offset.
|
||||
* @returns the value.
|
||||
*/
|
||||
function i16(data: Uint8Array, at: number): number {
|
||||
const value = u16(data, at)
|
||||
return value >= 0x8000 ? value - 0x10000 : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one sub-tile flag byte.
|
||||
*
|
||||
* @param raw - the flag byte.
|
||||
* @returns the decoded flags.
|
||||
*/
|
||||
export function subTileFlagsOf(raw: number): SubTileFlags {
|
||||
return {
|
||||
blockWalk: (raw & 1) === 1,
|
||||
blockLos: (raw & 2) === 2,
|
||||
blockJump: (raw & 4) === 4,
|
||||
blockPlayerWalk: (raw & 8) === 8,
|
||||
blockLight: (raw & 32) === 32,
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a DT1 file.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @returns the decoded library.
|
||||
*/
|
||||
export function decodeDt1(data: Uint8Array): Dt1 {
|
||||
if (data.byteLength < HEADER_PREFIX + HEADER_UNKNOWN + 8) {
|
||||
throw new CelError(`DT1 is ${String(data.byteLength)} bytes, too short for a header`)
|
||||
}
|
||||
const versionMajor = i32(data, 0)
|
||||
const versionMinor = i32(data, 4)
|
||||
if (versionMajor !== 7 || versionMinor !== 6) {
|
||||
throw new CelError(`unsupported DT1 version ${String(versionMajor)}.${String(versionMinor)} (expected 7.6)`)
|
||||
}
|
||||
const tileCount = i32(data, HEADER_PREFIX + HEADER_UNKNOWN)
|
||||
const tileDataStart = i32(data, HEADER_PREFIX + HEADER_UNKNOWN + 4)
|
||||
if (tileCount < 0 || tileDataStart < 0 || tileDataStart > data.byteLength) {
|
||||
throw new CelError(`DT1 header is implausible: ${String(tileCount)} tiles at ${String(tileDataStart)}`)
|
||||
}
|
||||
const tilesEnd = tileDataStart + tileCount * TILE_RECORD_SIZE
|
||||
if (tilesEnd > data.byteLength) {
|
||||
throw new CelError(`DT1 tile records run past the file end (${String(tilesEnd)} > ${String(data.byteLength)})`)
|
||||
}
|
||||
|
||||
const warnings: string[] = []
|
||||
const records: { tile: Omit<Dt1Tile, 'blocks' | 'minBlockY' | 'bitmapHeight'>; blocks: BlockHeader[] }[] = []
|
||||
for (let index = 0; index < tileCount; index += 1) {
|
||||
const at = tileDataStart + index * TILE_RECORD_SIZE
|
||||
const subTileFlags: SubTileFlags[] = []
|
||||
for (let sub = 0; sub < SUB_TILE_GRID * SUB_TILE_GRID; sub += 1) {
|
||||
subTileFlags.push(subTileFlagsOf(data[at + 40 + sub]!))
|
||||
}
|
||||
const blockHeaderPointer = i32(data, at + 72)
|
||||
const blockHeaderSize = i32(data, at + 76)
|
||||
const numBlocks = i32(data, at + 80)
|
||||
const blocks: BlockHeader[] = []
|
||||
for (let blockIndex = 0; blockIndex < numBlocks; blockIndex += 1) {
|
||||
const headerAt = blockHeaderPointer + blockIndex * BLOCK_HEADER_SIZE
|
||||
if (headerAt + BLOCK_HEADER_SIZE > data.byteLength) {
|
||||
throw new CelError(`tile ${String(index)} block ${String(blockIndex)} header is out of range`)
|
||||
}
|
||||
// Block header (20 bytes): X, Y, 2 unused, GridX, GridY, Format,
|
||||
// Length, 2 unused, FileOffset. Note the two unused words: a 16-byte
|
||||
// reading of this record parses every field after Y at the wrong offset.
|
||||
blocks.push({
|
||||
x: i16(data, headerAt),
|
||||
y: i16(data, headerAt + 2),
|
||||
gridX: data[headerAt + 6]!,
|
||||
gridY: data[headerAt + 7]!,
|
||||
format: i16(data, headerAt + 8),
|
||||
length: i32(data, headerAt + 10),
|
||||
fileOffset: i32(data, headerAt + 16),
|
||||
headerOffset: blockHeaderPointer,
|
||||
})
|
||||
}
|
||||
records.push({
|
||||
tile: {
|
||||
direction: i32(data, at + 0),
|
||||
height: i32(data, at + 8),
|
||||
width: i32(data, at + 12),
|
||||
type: i32(data, at + 20),
|
||||
style: i32(data, at + 24),
|
||||
sequence: i32(data, at + 28),
|
||||
materialFlags: u16(data, at + 6),
|
||||
roofHeight: i16(data, at + 4),
|
||||
rarityFrameIndex: i32(data, at + 32),
|
||||
subTileFlags,
|
||||
},
|
||||
blocks,
|
||||
})
|
||||
// The field at +76 is *not* a per-block stride: real Act 1 town tiles carry
|
||||
// 25 blocks and a value of 6900, which is the whole block section —
|
||||
// `25 * 20 (headers) + 25 * 256 (bodies)`. Treating it as `numBlocks * 20`
|
||||
// rejected every real Diablo II DT1 while accepting our own fixtures, which
|
||||
// is exactly the failure mode a fixture-only round trip cannot see. The
|
||||
// invariant is checked and reported instead of guessed at.
|
||||
if (numBlocks > 0) {
|
||||
const bodies = blocks.reduce((sum, block) => sum + block.length, 0)
|
||||
const expected = numBlocks * BLOCK_HEADER_SIZE + bodies
|
||||
if (blockHeaderSize !== expected) {
|
||||
warnings.push(
|
||||
`tile ${String(index)}: block section is ${String(blockHeaderSize)} bytes, `
|
||||
+ `but ${String(numBlocks)} headers + ${String(bodies)} body bytes = ${String(expected)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each tile is shifted by its *own* most negative block y, not by a file-wide
|
||||
// worst case: a wall's art legitimately extends above its cell (that is what
|
||||
// makes it a wall), and a shared shift would push every other tile down by the
|
||||
// tallest wall in the library.
|
||||
const tiles: Dt1Tile[] = records.map((record, index) => {
|
||||
let minBlockY = 0
|
||||
let maxBlockY = 0
|
||||
for (const block of record.blocks) {
|
||||
if (block.y < minBlockY) minBlockY = block.y
|
||||
if (block.y + BLOCK_PIXEL_HEIGHT > maxBlockY) maxBlockY = block.y + BLOCK_PIXEL_HEIGHT
|
||||
}
|
||||
const yOffset = -minBlockY
|
||||
const bitmapHeight = Math.max(Math.abs(record.tile.height), maxBlockY - minBlockY)
|
||||
return {
|
||||
...record.tile,
|
||||
minBlockY,
|
||||
bitmapHeight,
|
||||
blocks: record.blocks.map(block => decodeBlock(data, block, record.tile, yOffset, index, bitmapHeight)),
|
||||
}
|
||||
})
|
||||
|
||||
return { versionMajor, versionMinor, tiles, warnings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one block's pixels at tile size.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @param block - the block header.
|
||||
* @param tile - the owning tile.
|
||||
* @param yOffset - vertical shift applied to every block.
|
||||
* @param tileIndex - the tile's index, for error messages.
|
||||
* @returns the block with pixels.
|
||||
*/
|
||||
function decodeBlock(
|
||||
data: Uint8Array,
|
||||
block: BlockHeader,
|
||||
tile: Omit<Dt1Tile, 'blocks' | 'minBlockY' | 'bitmapHeight'>,
|
||||
yOffset: number,
|
||||
tileIndex: number,
|
||||
bitmapHeight: number,
|
||||
): Dt1Block {
|
||||
const tileWidth = tile.width
|
||||
const tileHeight = bitmapHeight
|
||||
if (tileWidth <= 0 || tileHeight <= 0 || tileWidth * tileHeight > (1 << 22)) {
|
||||
throw new CelError(`tile ${String(tileIndex)} has an implausible size ${String(tileWidth)}x${String(tileHeight)}`)
|
||||
}
|
||||
const pixels = new Uint8Array(tileWidth * tileHeight)
|
||||
const decodeWith = (offset: number): Uint8Array => {
|
||||
const encoded = data.subarray(offset, offset + block.length)
|
||||
if (encoded.byteLength < block.length) {
|
||||
throw new CelError(`tile ${String(tileIndex)} block data is truncated`)
|
||||
}
|
||||
const target = new Uint8Array(tileWidth * tileHeight)
|
||||
if (block.format === 1) {
|
||||
decodeIsometric(encoded, target, block, tileWidth, tileHeight, yOffset)
|
||||
} else {
|
||||
decodeRunLength(encoded, target, block, tileWidth, tileHeight, yOffset)
|
||||
}
|
||||
return target
|
||||
}
|
||||
// The offset field's base is not settled by the documentation (`offset in
|
||||
// file`) versus the reference implementation (relative to the block header),
|
||||
// so both readings are tried. An all-transparent result is what a wrong-but-
|
||||
// in-range reading looks like — an RLE stream of zero pairs advances rows and
|
||||
// draws nothing — so a blank first attempt falls through to the alternative
|
||||
// instead of being returned as a silently empty tile.
|
||||
let best: Uint8Array | null = null
|
||||
for (const offset of resolveBlockDataOffset(data, block)) {
|
||||
const decoded = decodeWith(offset)
|
||||
if (decoded.some(value => value !== 0)) return { x: block.x, y: block.y, gridX: block.gridX, gridY: block.gridY, format: block.format, pixels: decoded }
|
||||
best ??= decoded
|
||||
}
|
||||
/* v8 ignore next -- resolveBlockDataOffset guarantees at least one candidate. */
|
||||
if (best === null) throw new CelError(`tile ${String(tileIndex)} block produced no data`)
|
||||
return { x: block.x, y: block.y, gridX: block.gridX, gridY: block.gridY, format: block.format, pixels: best }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay an isometric block onto the tile bitmap.
|
||||
*
|
||||
* @param encoded - the 256-byte block.
|
||||
* @param pixels - the tile bitmap to fill.
|
||||
* @param block - the block placement.
|
||||
* @param tileWidth - tile width.
|
||||
* @param tileHeight - tile height.
|
||||
* @param yOffset - vertical shift applied to every block.
|
||||
*/
|
||||
function decodeIsometric(
|
||||
encoded: Uint8Array,
|
||||
pixels: Uint8Array,
|
||||
block: { x: number; y: number },
|
||||
tileWidth: number,
|
||||
tileHeight: number,
|
||||
yOffset: number,
|
||||
): void {
|
||||
let index = 0
|
||||
for (let row = 0; row < ISO_JUMP.length && index < encoded.byteLength; row += 1) {
|
||||
const startX = ISO_JUMP[row]!
|
||||
const run = ISO_RUN[row]!
|
||||
for (let i = 0; i < run; i += 1) {
|
||||
const x = block.x + startX + i
|
||||
const y = block.y + row + yOffset
|
||||
if (x < 0 || x >= tileWidth || y < 0 || y >= tileHeight) { index += 1; continue }
|
||||
pixels[y * tileWidth + x] = encoded[index]!
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay a run-length encoded block onto the tile bitmap.
|
||||
*
|
||||
* @param encoded - the block's run stream.
|
||||
* @param pixels - the tile bitmap to fill.
|
||||
* @param block - the block placement.
|
||||
* @param tileWidth - tile width.
|
||||
* @param tileHeight - tile height.
|
||||
* @param yOffset - vertical shift applied to every block.
|
||||
*/
|
||||
function decodeRunLength(
|
||||
encoded: Uint8Array,
|
||||
pixels: Uint8Array,
|
||||
block: { x: number; y: number },
|
||||
tileWidth: number,
|
||||
tileHeight: number,
|
||||
yOffset: number,
|
||||
): void {
|
||||
let index = 0
|
||||
let remaining = blockLengthOf(encoded)
|
||||
let x = 0
|
||||
let y = 0
|
||||
while (remaining > 0 && index + 1 < encoded.byteLength) {
|
||||
const skip = encoded[index]!
|
||||
const count = encoded[index + 1]!
|
||||
index += 2
|
||||
remaining -= 2
|
||||
if ((skip | count) === 0) {
|
||||
x = 0
|
||||
y += 1
|
||||
continue
|
||||
}
|
||||
x += skip
|
||||
remaining -= count
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const px = block.x + x + i
|
||||
const py = block.y + y + yOffset
|
||||
if (px >= 0 && px < tileWidth && py >= 0 && py < tileHeight) {
|
||||
pixels[py * tileWidth + px] = encoded[index] ?? 0
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
x += count
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The block's declared length, which the caller substitutes for the stream's
|
||||
* own bound. Kept as a helper so the run-length loop reads the same way as the
|
||||
* reference decoder, which counts down a byte budget rather than testing the
|
||||
* buffer end.
|
||||
*
|
||||
* @param encoded - the block's bytes.
|
||||
* @returns the byte budget.
|
||||
*/
|
||||
function blockLengthOf(encoded: Uint8Array): number {
|
||||
return encoded.byteLength
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Palette formats.
|
||||
*
|
||||
* Both Diablo I and classic Diablo II store a palette as 256 VGA triples in
|
||||
* 768 bytes; Diablo II additionally ships `.pl2` files, which carry the palette
|
||||
* plus per-entity colour shifts (not implemented until a real `.pl2` is at
|
||||
* hand — see the note at the end of this file).
|
||||
*/
|
||||
|
||||
/** A decoded 256-colour palette. */
|
||||
export interface Palette {
|
||||
/** RGB triples, 0..255, 768 bytes. */
|
||||
readonly rgb: Uint8Array
|
||||
/** Number of entries (always 256 for the supported formats). */
|
||||
readonly size: number
|
||||
}
|
||||
|
||||
/** Raised when a palette file is not the supported variant. */
|
||||
export class PaletteError extends Error {}
|
||||
|
||||
/**
|
||||
* Decode a raw 768-byte palette (`.pal`).
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @returns the palette.
|
||||
*/
|
||||
export function decodePal(data: Uint8Array): Palette {
|
||||
if (data.byteLength < 768) {
|
||||
throw new PaletteError(`palette is ${String(data.byteLength)} bytes, expected at least 768`)
|
||||
}
|
||||
return { rgb: data.subarray(0, 768), size: 256 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a 256-byte colour translation table (`.trn`).
|
||||
*
|
||||
* A translation table is a per-index remap applied when drawing: Diablo uses it
|
||||
* for monster variants (a red skeleton, a black bow skeleton, ...) so one
|
||||
* sprite set serves several looks. That is exactly the use case Diablo II's
|
||||
* palette shifts cover too, so the mechanism is shared.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @returns a 256-entry index remap.
|
||||
*/
|
||||
export function decodeTrn(data: Uint8Array): Uint8Array {
|
||||
if (data.byteLength !== 256) {
|
||||
throw new PaletteError(`translation table is ${String(data.byteLength)} bytes, expected 256`)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand palette indices into RGBA, optionally through a translation table.
|
||||
*
|
||||
* @param indices - one palette index per pixel.
|
||||
* @param mask - 1 where the pixel is opaque, 0 where transparent.
|
||||
* @param palette - the palette to resolve colours with.
|
||||
* @param trn - optional index remap applied before the palette lookup.
|
||||
* @returns RGBA8 pixels matching the index buffer's layout.
|
||||
*/
|
||||
export function indicesToRgba(
|
||||
indices: Uint8Array,
|
||||
mask: Uint8Array,
|
||||
palette: Palette,
|
||||
trn?: Uint8Array,
|
||||
): Uint8ClampedArray {
|
||||
const rgba = new Uint8ClampedArray(indices.length * 4)
|
||||
for (let i = 0; i < indices.length; i += 1) {
|
||||
if (mask[i] === 0) continue
|
||||
const index = trn === undefined ? indices[i]! : trn[indices[i]!]!
|
||||
const at = index * 3
|
||||
rgba[i * 4] = palette.rgb[at]!
|
||||
rgba[i * 4 + 1] = palette.rgb[at + 1]!
|
||||
rgba[i * 4 + 2] = palette.rgb[at + 2]!
|
||||
rgba[i * 4 + 3] = 255
|
||||
}
|
||||
return rgba
|
||||
}
|
||||
|
||||
// Diablo II's `.pl2` carries more than a palette: five act palettes plus
|
||||
// "transformation" tables indexed by unit type. Its layout only earns trust
|
||||
// against a real file, so it lands with the rest of the D2 decoders rather than
|
||||
// being written blind here.
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* PCX decoder for the 8-bit, single-plane, RLE-compressed images Diablo I and
|
||||
* II use for UI art.
|
||||
*
|
||||
* Only the variant the games actually ship is supported: manufacturer 0x0A,
|
||||
* encoding 1 (RLE), bitsPerPixel 8, planes 1, with the 256-colour palette in
|
||||
* the trailing 769 bytes (0x0C marker + 768 bytes of VGA triples).
|
||||
*/
|
||||
|
||||
/** One decoded image, ready to blit. */
|
||||
export interface PcxImage {
|
||||
/** Pixel width. */
|
||||
readonly width: number
|
||||
/** Pixel height. */
|
||||
readonly height: number
|
||||
/** RGBA8 pixels, row-major, top-left origin. */
|
||||
readonly rgba: Uint8ClampedArray
|
||||
/** The file's 256-colour palette as RGB triples. */
|
||||
readonly palette: Uint8Array
|
||||
}
|
||||
|
||||
/** Raised when a PCX file is not the supported variant or is truncated. */
|
||||
export class PcxError extends Error {}
|
||||
|
||||
/**
|
||||
* Decode a PCX image.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @returns the decoded image.
|
||||
*/
|
||||
export function decodePcx(data: Uint8Array): PcxImage {
|
||||
if (data.byteLength < 128) throw new PcxError(`truncated header (${String(data.byteLength)} bytes)`)
|
||||
if (data[0] !== 0x0a) throw new PcxError(`bad manufacturer 0x${(data[0] ?? 0).toString(16)}`)
|
||||
const encoding = data[2]
|
||||
const bpp = data[3]
|
||||
const planes = data[65]
|
||||
if (bpp !== 8 || planes !== 1) throw new PcxError(`unsupported layout (bpp ${String(bpp)}, planes ${String(planes)})`)
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
const xMin = view.getUint16(4, true)
|
||||
const yMin = view.getUint16(6, true)
|
||||
const xMax = view.getUint16(8, true)
|
||||
const yMax = view.getUint16(10, true)
|
||||
const bytesPerLine = view.getUint16(66, true)
|
||||
const width = xMax - xMin + 1
|
||||
const height = yMax - yMin + 1
|
||||
if (width <= 0 || height <= 0 || bytesPerLine < width) {
|
||||
throw new PcxError(`implausible geometry ${String(width)}x${String(height)} (stride ${String(bytesPerLine)})`)
|
||||
}
|
||||
|
||||
// The palette is the last 769 bytes, marked by 0x0C.
|
||||
const paletteAt = data.byteLength - 769
|
||||
if (paletteAt < 128 || data[paletteAt] !== 0x0c) {
|
||||
throw new PcxError('missing 256-colour palette trailer')
|
||||
}
|
||||
const palette = data.subarray(paletteAt + 1, paletteAt + 769)
|
||||
|
||||
// Row-major RLE: a byte with the two top bits set is a run of the low six
|
||||
// bits, followed by the value; anything else is one literal pixel.
|
||||
const pixels = new Uint8Array(width * height)
|
||||
let read = 128
|
||||
if (encoding === 1) {
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
let column = 0
|
||||
while (column < bytesPerLine) {
|
||||
if (read >= paletteAt) throw new PcxError('truncated RLE stream')
|
||||
const marker = data[read]!
|
||||
read += 1
|
||||
if ((marker & 0xc0) === 0xc0) {
|
||||
const run = marker & 0x3f
|
||||
if (read >= data.byteLength) throw new PcxError('truncated RLE run')
|
||||
const value = data[read]!
|
||||
read += 1
|
||||
for (let i = 0; i < run && column < bytesPerLine; i += 1, column += 1) {
|
||||
if (column < width) pixels[row * width + column] = value
|
||||
}
|
||||
} else {
|
||||
if (column < width) pixels[row * width + column] = marker
|
||||
column += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (encoding === 0) {
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
for (let column = 0; column < width; column += 1) {
|
||||
const at = 128 + row * bytesPerLine + column
|
||||
if (at >= paletteAt) throw new PcxError('truncated raw image')
|
||||
pixels[row * width + column] = data[at]!
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new PcxError(`unsupported encoding ${String(encoding)}`)
|
||||
}
|
||||
|
||||
const rgba = new Uint8ClampedArray(width * height * 4)
|
||||
for (let i = 0; i < pixels.length; i += 1) {
|
||||
const index = pixels[i]! * 3
|
||||
rgba[i * 4] = palette[index]!
|
||||
rgba[i * 4 + 1] = palette[index + 1]!
|
||||
rgba[i * 4 + 2] = palette[index + 2]!
|
||||
rgba[i * 4 + 3] = 255
|
||||
}
|
||||
return { width, height, rgba, palette }
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* Diablo II `.pl2` palette+transform decoder.
|
||||
*
|
||||
* A PL2 is a base palette followed by a long series of *transform tables*: each
|
||||
* is 256 bytes mapping a palette index to another palette index. They are how
|
||||
* Diablo II does lighting, blending, unit colour shifts and text colours without
|
||||
* ever touching RGB — a unit under a light level or a monster variant is drawn
|
||||
* through a different transform, over the same indexed sprite. That is why the
|
||||
* decoders here keep sprites indexed and resolve colour last.
|
||||
*
|
||||
* The layout is fixed and entirely positional: one 1024-byte base palette, then
|
||||
* tables in this exact order, then a 39-byte text palette and its own transforms.
|
||||
* There is no header, no count and no directory, so the table order *is* the
|
||||
* format — hence the counts written out below rather than derived.
|
||||
*
|
||||
* Confidence note: the base palette's stored byte order is taken from the
|
||||
* reference implementation (r, g, b, unused). A swapped red/blue would only show
|
||||
* against real art, so this is re-checked when a real archive lands.
|
||||
*/
|
||||
|
||||
/** Palette entries (one transform maps every entry). */
|
||||
const PALETTE_COLORS = 256
|
||||
/** Bytes per base-palette entry: r, g, b, one unused byte. */
|
||||
const BASE_ENTRY_BYTES = 4
|
||||
/** Base palette size in bytes (1024). */
|
||||
const BASE_BYTES = PALETTE_COLORS * BASE_ENTRY_BYTES
|
||||
/** Text palette entries. */
|
||||
const TEXT_COLORS = 13
|
||||
/** Bytes per text-palette entry (r, g, b). */
|
||||
const TEXT_ENTRY_BYTES = 3
|
||||
/** Light-level tables. */
|
||||
const LIGHT_LEVEL_VARIATIONS = 32
|
||||
/** Inverse-colour tables. */
|
||||
const INV_COLOR_VARIATIONS = 16
|
||||
/** Coarse alpha-blend groups, each with a full fine table. */
|
||||
const ALPHA_BLEND_COARSE = 3
|
||||
/** Fine alpha-blend tables per coarse group. */
|
||||
const ALPHA_BLEND_FINE = 256
|
||||
/** Additive-blend tables. */
|
||||
const ADDITIVE_BLENDS = 256
|
||||
/** Multiplicative-blend tables. */
|
||||
const MULTIPLY_BLENDS = 256
|
||||
/** Hue-variation tables. */
|
||||
const HUE_VARIATIONS = 111
|
||||
/** Unnamed variation tables (kept as raw tables). */
|
||||
const UNKNOWN_VARIATIONS = 14
|
||||
/** Maximum-component blend tables. */
|
||||
const MAX_COMPONENT_BLENDS = 256
|
||||
/** Text-colour shift tables. */
|
||||
const TEXT_SHIFTS = 13
|
||||
|
||||
/** One transform table: 256 index remaps. */
|
||||
export type Pl2Transform = Uint8Array
|
||||
|
||||
/** A decoded PL2 palette file. */
|
||||
export interface Pl2 {
|
||||
/** Base palette as 768 RGB triples, ready for the sprite decoders. */
|
||||
readonly rgb: Uint8Array
|
||||
/** Light-level tables (index into the base palette). */
|
||||
readonly lightLevels: readonly Pl2Transform[]
|
||||
/** Inverse-colour tables. */
|
||||
readonly inverseColors: readonly Pl2Transform[]
|
||||
/** Selected-unit shift. */
|
||||
readonly selectedUnitShift: Pl2Transform
|
||||
/** Alpha-blend tables, grouped coarsely then finely. */
|
||||
readonly alphaBlend: readonly (readonly Pl2Transform[])[]
|
||||
/** Additive-blend tables. */
|
||||
readonly additiveBlend: readonly Pl2Transform[]
|
||||
/** Multiplicative-blend tables. */
|
||||
readonly multiplyBlend: readonly Pl2Transform[]
|
||||
/** Hue-variation tables. */
|
||||
readonly hueVariations: readonly Pl2Transform[]
|
||||
/** Red-tone shift. */
|
||||
readonly redTones: Pl2Transform
|
||||
/** Green-tone shift. */
|
||||
readonly greenTones: Pl2Transform
|
||||
/** Blue-tone shift. */
|
||||
readonly blueTones: Pl2Transform
|
||||
/** Unnamed variation tables. */
|
||||
readonly unknownVariations: readonly Pl2Transform[]
|
||||
/** Maximum-component blend tables. */
|
||||
readonly maxComponentBlend: readonly Pl2Transform[]
|
||||
/** Darkened colour shift. */
|
||||
readonly darkenedShift: Pl2Transform
|
||||
/** Text palette as RGB triples (39 bytes). */
|
||||
readonly textRgb: Uint8Array
|
||||
/** Text-colour shift tables. */
|
||||
readonly textShifts: readonly Pl2Transform[]
|
||||
}
|
||||
|
||||
/** Raised when a PL2 file is short or malformed. */
|
||||
export class Pl2Error extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'Pl2Error'
|
||||
}
|
||||
}
|
||||
|
||||
/** Transform tables in the order they appear, and how many of each. */
|
||||
const TABLE_ORDER: readonly (readonly [string, number])[] = [
|
||||
['lightLevels', LIGHT_LEVEL_VARIATIONS],
|
||||
['inverseColors', INV_COLOR_VARIATIONS],
|
||||
['selectedUnitShift', 1],
|
||||
// Coarse alpha groups, each a run of fine tables.
|
||||
['alphaBlend0', ALPHA_BLEND_FINE],
|
||||
['alphaBlend1', ALPHA_BLEND_FINE],
|
||||
['alphaBlend2', ALPHA_BLEND_FINE],
|
||||
['additiveBlend', ADDITIVE_BLENDS],
|
||||
['multiplyBlend', MULTIPLY_BLENDS],
|
||||
['hueVariations', HUE_VARIATIONS],
|
||||
['redTones', 1],
|
||||
['greenTones', 1],
|
||||
['blueTones', 1],
|
||||
['unknownVariations', UNKNOWN_VARIATIONS],
|
||||
['maxComponentBlend', MAX_COMPONENT_BLENDS],
|
||||
['darkenedShift', 1],
|
||||
]
|
||||
|
||||
/**
|
||||
* The file size a PL2 must have, in bytes: base palette, every transform, the
|
||||
* text palette and its shifts.
|
||||
*
|
||||
* @returns the expected size.
|
||||
*/
|
||||
export function pl2ExpectedSize(): number {
|
||||
const tables = TABLE_ORDER.reduce((sum, [, count]) => sum + count, 0) + TEXT_SHIFTS
|
||||
return BASE_BYTES + tables * PALETTE_COLORS + TEXT_COLORS * TEXT_ENTRY_BYTES
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a PL2 file.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @returns the palette and its transforms.
|
||||
*/
|
||||
export function decodePl2(data: Uint8Array): Pl2 {
|
||||
const expected = pl2ExpectedSize()
|
||||
if (data.byteLength < expected) {
|
||||
throw new Pl2Error(`PL2 is ${String(data.byteLength)} bytes, expected ${String(expected)}`)
|
||||
}
|
||||
// The base palette interleaves an unused byte per entry; the sprite decoders
|
||||
// want dense RGB triples, so it is repacked rather than aliased.
|
||||
const rgb = new Uint8Array(PALETTE_COLORS * 3)
|
||||
for (let index = 0; index < PALETTE_COLORS; index += 1) {
|
||||
const at = index * BASE_ENTRY_BYTES
|
||||
rgb[index * 3] = data[at]!
|
||||
rgb[index * 3 + 1] = data[at + 1]!
|
||||
rgb[index * 3 + 2] = data[at + 2]!
|
||||
}
|
||||
|
||||
// Transform tables are read as views: 1727 tables is ~442 KB, and nothing
|
||||
// here needs its own copy.
|
||||
let cursor = BASE_BYTES
|
||||
const take = (count: number): Pl2Transform[] => {
|
||||
const tables: Pl2Transform[] = []
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
tables.push(data.subarray(cursor, cursor + PALETTE_COLORS))
|
||||
cursor += PALETTE_COLORS
|
||||
}
|
||||
return tables
|
||||
}
|
||||
const takeOne = (): Pl2Transform => {
|
||||
const table = data.subarray(cursor, cursor + PALETTE_COLORS)
|
||||
cursor += PALETTE_COLORS
|
||||
return table
|
||||
}
|
||||
|
||||
const lightLevels = take(LIGHT_LEVEL_VARIATIONS)
|
||||
const inverseColors = take(INV_COLOR_VARIATIONS)
|
||||
const selectedUnitShift = takeOne()
|
||||
const alphaBlend = [
|
||||
take(ALPHA_BLEND_FINE),
|
||||
take(ALPHA_BLEND_FINE),
|
||||
take(ALPHA_BLEND_FINE),
|
||||
]
|
||||
const additiveBlend = take(ADDITIVE_BLENDS)
|
||||
const multiplyBlend = take(MULTIPLY_BLENDS)
|
||||
const hueVariations = take(HUE_VARIATIONS)
|
||||
const redTones = takeOne()
|
||||
const greenTones = takeOne()
|
||||
const blueTones = takeOne()
|
||||
const unknownVariations = take(UNKNOWN_VARIATIONS)
|
||||
const maxComponentBlend = take(MAX_COMPONENT_BLENDS)
|
||||
const darkenedShift = takeOne()
|
||||
|
||||
const textRgb = new Uint8Array(TEXT_COLORS * TEXT_ENTRY_BYTES)
|
||||
for (let index = 0; index < TEXT_COLORS; index += 1) {
|
||||
const at = cursor + index * TEXT_ENTRY_BYTES
|
||||
textRgb[index * 3] = data[at]!
|
||||
textRgb[index * 3 + 1] = data[at + 1]!
|
||||
textRgb[index * 3 + 2] = data[at + 2]!
|
||||
}
|
||||
cursor += TEXT_COLORS * TEXT_ENTRY_BYTES
|
||||
|
||||
const textShifts = take(TEXT_SHIFTS)
|
||||
|
||||
if (cursor !== expected) {
|
||||
/* v8 ignore next -- the size check above makes this unreachable for well-formed files. */
|
||||
throw new Pl2Error(`PL2 layout consumed ${String(cursor)} bytes, expected ${String(expected)}`)
|
||||
}
|
||||
|
||||
return {
|
||||
rgb,
|
||||
lightLevels, inverseColors, selectedUnitShift, alphaBlend,
|
||||
additiveBlend, multiplyBlend, hueVariations,
|
||||
redTones, greenTones, blueTones,
|
||||
unknownVariations, maxComponentBlend, darkenedShift,
|
||||
textRgb, textShifts,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a transform into a concrete palette.
|
||||
*
|
||||
* @param baseRgb - the base palette as RGB triples.
|
||||
* @param transform - the index remap to apply.
|
||||
* @returns a 768-byte RGB palette.
|
||||
*/
|
||||
export function applyTransform(baseRgb: Uint8Array, transform: Pl2Transform): Uint8Array {
|
||||
const out = new Uint8Array(PALETTE_COLORS * 3)
|
||||
for (let index = 0; index < PALETTE_COLORS; index += 1) {
|
||||
const source = (transform[index] ?? 0) * 3
|
||||
out[index * 3] = baseRgb[source] ?? 0
|
||||
out[index * 3 + 1] = baseRgb[source + 1] ?? 0
|
||||
out[index * 3 + 2] = baseRgb[source + 2] ?? 0
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Shared sprite vocabulary: the frame/group/sheet shape every sprite decoder
|
||||
* produces, regardless of container (Diablo I `.cel`/`.cl2` today, Diablo II
|
||||
* `.dc6`/`.dcc` next).
|
||||
*
|
||||
* Frames stay palette-indexed on purpose. Both games implement unit variants
|
||||
* (a red skeleton, a black bow skeleton) as an index remap applied at draw
|
||||
* time, so keeping indices plus a transparency mask is what lets one decoded
|
||||
* sprite serve every variant without re-decoding — the same reason Diablo II's
|
||||
* palette shifts exist.
|
||||
*/
|
||||
|
||||
/** One decoded frame. */
|
||||
export interface SpriteFrame {
|
||||
/** Frame width in pixels. */
|
||||
readonly width: number
|
||||
/** Frame height in pixels. */
|
||||
readonly height: number
|
||||
/** Palette indices, row-major, `width * height` entries. */
|
||||
readonly indices: Uint8Array
|
||||
/** 1 where the pixel is opaque, 0 where transparent. */
|
||||
readonly mask: Uint8Array
|
||||
}
|
||||
|
||||
/** Frames belonging to one animation direction. */
|
||||
export interface SpriteGroup {
|
||||
/** The group's frames, in animation order. */
|
||||
readonly frames: readonly SpriteFrame[]
|
||||
}
|
||||
|
||||
/** A decoded file: one group for plain sheets, many for multi-direction art. */
|
||||
export interface SpriteSheet {
|
||||
/** The groups, in file order. */
|
||||
readonly groups: readonly SpriteGroup[]
|
||||
/**
|
||||
* The uniform width every frame was decoded with, or null when the frames
|
||||
* were given individual widths.
|
||||
*/
|
||||
readonly width: number | null
|
||||
}
|
||||
|
||||
/** Raised when a sprite file cannot be decoded. */
|
||||
export class CelError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'CelError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Smallest frame width worth trying. */
|
||||
const MIN_WIDTH = 8
|
||||
/**
|
||||
* Largest frame width worth trying. Diablo I's cutscene and panel art is
|
||||
* 640 wide, so the cap has to sit above that; Diablo II's widest sheets stay
|
||||
* below 256.
|
||||
*/
|
||||
const MAX_WIDTH = 1024
|
||||
|
||||
/**
|
||||
* Width candidates for auto-detection, smallest first.
|
||||
*
|
||||
* Only meaningful for formats whose runs are line-bounded (Diablo I `.cel`):
|
||||
* there a width is valid exactly when every row's runs sum to it, so a wrong
|
||||
* width is rejected outright. For formats whose runs may cross rows (`.cl2`,
|
||||
* Diablo II `.dcc`) the run stream is consumed to the frame end under *any*
|
||||
* width, so the width cannot be recovered from the file at all — the game
|
||||
* supplies it per animation, and so must a caller.
|
||||
*
|
||||
* @returns candidate widths.
|
||||
*/
|
||||
export function spriteWidthCandidates(): number[] {
|
||||
const widths: number[] = []
|
||||
for (let width = MIN_WIDTH; width <= MAX_WIDTH; width += 1) widths.push(width)
|
||||
return widths
|
||||
}
|
||||
|
||||
/**
|
||||
* Player animation frame widths.
|
||||
*
|
||||
* These are the values the original game assigns in `SetPlrAnims`
|
||||
* (`Source/player.cpp` of the Devilution reconstruction): every class stands
|
||||
* and walks at 96 (the Hellfire monk at 112), attacks at 128, and drops to 96
|
||||
* for bow and unarmed attacks. They are data, not guesses — a `.cl2` file
|
||||
* cannot be decoded without them.
|
||||
*/
|
||||
export const PLAYER_SPRITE_WIDTH = {
|
||||
/** Stand and walk, base classes. */
|
||||
stand: 96,
|
||||
/** Walk, base classes. */
|
||||
walk: 96,
|
||||
/** Melee attack. */
|
||||
attack: 128,
|
||||
/** Attack without a weapon, or with a bow. */
|
||||
attackNarrow: 96,
|
||||
/** Stand/walk for the Hellfire monk. */
|
||||
monk: 112,
|
||||
/** Melee attack for the Hellfire monk. */
|
||||
monkAttack: 130,
|
||||
} as const
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Diablo II `.tbl` string-table decoder (classic layout).
|
||||
*
|
||||
* A TBL is an *indexed* string table — item, skill, monster and quest names are
|
||||
* referred to by number, not by key:
|
||||
*
|
||||
* ```
|
||||
* u16 crc (unused here; the game uses it as a content checksum)
|
||||
* u16 entryCount
|
||||
* u16 offsets[entryCount] absolute file offsets, one per index
|
||||
* ... strings: u16 characterCount, then characterCount * 2 bytes UTF-16LE
|
||||
* ```
|
||||
*
|
||||
* The classic Diablo II files (`string.tbl`, `expansionstring.tbl`,
|
||||
* `patchstring.tbl`) use exactly this layout. A later "extended" variant adds a
|
||||
* hash table of name/value pairs on top; the community Go package implements
|
||||
* that variant instead, so unlike the other formats in this directory **there is
|
||||
* no independent decoder to diff this one against** — it is checked by
|
||||
* construction (write a table, read it back, including non-ASCII) and is on the
|
||||
* list to confirm against a real `string.tbl`.
|
||||
*
|
||||
* Unused indices are legal: an offset that is zero, or that points past the
|
||||
* table, decodes to `undefined` rather than an empty string, so a caller can
|
||||
* tell "no such string" from "empty string".
|
||||
*/
|
||||
|
||||
/** Bytes of the fixed header: crc + entry count. */
|
||||
const HEADER_BYTES = 4
|
||||
/** Bytes per index-table entry. */
|
||||
const INDEX_BYTES = 2
|
||||
/** Bytes of one string's length prefix. */
|
||||
const LENGTH_BYTES = 2
|
||||
/** Bytes per UTF-16 code unit. */
|
||||
const CODE_UNIT_BYTES = 2
|
||||
|
||||
/** Raised when a TBL file is malformed. */
|
||||
export class TblError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'TblError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a little-endian uint16.
|
||||
*
|
||||
* @param data - the buffer.
|
||||
* @param at - byte offset.
|
||||
* @returns the value.
|
||||
*/
|
||||
function u16(data: Uint8Array, at: number): number {
|
||||
return data[at]! | (data[at + 1]! << 8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a TBL file.
|
||||
*
|
||||
* @param data - the complete file.
|
||||
* @returns one entry per string index; `undefined` where the index is unused.
|
||||
*/
|
||||
export function decodeTbl(data: Uint8Array): (string | undefined)[] {
|
||||
if (data.byteLength < HEADER_BYTES) {
|
||||
throw new TblError(`TBL is ${String(data.byteLength)} bytes, too short for a header`)
|
||||
}
|
||||
const entryCount = u16(data, 2)
|
||||
const indexEnd = HEADER_BYTES + entryCount * INDEX_BYTES
|
||||
if (indexEnd > data.byteLength) {
|
||||
throw new TblError(`TBL declares ${String(entryCount)} entries but the index table runs past the file`)
|
||||
}
|
||||
const decoder = new TextDecoder('utf-16le')
|
||||
const entries: (string | undefined)[] = []
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
const offset = u16(data, HEADER_BYTES + index * INDEX_BYTES)
|
||||
// A zero offset marks an unused index; so does one that cannot hold a
|
||||
// length prefix. Both are normal in shipped tables.
|
||||
if (offset === 0 || offset + LENGTH_BYTES > data.byteLength) {
|
||||
entries.push(undefined)
|
||||
continue
|
||||
}
|
||||
const characters = u16(data, offset)
|
||||
const from = offset + LENGTH_BYTES
|
||||
const to = from + characters * CODE_UNIT_BYTES
|
||||
if (to > data.byteLength) {
|
||||
throw new TblError(`entry ${String(index)} at ${String(offset)} declares ${String(characters)} characters past the file end`)
|
||||
}
|
||||
// The stored count is characters, not bytes; decoding from a view keeps the
|
||||
// conversion (including surrogate pairs) in the platform's hands.
|
||||
entries.push(decoder.decode(data.subarray(from, to)))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an index → string lookup over a decoded table.
|
||||
*
|
||||
* @param entries - a decoded table.
|
||||
* @returns the lookup.
|
||||
*/
|
||||
export function tblLookup(entries: readonly (string | undefined)[]): {
|
||||
/** Number of usable entries. */
|
||||
readonly size: number
|
||||
/**
|
||||
* Read one entry.
|
||||
*
|
||||
* @param index - the string index.
|
||||
* @returns the string, or undefined when the index is unused.
|
||||
*/
|
||||
get: (index: number) => string | undefined
|
||||
} {
|
||||
let size = 0
|
||||
for (const entry of entries) if (entry !== undefined) size += 1
|
||||
return {
|
||||
size,
|
||||
get: index => (index >= 0 && index < entries.length ? entries[index] : undefined),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
/**
|
||||
* Resolving one act's town from Diablo II's own tables.
|
||||
*
|
||||
* The chain is three tables deep and every link is data, so nothing here is
|
||||
* hard-coded per act — which is the point, because five towns with different
|
||||
* sizes, different tile sets and different palettes cannot be a lookup table
|
||||
* anyone maintains by hand:
|
||||
*
|
||||
* `Levels.txt` `Act N - Town` → level id, act, palette index, level type
|
||||
* `LvlTypes.txt` level type id → `File 1..File 32` (the DT1 libraries)
|
||||
* `LvlPrest.txt` level id (`LevelId`) → `File1..File6` (the DS1 quadrants), Dt1Mask
|
||||
* `data\\global\\palette\\actN\\pal.pl2` → the act's palette
|
||||
*
|
||||
* Two details cost real time to find and are encoded here:
|
||||
*
|
||||
* 1. **`LvlPrest` joins on `LevelId`, not on `Def`.** `Def` is the preset's own
|
||||
* id (Act 2's town has `Def = 301`, `LevelId = 40`), so joining on the wrong
|
||||
* column silently returns *other levels'* maps for four of the five acts.
|
||||
* 2. **`Dt1Mask` filters the file list.** Its bits correspond to `File 1..N`;
|
||||
* for all five towns the mask happens to be saturated, but a level whose mask
|
||||
* is not would otherwise load libraries the map never references and index
|
||||
* the rest incorrectly.
|
||||
*/
|
||||
import { decodeDs1 } from '../formats/ds1.ts'
|
||||
import type { Ds1 } from '../formats/ds1.ts'
|
||||
import { decodeDt1 } from '../formats/dt1.ts'
|
||||
import type { Dt1 } from '../formats/dt1.ts'
|
||||
import { decodePl2 } from '../formats/pl2.ts'
|
||||
import type { Pl2 } from '../formats/pl2.ts'
|
||||
import type { MountedArchives } from '../mpq/mount.ts'
|
||||
|
||||
/** Where the data tables live inside an archive. */
|
||||
const EXCEL = 'data\\global\\excel\\'
|
||||
/** Tile libraries and levels are addressed relative to this prefix. */
|
||||
const TILES = 'data\\global\\tiles\\'
|
||||
|
||||
/** A tab-separated table with its header row. */
|
||||
export interface D2Table {
|
||||
/** Column names, in file order. */
|
||||
readonly header: readonly string[]
|
||||
/** Data rows. */
|
||||
readonly rows: readonly (readonly string[])[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode bytes as Latin-1 without touching Node APIs.
|
||||
*
|
||||
* The tables are ASCII, but this module is imported by the browser bundle, so
|
||||
* no `Buffer` and no Node-only globals: chunks keep `String.fromCharCode` from
|
||||
* blowing the argument limit on a 130 KB table.
|
||||
*
|
||||
* @param bytes - the bytes.
|
||||
* @returns the text.
|
||||
*/
|
||||
function latin1(bytes: Uint8Array): string {
|
||||
const chunk = 8192
|
||||
let out = ''
|
||||
for (let at = 0; at < bytes.length; at += chunk) {
|
||||
out += String.fromCharCode(...bytes.subarray(at, Math.min(at + chunk, bytes.length)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one of Diablo II's tab-separated tables.
|
||||
*
|
||||
* Rows end with CRLF and the last line may be empty; both are tolerated because
|
||||
* `Patch_D2.mpq` and `d2data.mpq` disagree about the trailing newline.
|
||||
*
|
||||
* @param bytes - the decoded member.
|
||||
* @returns the table.
|
||||
*/
|
||||
export function parseTable(bytes: Uint8Array): D2Table {
|
||||
const text = latin1(bytes)
|
||||
const lines = text.split(/\r?\n/).filter(line => line.length > 0)
|
||||
const header = (lines.shift() ?? '').split('\t')
|
||||
return { header, rows: lines.map(line => line.split('\t')) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a column out of a row by name.
|
||||
*
|
||||
* @param table - the table.
|
||||
* @param row - the row.
|
||||
* @param column - column name.
|
||||
* @returns the cell, or an empty string when the column is absent.
|
||||
*/
|
||||
export function cell(table: D2Table, row: readonly string[], column: string): string {
|
||||
const index = table.header.indexOf(column)
|
||||
return index === -1 ? '' : (row[index] ?? '')
|
||||
}
|
||||
|
||||
/** The three tables a town resolution needs. */
|
||||
export interface ActTables {
|
||||
readonly levels: D2Table
|
||||
readonly lvltypes: D2Table
|
||||
readonly lvlprest: D2Table
|
||||
}
|
||||
|
||||
/** Everything needed to place and render one level. */
|
||||
export interface LevelInfo {
|
||||
/** Act number, 1..5. */
|
||||
readonly act: number
|
||||
/** `Levels.txt` name, e.g. `Act 1 - Town`. */
|
||||
readonly levelName: string
|
||||
/** `Levels.txt` id. */
|
||||
readonly levelId: number
|
||||
/** Palette slot from `Levels.txt` (`Pal`), 0-based. */
|
||||
readonly paletteIndex: number
|
||||
/** `LvlTypes.txt` name. */
|
||||
readonly levelTypeName: string
|
||||
/** DS1 members, full archive paths. */
|
||||
readonly ds1Names: readonly string[]
|
||||
/** DT1 members, full archive paths, in `File 1..N` order after the mask. */
|
||||
readonly dt1Names: readonly string[]
|
||||
/** `LvlPrest.Dt1Mask`, kept for reporting. */
|
||||
readonly dt1Mask: number
|
||||
/** Level extent in cells, from `Levels.txt`. */
|
||||
readonly sizeX: number
|
||||
readonly sizeY: number
|
||||
/** Palette member path. */
|
||||
readonly paletteName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The same shape under the name the act pages used before levels were generic.
|
||||
*
|
||||
* @deprecated use {@link LevelInfo}; kept so existing call sites keep reading.
|
||||
*/
|
||||
export type ActTown = LevelInfo
|
||||
|
||||
/** Turn a table-relative tile path into a member name. */
|
||||
export function tileMemberPath(relative: string): string {
|
||||
return `${TILES}${relative.replaceAll('/', '\\')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the three tables from a mounted stack.
|
||||
*
|
||||
* @param archives - the mounted archives.
|
||||
* @returns the parsed tables.
|
||||
*/
|
||||
export async function loadActTables(archives: MountedArchives): Promise<ActTables> {
|
||||
const read = async (name: string): Promise<D2Table> => parseTable(await archives.read(`${EXCEL}${name}`))
|
||||
return {
|
||||
levels: await read('levels.txt'),
|
||||
lvltypes: await read('lvltypes.txt'),
|
||||
lvlprest: await read('lvlprest.txt'),
|
||||
}
|
||||
}
|
||||
|
||||
/** A level type's tile libraries, with the mask when the level declares one. */
|
||||
export interface LevelLibraries {
|
||||
/** `LvlTypes.txt` name. */
|
||||
readonly levelTypeName: string
|
||||
/** DT1 member names, in `File 1..N` order. */
|
||||
readonly dt1Names: readonly string[]
|
||||
/**
|
||||
* `LvlPrest.Dt1Mask` when the level has preset rows, else `null`.
|
||||
*
|
||||
* Maze and wilderness levels have **no** `LvlPrest` rows at all — their fixed
|
||||
* pieces are picked by the generators — so there is no mask to apply and the
|
||||
* whole file list is in play.
|
||||
*/
|
||||
readonly dt1Mask: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one level by its `Levels.txt` id.
|
||||
*
|
||||
* Everything after the level row is data: its level type names the DT1
|
||||
* libraries, its `LvlPrest` rows name the DS1 files, and its `Pal` slot selects
|
||||
* the act palette. Towns and dungeons differ only in which rows they hit, so one
|
||||
* function covers both — the earlier town-only version could not reach
|
||||
* Tristram, the Cathedral or the Throne Room at all.
|
||||
*
|
||||
* @param tables - the loaded tables.
|
||||
* @param levelId - `Levels.txt` `Id`.
|
||||
* @param act - act number to report (`Levels.txt` stores it 0-based and often
|
||||
* as 0 for expansion rows, so the caller states it when it knows).
|
||||
* @returns the level description.
|
||||
*/
|
||||
export function resolveLevel(tables: ActTables, levelId: number, act?: number): LevelInfo {
|
||||
const row = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === levelId)
|
||||
if (row === undefined) throw new Error(`Levels.txt has no row with Id ${String(levelId)}`)
|
||||
const paletteIndex = Number(cell(tables.levels, row, 'Pal'))
|
||||
const levelTypeId = cell(tables.levels, row, 'LevelType')
|
||||
|
||||
const typeRow = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === levelTypeId)
|
||||
if (typeRow === undefined) throw new Error(`LvlTypes.txt has no row with Id ${levelTypeId} (level ${String(levelId)})`)
|
||||
|
||||
const preset = tables.lvlprest.rows.filter(candidate => Number(cell(tables.lvlprest, candidate, 'LevelId')) === levelId)
|
||||
if (preset.length === 0) throw new Error(`LvlPrest.txt has no row with LevelId ${String(levelId)}`)
|
||||
|
||||
const dt1Mask = Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask'))
|
||||
const dt1Names: string[] = []
|
||||
for (let slot = 1; slot <= 32; slot += 1) {
|
||||
const value = cell(tables.lvltypes, typeRow, `File ${String(slot)}`)
|
||||
if (value === '' || value === '0') continue
|
||||
// The mask is indexed by the slot's position in the file list, not by the
|
||||
// position of the surviving entries.
|
||||
if ((dt1Mask & (1 << (slot - 1))) === 0) continue
|
||||
dt1Names.push(tileMemberPath(value))
|
||||
}
|
||||
|
||||
const ds1Names: string[] = []
|
||||
for (const presetRow of preset) {
|
||||
for (let file = 1; file <= 6; file += 1) {
|
||||
const value = cell(tables.lvlprest, presetRow, `File${String(file)}`)
|
||||
if (value === '' || value === '0') continue
|
||||
ds1Names.push(tileMemberPath(value))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
act: act ?? Number(cell(tables.levels, row, 'Act')) + 1,
|
||||
levelName: cell(tables.levels, row, 'Name'),
|
||||
levelId,
|
||||
paletteIndex,
|
||||
levelTypeName: cell(tables.lvltypes, typeRow, 'Name'),
|
||||
ds1Names,
|
||||
dt1Names,
|
||||
dt1Mask,
|
||||
sizeX: Number(cell(tables.levels, row, 'SizeX')),
|
||||
sizeY: Number(cell(tables.levels, row, 'SizeY')),
|
||||
paletteName: `data\\global\\palette\\act${String(paletteIndex + 1)}\\pal.pl2`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one act's town.
|
||||
*
|
||||
* @param tables - the loaded tables.
|
||||
* @param act - act number, 1..5.
|
||||
* @returns the town description.
|
||||
*/
|
||||
export function resolveActTown(tables: ActTables, act: number): LevelInfo {
|
||||
const row = tables.levels.rows.find(candidate => cell(tables.levels, candidate, 'Name') === `Act ${String(act)} - Town`)
|
||||
if (row === undefined) throw new Error(`Levels.txt has no "Act ${String(act)} - Town" row`)
|
||||
return resolveLevel(tables, Number(cell(tables.levels, row, 'Id')), act)
|
||||
}
|
||||
|
||||
/** A resolved level with its decoded assets. */
|
||||
export interface LoadedActTown {
|
||||
/** The resolution result. */
|
||||
readonly town: LevelInfo
|
||||
/** The decoded DS1 quadrants. */
|
||||
readonly levels: readonly Ds1[]
|
||||
/** The decoded DT1 libraries, in file-list order. */
|
||||
readonly libraries: readonly Dt1[]
|
||||
/** The act palette. */
|
||||
readonly palette: Pl2
|
||||
/** Total cells across the quadrants. */
|
||||
readonly cells: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and decode one act's town.
|
||||
*
|
||||
* @param archives - the mounted archives.
|
||||
* @param tables - the loaded tables.
|
||||
* @param act - act number, 1..5.
|
||||
* @returns the town with assets.
|
||||
*/
|
||||
export async function loadActTown(
|
||||
archives: MountedArchives,
|
||||
tables: ActTables,
|
||||
act: number,
|
||||
): Promise<LoadedActTown> {
|
||||
return loadLevel(archives, tables, resolveActTown(tables, act))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a level type's DT1 libraries without requiring preset rows.
|
||||
*
|
||||
* `resolveLevel` needs `LvlPrest` rows because it also wants DS1 files; the
|
||||
* generators need only the tile libraries, and 101 of the game's 136 levels have
|
||||
* no preset rows at all.
|
||||
*
|
||||
* @param tables - the loaded tables.
|
||||
* @param levelId - `Levels.txt` `Id`.
|
||||
* @returns the library list.
|
||||
*/
|
||||
export function resolveLevelLibraries(tables: ActTables, levelId: number): LevelLibraries {
|
||||
const row = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === levelId)
|
||||
if (row === undefined) throw new Error(`Levels.txt has no row with Id ${String(levelId)}`)
|
||||
const typeRow = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === cell(tables.levels, row, 'LevelType'))
|
||||
if (typeRow === undefined) throw new Error(`LvlTypes.txt has no row for level ${String(levelId)}`)
|
||||
const preset = tables.lvlprest.rows.filter(candidate => Number(cell(tables.lvlprest, candidate, 'LevelId')) === levelId)
|
||||
const dt1Mask = preset.length === 0 ? null : Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask'))
|
||||
const dt1Names: string[] = []
|
||||
for (let slot = 1; slot <= 32; slot += 1) {
|
||||
const value = cell(tables.lvltypes, typeRow, `File ${String(slot)}`)
|
||||
if (value === '' || value === '0') continue
|
||||
if (dt1Mask !== null && (dt1Mask & (1 << (slot - 1))) === 0) continue
|
||||
dt1Names.push(tileMemberPath(value))
|
||||
}
|
||||
return { levelTypeName: cell(tables.lvltypes, typeRow, 'Name'), dt1Names, dt1Mask }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a resolved level's assets.
|
||||
*
|
||||
* @param archives - the mounted archives.
|
||||
* @param tables - the loaded tables (unused today; kept for symmetry with the
|
||||
* resolver so callers can pass both without re-deriving).
|
||||
* @param town - the resolution result.
|
||||
* @returns the level with assets.
|
||||
*/
|
||||
export async function loadLevel(
|
||||
archives: MountedArchives,
|
||||
tables: ActTables,
|
||||
town: LevelInfo,
|
||||
): Promise<LoadedActTown> {
|
||||
void tables
|
||||
const levels: Ds1[] = []
|
||||
for (const name of town.ds1Names) levels.push(decodeDs1(await archives.read(name)))
|
||||
const libraries: Dt1[] = []
|
||||
for (const name of town.dt1Names) libraries.push(decodeDt1(await archives.read(name)))
|
||||
const palette = decodePl2(await archives.read(town.paletteName))
|
||||
return {
|
||||
town,
|
||||
levels,
|
||||
libraries,
|
||||
palette,
|
||||
cells: levels.reduce((sum, level) => sum + level.width * level.height, 0),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Actor animation: direction-indexed frame playback at the simulation rate.
|
||||
*
|
||||
* Both games drive animation off the same 25 Hz tick the simulation runs on, so
|
||||
* playback is counted in ticks rather than milliseconds — an animation that
|
||||
* looked right at 60 fps would run at the wrong speed and, worse, at a
|
||||
* different speed on different machines.
|
||||
*
|
||||
* The module is deliberately format-agnostic: a clip is just "frames per
|
||||
* direction plus a tick rate". Diablo I's direction-major CL2 sheets, Diablo II's
|
||||
* per-direction DC6 sheets and its composite DCC animations all reduce to that,
|
||||
* and Diablo's own convention is preserved in the direction *index* (0 = south,
|
||||
* turning west), which is also the group order inside those files.
|
||||
*/
|
||||
|
||||
/** One playable animation: frames per direction, plus timing. */
|
||||
export interface ActorClip {
|
||||
/** Clip name (`walk`, `stand`, `attack`, …). */
|
||||
readonly name: string
|
||||
/** Frames per direction in play order; index 0 is south. */
|
||||
readonly directions: readonly (readonly unknown[])[]
|
||||
/** Ticks between frames (25 ticks per second). */
|
||||
readonly ticksPerFrame: number
|
||||
/** Whether the clip advances past its last frame. */
|
||||
readonly loop: boolean
|
||||
}
|
||||
|
||||
/** Convenience alias so callers can stay generic over the frame type. */
|
||||
export type ActorAnimatorFrame<TFrame> = TFrame | undefined
|
||||
|
||||
/**
|
||||
* Plays one clip at a time, for one direction at a time.
|
||||
*
|
||||
* @typeParam TFrame - the frame handle the renderer draws (an atlas placement in
|
||||
* practice; kept generic here so the animator has no rendering dependency).
|
||||
*/
|
||||
export class ActorAnimator<TFrame> {
|
||||
private readonly clips = new Map<string, ActorClip>()
|
||||
private current: ActorClip | null = null
|
||||
private direction = 0
|
||||
private elapsed = 0
|
||||
private index = 0
|
||||
|
||||
/**
|
||||
* Register a clip. A later registration under the same name replaces it.
|
||||
*
|
||||
* @param clip - the clip to register.
|
||||
*/
|
||||
add(clip: ActorClip): void {
|
||||
this.clips.set(clip.name, clip)
|
||||
}
|
||||
|
||||
/** The clip currently playing, if any. */
|
||||
get clipName(): string | null {
|
||||
return this.current?.name ?? null
|
||||
}
|
||||
|
||||
/** Current facing (0 = south, turning west). */
|
||||
get facing(): number {
|
||||
return this.direction
|
||||
}
|
||||
|
||||
/** Index of the frame being shown. */
|
||||
get frameIndex(): number {
|
||||
return this.index
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch clips, restarting only when the clip actually changes.
|
||||
*
|
||||
* A walk cycle that restarts every time the key is re-pressed stutters, so
|
||||
* re-requesting the current clip is a no-op — the same rule the games use.
|
||||
*
|
||||
* @param name - clip name; unknown names are ignored.
|
||||
* @param direction - facing to play it in.
|
||||
*/
|
||||
play(name: string, direction: number): void {
|
||||
const clip = this.clips.get(name)
|
||||
if (clip === undefined) return
|
||||
this.direction = direction
|
||||
if (this.current === clip) return
|
||||
this.current = clip
|
||||
this.elapsed = 0
|
||||
this.index = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance one simulation tick.
|
||||
*
|
||||
* @param direction - facing to keep playing in.
|
||||
*/
|
||||
tick(direction: number): void {
|
||||
this.direction = direction
|
||||
const clip = this.current
|
||||
if (clip === null) return
|
||||
const frames = clip.directions[direction]
|
||||
const count = frames?.length ?? 0
|
||||
if (count <= 1 || clip.ticksPerFrame <= 0) return
|
||||
this.elapsed += 1
|
||||
if (this.elapsed < clip.ticksPerFrame) return
|
||||
this.elapsed = 0
|
||||
if (this.index + 1 < count) {
|
||||
this.index += 1
|
||||
return
|
||||
}
|
||||
this.index = clip.loop ? 0 : count - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame to draw now.
|
||||
*
|
||||
* @returns the frame handle, or undefined when nothing is playing — including
|
||||
* when the current direction has no frames (a sheet with fewer directions than
|
||||
* the actor was asked for).
|
||||
*/
|
||||
frame(): ActorAnimatorFrame<TFrame> {
|
||||
const clip = this.current
|
||||
if (clip === null) return undefined
|
||||
const frames = clip.directions[this.direction]
|
||||
if (frames === undefined || frames.length === 0) return undefined
|
||||
return frames[Math.min(this.index, frames.length - 1)] as TFrame
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
/**
|
||||
* Compositing Diablo II's character and object animations.
|
||||
*
|
||||
* A `DCC` file holds one *layer* of an animation (a head, a torso, a leg, a
|
||||
* shield), and a `COF` file says which layers make up a given animation and in
|
||||
* what order they stack. Neither is drawable alone: the file that says "this is
|
||||
* the Sorceress walking" is the COF, and the pixels are in eight DCCs beside it.
|
||||
*
|
||||
* This module joins the two into the plain `SpriteSheet` the renderer already
|
||||
* consumes, so the rest of the engine treats a character exactly like a tile
|
||||
* atlas. It reuses the path convention the verification script proved out:
|
||||
* a layer record names no file — it carries a *composite type* (which body part)
|
||||
* and a *weapon class*, and the COF's own name carries the animation and weapon
|
||||
* codes, so the sprite path is
|
||||
* `<root><component>/<token><component><variant><animation><weapon>.dcc`.
|
||||
* `<variant>` is an armour/object tier code that lives in the item tables rather
|
||||
* than the COF; the lexicographically first candidate is taken, and the chosen
|
||||
* members are reported so the choice is visible instead of implied.
|
||||
*/
|
||||
import type { MpqArchive } from '../mpq/archive.ts'
|
||||
import type { MountedArchives } from '../mpq/mount.ts'
|
||||
import { decodeCof, cofLayerOrder } from '../formats/cof.ts'
|
||||
import type { CofFile } from '../formats/cof.ts'
|
||||
import { decodeDcc } from '../formats/dcc.ts'
|
||||
import type { DccFile } from '../formats/dcc.ts'
|
||||
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
||||
|
||||
/** Composite-type index → component directory, as the archives name them. */
|
||||
const COMPONENTS: readonly string[] = ['hd', 'tr', 'lg', 'ra', 'la', 'rh', 'lh', 'sh', 's1', 's2']
|
||||
|
||||
/** A composited animation, ready to draw. */
|
||||
export interface CharacterSheet {
|
||||
/** One group per direction, frames in animation order. */
|
||||
readonly sheet: SpriteSheet
|
||||
/** Directions in the COF (16 for characters, fewer for objects). */
|
||||
readonly directions: number
|
||||
/** Frames per direction. */
|
||||
readonly framesPerDirection: number
|
||||
/** Layers the COF declared. */
|
||||
readonly layers: number
|
||||
/** Layer/direction combinations skipped because a sprite was missing. */
|
||||
readonly skipped: number
|
||||
/** The DCC members that were decoded, for provenance. */
|
||||
readonly members: readonly string[]
|
||||
/** Non-fatal observations worth reporting. */
|
||||
readonly notes: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the sprite a COF layer draws.
|
||||
*
|
||||
* @param names - the archive's name list.
|
||||
* @param root - directory shared by the COF and its art, e.g. `data\\global\\chars\\so\\`.
|
||||
* @param token - class or object code, e.g. `so`.
|
||||
* @param animation - two-letter animation code from the COF name, e.g. `wl`.
|
||||
* @param weapon - weapon-class code from the COF name, e.g. `hth`.
|
||||
* @param component - component directory for the layer's composite type.
|
||||
* @returns the member name, or undefined.
|
||||
*/
|
||||
function findLayerSprite(
|
||||
names: readonly string[],
|
||||
root: string,
|
||||
token: string,
|
||||
animation: string,
|
||||
weapon: string,
|
||||
component: string,
|
||||
): string | undefined {
|
||||
const prefix = `${root}${component}\\${token}${component}`
|
||||
const suffix = `${animation}${weapon}.dcc`
|
||||
const hits = names.filter(name => name.toLowerCase().startsWith(prefix.toLowerCase())
|
||||
&& name.toLowerCase().endsWith(suffix.toLowerCase()))
|
||||
hits.sort()
|
||||
return hits[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Blit one layer onto a bigger canvas.
|
||||
*
|
||||
* The frames are palette-indexed with a transparency mask, so compositing is a
|
||||
* "masked copy": later layers paint over earlier ones exactly where they have
|
||||
* pixels, which is the same rule the DT1 tile compositor uses.
|
||||
*
|
||||
* @param target - destination frame (its buffers are written).
|
||||
* @param source - layer frame.
|
||||
* @param atX - destination x.
|
||||
* @param atY - destination y.
|
||||
*/
|
||||
function blit(target: { indices: Uint8Array; mask: Uint8Array; width: number; height: number }, source: SpriteFrame, atX: number, atY: number): void {
|
||||
for (let y = 0; y < source.height; y += 1) {
|
||||
const ty = atY + y
|
||||
if (ty < 0 || ty >= target.height) continue
|
||||
for (let x = 0; x < source.width; x += 1) {
|
||||
const at = y * source.width + x
|
||||
if (source.mask[at] === 0) continue
|
||||
const tx = atX + x
|
||||
if (tx < 0 || tx >= target.width) continue
|
||||
const to = ty * target.width + tx
|
||||
target.indices[to] = source.indices[at]!
|
||||
target.mask[to] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and composite one animation.
|
||||
*
|
||||
* @param archives - the mounted archives.
|
||||
* @param token - class or object code, e.g. `so` for the Sorceress.
|
||||
* @param animation - animation code from the COF name, e.g. `wl` (walk) or `nu` (neutral).
|
||||
* @param weapon - weapon class, e.g. `hth` for unarmed.
|
||||
* @returns the composited animation.
|
||||
*/
|
||||
export async function loadCharacterSheet(
|
||||
archives: MountedArchives,
|
||||
token: string,
|
||||
animation: string,
|
||||
weapon: string,
|
||||
): Promise<CharacterSheet> {
|
||||
const lower = token.toLowerCase()
|
||||
const root = `data\\global\\chars\\${lower}\\`
|
||||
const cofMember = `${root}cof\\${lower}${animation}${weapon}.cof`
|
||||
const cofBytes = await archives.read(cofMember)
|
||||
const cof: CofFile = decodeCof(cofBytes)
|
||||
|
||||
const names = await archives.listFiles()
|
||||
const notes: string[] = []
|
||||
const members: string[] = []
|
||||
const sprites: (DccFile | null)[] = []
|
||||
for (const layer of cof.layers) {
|
||||
const component = COMPONENTS[layer.type]
|
||||
if (component === undefined) {
|
||||
notes.push(`layer type ${String(layer.type)} has no component directory`)
|
||||
sprites.push(null)
|
||||
continue
|
||||
}
|
||||
const member = findLayerSprite(names, root, lower, animation, weapon, component)
|
||||
if (member === undefined) {
|
||||
notes.push(`no sprite for component ${component} (${animation}${weapon})`)
|
||||
sprites.push(null)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
sprites.push(decodeDcc(await archives.read(member)))
|
||||
members.push(member)
|
||||
} catch (err) {
|
||||
notes.push(`${member}: ${(err as Error).message}`)
|
||||
sprites.push(null)
|
||||
}
|
||||
}
|
||||
|
||||
const groups: { frames: SpriteFrame[] }[] = []
|
||||
let skipped = 0
|
||||
for (let direction = 0; direction < cof.numberOfDirections; direction += 1) {
|
||||
const frames: SpriteFrame[] = []
|
||||
for (let index = 0; index < cof.framesPerDirection; index += 1) {
|
||||
// Union box across the layers that have art for this direction, so every
|
||||
// layer keeps its position in sprite space.
|
||||
let left = Number.POSITIVE_INFINITY
|
||||
let top = Number.POSITIVE_INFINITY
|
||||
let right = Number.NEGATIVE_INFINITY
|
||||
let bottom = Number.NEGATIVE_INFINITY
|
||||
let placedCount = 0
|
||||
for (let layer = 0; layer < cof.layers.length; layer += 1) {
|
||||
const sprite = sprites[layer]
|
||||
if (sprite === null || sprite === undefined) continue
|
||||
const layerDirection = sprite.directions[direction % sprite.directions.length]
|
||||
const frame = layerDirection?.frames[index]
|
||||
if (frame === undefined) { skipped += 1; continue }
|
||||
left = Math.min(left, layerDirection!.box.left)
|
||||
top = Math.min(top, layerDirection!.box.top)
|
||||
right = Math.max(right, layerDirection!.box.left + layerDirection!.box.width)
|
||||
bottom = Math.max(bottom, layerDirection!.box.top + layerDirection!.box.height)
|
||||
placedCount += 1
|
||||
}
|
||||
if (placedCount === 0) {
|
||||
frames.push({ width: 1, height: 1, indices: new Uint8Array(1), mask: new Uint8Array(1) })
|
||||
continue
|
||||
}
|
||||
const boxLeft = Math.round(left)
|
||||
const boxTop = Math.round(top)
|
||||
const width = Math.max(1, Math.round(right) - boxLeft)
|
||||
const height = Math.max(1, Math.round(bottom) - boxTop)
|
||||
const composed: SpriteFrame = {
|
||||
width, height, indices: new Uint8Array(width * height), mask: new Uint8Array(width * height),
|
||||
}
|
||||
// Draw in the COF's own back-to-front order for this direction and frame;
|
||||
// the priority table is the authority on what covers what.
|
||||
const order = cofLayerOrder(cof, direction, index)
|
||||
const ordered = order
|
||||
.map(layerIndex => { const sprite = sprites[layerIndex]; return { layerIndex, sprite } })
|
||||
.filter(entry => entry.sprite !== null && entry.sprite !== undefined)
|
||||
for (const entry of ordered) {
|
||||
const sprite = entry.sprite as DccFile
|
||||
const layerDirection = sprite.directions[direction % sprite.directions.length]
|
||||
const frame = layerDirection?.frames[index]
|
||||
if (frame === undefined) continue
|
||||
blit(composed, frame.frame, Math.round(layerDirection!.box.left) - boxLeft, Math.round(layerDirection!.box.top) - boxTop)
|
||||
}
|
||||
frames.push(composed)
|
||||
}
|
||||
groups.push({ frames })
|
||||
}
|
||||
|
||||
return {
|
||||
sheet: { groups, width: null },
|
||||
directions: cof.numberOfDirections,
|
||||
framesPerDirection: cof.framesPerDirection,
|
||||
layers: cof.layers.length,
|
||||
skipped,
|
||||
members,
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine's 64-direction space → COF direction tables.
|
||||
*
|
||||
* Ported from OpenDiablo2 `d2fileformats/d2cof/cof_dir_lookup.go` (`Dir64ToCof`),
|
||||
* which reproduces the engine's own lookup: the mapping is **not**
|
||||
* `floor(dir64 * n / 64)` — it is five literal tables, and they are uneven in
|
||||
* places (16 directions: `dir64` 0 and 1 → 0, 2..5 → 1, 6..9 → 2, …).
|
||||
*/
|
||||
const DIR64_TO_COF: Readonly<Record<number, readonly number[]>> = {
|
||||
4: [
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1,
|
||||
1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2,
|
||||
2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3,
|
||||
3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
],
|
||||
8: [
|
||||
0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2,
|
||||
2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4,
|
||||
4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6,
|
||||
6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 0, 0, 0, 0,
|
||||
],
|
||||
16: [
|
||||
0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4,
|
||||
4, 4, 5, 5, 5, 5, 6, 6, 6, 6, 7, 7, 7, 7, 8, 8,
|
||||
8, 8, 9, 9, 9, 9, 10, 10, 10, 10, 11, 11, 11, 11, 12, 12,
|
||||
12, 12, 13, 13, 13, 13, 14, 14, 14, 14, 15, 15, 15, 15, 0, 0,
|
||||
],
|
||||
32: [
|
||||
0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8,
|
||||
8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 16,
|
||||
16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23, 24,
|
||||
24, 25, 25, 26, 26, 27, 27, 28, 28, 29, 29, 30, 30, 31, 31, 0,
|
||||
],
|
||||
64: Array.from({ length: 64 }, (_, index) => index),
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a 64-direction space direction to a COF direction, the way the engine does.
|
||||
*
|
||||
* @param direction - 0..63.
|
||||
* @param directions - directions in the COF (4, 8, 16, 32 or 64).
|
||||
* @returns the COF direction index, 0 when the count is not one the engine uses.
|
||||
*/
|
||||
export function dir64ToCof(direction: number, directions: number): number {
|
||||
const table = DIR64_TO_COF[directions]
|
||||
if (table === undefined) return 0
|
||||
const index = ((Math.trunc(direction) % 64) + 64) % 64
|
||||
return table[index] ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a screen facing to a COF direction.
|
||||
*
|
||||
* The page's input is eight screen directions starting north, which in the
|
||||
* engine's 64-direction space are the eight multiples of 8 — so this is
|
||||
* {@link dir64ToCof} applied to `facing * 8`. Keeping the conversion in the
|
||||
* engine's terms means the result stays right if the facing ever gets finer
|
||||
* (mouse aiming, 16-way input), where the plain "two steps per facing" reading
|
||||
* would not: with eight facings the two agree, which
|
||||
* `npm run verify:dcc` checks for every direction count.
|
||||
*
|
||||
* @param facing - 0 = north, clockwise, 0..7.
|
||||
* @param directions - directions in the COF.
|
||||
* @returns the direction index.
|
||||
*/
|
||||
export function facingToDirection(facing: number, directions: number): number {
|
||||
return dir64ToCof(facing * 8, directions)
|
||||
}
|
||||
|
||||
/** Decode one DCC member, for callers that only need its first frame. */
|
||||
export async function firstFrameOf(archive: MpqArchive, member: string): Promise<SpriteFrame | undefined> {
|
||||
const file = archive.find(member)
|
||||
if (file === undefined) return undefined
|
||||
const dcc = decodeDcc(await archive.read(file))
|
||||
return dcc.directions[0]?.frames[0]?.frame
|
||||
}
|
||||
|
|
@ -0,0 +1,676 @@
|
|||
/**
|
||||
* Combat sandbox: monsters, melee, resources and experience, at the game's tick
|
||||
* rate.
|
||||
*
|
||||
* The module is deliberately free of rendering and input concerns. It advances
|
||||
* one 25 Hz tick at a time from a small input record and a collision predicate,
|
||||
* which is what allows the whole system to be simulated headlessly in a test —
|
||||
* combat that can only be verified by looking at the screen is combat that will
|
||||
* be broken most of the time.
|
||||
*
|
||||
* Numbers come from data tables (see `tables.ts`), never from constants baked
|
||||
* into the AI: a monster's health, damage, speed and aggro range are its table
|
||||
* row, so swapping in the real `MonStats.txt` changes behaviour without touching
|
||||
* code.
|
||||
*
|
||||
* What this is *not*: it is not Diablo II's combat model. Real damage involves
|
||||
* attack rating versus defence, hit recovery, block, resistances, elemental
|
||||
* damage and per-skill formulas. This is the skeleton those formulas plug into —
|
||||
* the states, cooldowns, resource pools and event stream are the parts that need
|
||||
* to exist first, and they are the parts whose shape is independent of the exact
|
||||
* arithmetic.
|
||||
*/
|
||||
import { numberCell, textCell } from './tables.ts'
|
||||
import type { DataTable } from './tables.ts'
|
||||
|
||||
/** Movement request and intent for one tick. */
|
||||
export interface CombatInput {
|
||||
/** Normalized movement, screen axes (y grows downward). */
|
||||
readonly movement: { readonly x: number; readonly y: number }
|
||||
/** Whether the attack control is held this tick. */
|
||||
readonly attack: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* What the simulation needs from the world it moves in.
|
||||
*
|
||||
* The predicate reports *how many* solid sub-tiles a position overlaps rather
|
||||
* than a yes/no, because movement needs the count: a body that is already
|
||||
* overlapping something (a bad spawn, a tile that turned solid under it) must
|
||||
* still be able to walk out, and the rule that allows it is "never make the
|
||||
* overlap worse". A boolean cannot express that, and a body stuck forever is a
|
||||
* far worse failure than one that briefly clips a corner.
|
||||
*/
|
||||
export interface CombatTerrain {
|
||||
/**
|
||||
* Count solid sub-tiles overlapping a body position.
|
||||
*
|
||||
* @param x - body centre x.
|
||||
* @param y - body centre y.
|
||||
* @returns the overlap count (0 = clear ground).
|
||||
*/
|
||||
readonly overlap: (x: number, y: number) => number
|
||||
}
|
||||
|
||||
/** Tuning that is not per-monster. */
|
||||
export interface CombatOptions {
|
||||
/** Player walk speed in pixels per second. */
|
||||
readonly playerSpeed: number
|
||||
/** Player melee reach in pixels. */
|
||||
readonly playerReach: number
|
||||
/** Ticks between player attacks. */
|
||||
readonly playerCooldownTicks: number
|
||||
/** Player damage per hit. */
|
||||
readonly playerDamage: number
|
||||
/** Mana spent per attack (0 disables the cost). */
|
||||
readonly playerManaPerAttack: number
|
||||
/** Ticks before a dead player is restored, at full resources. */
|
||||
readonly respawnTicks: number
|
||||
}
|
||||
|
||||
/** One monster's immutable numbers, read from a table row. */
|
||||
export interface MonsterStats {
|
||||
/** Identifier, as written in the table. */
|
||||
readonly id: string
|
||||
/** Display name. */
|
||||
readonly name: string
|
||||
/** Maximum health. */
|
||||
readonly hp: number
|
||||
/** Damage per hit. */
|
||||
readonly damage: number
|
||||
/** Ticks between attacks. */
|
||||
readonly cooldownTicks: number
|
||||
/** Reach in pixels. */
|
||||
readonly reach: number
|
||||
/** Distance at which the monster notices the player, in pixels. */
|
||||
readonly aggroRadius: number
|
||||
/** Speed in pixels per second. */
|
||||
readonly speed: number
|
||||
/** Experience awarded on death. */
|
||||
readonly xp: number
|
||||
}
|
||||
|
||||
/** One monster's mutable state. */
|
||||
export interface Monster {
|
||||
/** Stable index, for events and debugging. */
|
||||
readonly index: number
|
||||
/** Its table numbers. */
|
||||
readonly stats: MonsterStats
|
||||
/** World x. */
|
||||
x: number
|
||||
/** World y. */
|
||||
y: number
|
||||
/** Current health. */
|
||||
hp: number
|
||||
/** Ticks until it may attack again. */
|
||||
cooldown: number
|
||||
/** What it is doing. */
|
||||
state: 'idle' | 'chase' | 'attack' | 'dead'
|
||||
/** Facing (0 = south, turning west), for the renderer. */
|
||||
facing: number
|
||||
/** Ticks left of the hit flash, for the renderer. */
|
||||
hitFlash: number
|
||||
/** Ticks left of the death animation. */
|
||||
corpseTicks: number
|
||||
}
|
||||
|
||||
/** The player's combat-relevant state. */
|
||||
export interface CombatPlayer {
|
||||
/** World x. */
|
||||
x: number
|
||||
/** World y. */
|
||||
y: number
|
||||
/** Current health. */
|
||||
hp: number
|
||||
/** Maximum health. */
|
||||
maxHp: number
|
||||
/** Current mana. */
|
||||
mana: number
|
||||
/** Maximum mana. */
|
||||
maxMana: number
|
||||
/** Character level. */
|
||||
level: number
|
||||
/** Experience accumulated. */
|
||||
xp: number
|
||||
/** Ticks until the next attack. */
|
||||
cooldown: number
|
||||
/** Facing (0 = south, turning west). */
|
||||
facing: number
|
||||
/** Whether the player is alive. */
|
||||
alive: boolean
|
||||
/** Ticks left until respawn while dead. */
|
||||
respawnIn: number
|
||||
/** Ticks left of the swing animation, for the renderer. */
|
||||
swingTicks: number
|
||||
}
|
||||
|
||||
/** Something worth telling the player (and the renderer) about. */
|
||||
export interface CombatEvent {
|
||||
/** Event kind. */
|
||||
readonly kind: 'playerHit' | 'monsterHit' | 'kill' | 'levelUp' | 'playerDeath' | 'respawn' | 'noMana'
|
||||
/** World x the event happened at. */
|
||||
readonly x: number
|
||||
/** World y the event happened at. */
|
||||
readonly y: number
|
||||
/** Magnitude (damage), when meaningful. */
|
||||
readonly amount?: number
|
||||
/** Text to float above the point, when meaningful. */
|
||||
readonly text?: string
|
||||
/** Subject identity (a monster's table id on a kill, for quest counters). */
|
||||
readonly subjectId?: string
|
||||
}
|
||||
|
||||
/** The whole simulation state. */
|
||||
export interface CombatWorld {
|
||||
/**
|
||||
* The local player, for the single-player scene and every existing caller.
|
||||
*
|
||||
* In a networked game this is one of {@link CombatWorld.players}, and which one
|
||||
* is a *view* decision: the simulation must not depend on it, which is why the
|
||||
* state digest covers `players` in peer order and never this field.
|
||||
*
|
||||
* **Invariant:** this object is the same object as one entry of `players`. A
|
||||
* world assembled by copying fields — a save being loaded, a snapshot being
|
||||
* restored — breaks that unless {@link rebindPlayer} is called, and the symptom
|
||||
* is a world that simulates one body while the screen draws another.
|
||||
*/
|
||||
player: CombatPlayer
|
||||
/**
|
||||
* Every player in the world, in peer order, index 0 first.
|
||||
*
|
||||
* Monsters pick the nearest living entry, so two peers whose worlds hold the
|
||||
* same players in the same order see the same monsters make the same choices.
|
||||
*/
|
||||
players: CombatPlayer[]
|
||||
/** Every monster, dead ones included until their corpse timer expires. */
|
||||
monsters: Monster[]
|
||||
/** Ticks simulated. */
|
||||
tick: number
|
||||
/** Events produced during the last tick (cleared each tick). */
|
||||
events: CombatEvent[]
|
||||
/** Monsters killed. */
|
||||
kills: number
|
||||
}
|
||||
|
||||
/** Defaults that keep the sandbox playable when a table is thin. */
|
||||
const DEFAULTS = {
|
||||
hp: 20,
|
||||
damage: 3,
|
||||
cooldownTicks: 25,
|
||||
reach: 40,
|
||||
aggroRadius: 220,
|
||||
speed: 90,
|
||||
xp: 10,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Read one monster's numbers from a table row.
|
||||
*
|
||||
* @param row - the record.
|
||||
* @param rowIndex - row position, used to build a fallback id.
|
||||
* @returns the stats.
|
||||
*/
|
||||
export function monsterStatsFromRow(row: Readonly<Record<string, string>>, rowIndex: number): MonsterStats {
|
||||
return {
|
||||
id: textCell(row, 'Id', `monster${String(rowIndex)}`),
|
||||
name: textCell(row, 'Name', textCell(row, 'Id', `Monster ${String(rowIndex)}`)),
|
||||
hp: numberCell(row, 'HP', DEFAULTS.hp),
|
||||
damage: numberCell(row, 'Damage', DEFAULTS.damage),
|
||||
cooldownTicks: numberCell(row, 'CooldownTicks', DEFAULTS.cooldownTicks),
|
||||
reach: numberCell(row, 'Reach', DEFAULTS.reach),
|
||||
aggroRadius: numberCell(row, 'AggroRadius', DEFAULTS.aggroRadius),
|
||||
speed: numberCell(row, 'Speed', DEFAULTS.speed),
|
||||
xp: numberCell(row, 'XP', DEFAULTS.xp),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every monster definition in a table.
|
||||
*
|
||||
* @param table - a `MonStats`-shaped table.
|
||||
* @returns the definitions.
|
||||
*/
|
||||
export function monsterStatsFromTable(table: DataTable): MonsterStats[] {
|
||||
return table.rows.map((row, index) => monsterStatsFromRow(row, index))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the experience required per level.
|
||||
*
|
||||
* @param table - an `Experience`-shaped table with a `Level` and an `XP` column.
|
||||
* @returns required cumulative experience by level, index 1 = level 1.
|
||||
*/
|
||||
export function experienceTable(table: DataTable): number[] {
|
||||
const levels: number[] = [0, 0]
|
||||
for (const row of table.rows) {
|
||||
const level = numberCell(row, 'Level', 0)
|
||||
if (level < 1) continue
|
||||
levels[level] = numberCell(row, 'XP', 0)
|
||||
}
|
||||
for (let level = 2; level < levels.length; level += 1) {
|
||||
levels[level] = Math.max(levels[level] ?? 0, levels[level - 1] ?? 0)
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a world with a player at a position.
|
||||
*
|
||||
* @param x - player x.
|
||||
* @param y - player y.
|
||||
* @param maxHp - starting maximum health.
|
||||
* @param maxMana - starting maximum mana.
|
||||
* @returns the world.
|
||||
*/
|
||||
export function createWorld(x: number, y: number, maxHp = 60, maxMana = 30): CombatWorld {
|
||||
const player = createPlayer(x, y, maxHp, maxMana)
|
||||
return {
|
||||
player,
|
||||
players: [player],
|
||||
monsters: [],
|
||||
tick: 0,
|
||||
events: [],
|
||||
kills: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one player body.
|
||||
*
|
||||
* @param x - x.
|
||||
* @param y - y.
|
||||
* @param maxHp - starting maximum health.
|
||||
* @param maxMana - starting maximum mana.
|
||||
* @returns the player.
|
||||
*/
|
||||
export function createPlayer(x: number, y: number, maxHp = 60, maxMana = 30): CombatPlayer {
|
||||
return {
|
||||
x, y, hp: maxHp, maxHp, mana: maxMana, maxMana,
|
||||
level: 1, xp: 0, cooldown: 0, facing: 0, alive: true, respawnIn: 0, swingTicks: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-establish the invariant that the local player is in the player list.
|
||||
*
|
||||
* The combat tick drives `players` while the scene and the renderer read
|
||||
* `player`; a world built by spreading one object over another ends up with a
|
||||
* fresh `player` and the *previous* `players` array, so those two would drift
|
||||
* apart on the next tick. A loaded save is a single-player world by definition,
|
||||
* so the list becomes exactly this one player.
|
||||
*
|
||||
* @param world - the world to repair (mutated in place).
|
||||
* @returns the same world.
|
||||
*/
|
||||
export function rebindPlayer(world: CombatWorld): CombatWorld {
|
||||
world.players = [world.player]
|
||||
return world
|
||||
}
|
||||
|
||||
/**
|
||||
* Add another player to the world, as a joining peer would.
|
||||
*
|
||||
* The new body is appended, so its index is its peer index and every peer agrees
|
||||
* on the order. Nothing about the joining player is derived from the local view.
|
||||
*
|
||||
* @param world - the world.
|
||||
* @param x - spawn x.
|
||||
* @param y - spawn y.
|
||||
* @param maxHp - starting maximum health.
|
||||
* @param maxMana - starting maximum mana.
|
||||
* @returns the player that was added.
|
||||
*/
|
||||
export function addPlayer(world: CombatWorld, x: number, y: number, maxHp = 60, maxMana = 30): CombatPlayer {
|
||||
const player = createPlayer(x, y, maxHp, maxMana)
|
||||
world.players.push(player)
|
||||
return player
|
||||
}
|
||||
|
||||
/**
|
||||
* The player a monster should go for: the nearest of this tick's targets.
|
||||
*
|
||||
* Nearest rather than "player 0" because a monster that ignores the peer standing
|
||||
* on top of it reads as broken, and because the choice has to be a pure function
|
||||
* of the world for two peers to agree.
|
||||
*
|
||||
* @param monster - the monster choosing.
|
||||
* @param targets - the players it may attack.
|
||||
* @returns the target, or null when there is none.
|
||||
*/
|
||||
function nearestTarget(monster: Monster, targets: readonly CombatPlayer[]): CombatPlayer | null {
|
||||
let best: CombatPlayer | null = null
|
||||
let bestDistance = Number.POSITIVE_INFINITY
|
||||
for (const candidate of targets) {
|
||||
const distance = Math.hypot(candidate.x - monster.x, candidate.y - monster.y)
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Place monsters around a point, skipping unwalkable spots.
|
||||
*
|
||||
* Spawns are rejected rather than nudged: dropping a monster into a wall because
|
||||
* nothing better was found is how units end up permanently stuck, and the caller
|
||||
* can see the shortfall in the return value.
|
||||
*
|
||||
* @param world - the world to populate.
|
||||
* @param stats - the definitions to spawn from.
|
||||
* @param count - how many to spawn.
|
||||
* @param around - spawn centre.
|
||||
* @param spread - spawn radius in pixels.
|
||||
* @param terrain - collision predicate.
|
||||
* @returns how many were actually placed.
|
||||
*/
|
||||
export function spawnMonsters(
|
||||
world: CombatWorld,
|
||||
stats: readonly MonsterStats[],
|
||||
count: number,
|
||||
around: { readonly x: number; readonly y: number },
|
||||
spread: number,
|
||||
terrain: CombatTerrain,
|
||||
): number {
|
||||
if (stats.length === 0) return 0
|
||||
let placed = 0
|
||||
let attempt = 0
|
||||
while (placed < count && attempt < count * 24) {
|
||||
attempt += 1
|
||||
// A deterministic spiral keeps spawns reproducible, which is what makes a
|
||||
// failing test repeatable.
|
||||
const angle = attempt * 2.399963
|
||||
const radius = spread * Math.sqrt(attempt / (count * 24))
|
||||
const x = around.x + Math.cos(angle) * radius
|
||||
const y = around.y + Math.sin(angle) * radius
|
||||
if (terrain.overlap(x, y) > 0) continue
|
||||
const definition = stats[placed % stats.length]!
|
||||
world.monsters.push({
|
||||
index: world.monsters.length,
|
||||
stats: definition,
|
||||
x, y,
|
||||
hp: definition.hp,
|
||||
cooldown: 0,
|
||||
state: 'idle',
|
||||
facing: 0,
|
||||
hitFlash: 0,
|
||||
corpseTicks: 0,
|
||||
})
|
||||
placed += 1
|
||||
}
|
||||
return placed
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction index (0 = south, turning west) for a vector.
|
||||
*
|
||||
* @param dx - horizontal component.
|
||||
* @param dy - vertical component.
|
||||
* @returns the facing.
|
||||
*/
|
||||
function facingOf(dx: number, dy: number): number {
|
||||
const sx = Math.sign(dx)
|
||||
const sy = Math.sign(dy)
|
||||
if (sx === 0 && sy === 0) return 0
|
||||
if (sx === 0) return sy > 0 ? 0 : 4
|
||||
if (sy === 0) return sx > 0 ? 6 : 2
|
||||
if (sx < 0) return sy > 0 ? 1 : 3
|
||||
return sy > 0 ? 7 : 5
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a body one tick, sliding along blocked axes.
|
||||
*
|
||||
* @param from - current position.
|
||||
* @param dx - desired delta x.
|
||||
* @param dy - desired delta y.
|
||||
* @param terrain - collision predicate.
|
||||
* @returns the new position.
|
||||
*/
|
||||
function moveWithCollision(
|
||||
from: { readonly x: number; readonly y: number },
|
||||
dx: number,
|
||||
dy: number,
|
||||
terrain: CombatTerrain,
|
||||
): { x: number; y: number } {
|
||||
let { x, y } = from
|
||||
const current = terrain.overlap(x, y)
|
||||
// Axis-separated: a blocked axis stops while the other slides along the wall.
|
||||
if (dx !== 0 && terrain.overlap(x + dx, y) <= current) x += dx
|
||||
if (dy !== 0 && terrain.overlap(x, y + dy) <= current) y += dy
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply damage to one monster from any source (a melee swing, a projectile).
|
||||
*
|
||||
* Kill handling lives here rather than at each call site so experience, the kill
|
||||
* counter, the corpse timer and the event stream behave identically however the
|
||||
* damage arrived — which is the difference between "the skill works" and "the
|
||||
* skill works but kills do not count".
|
||||
*
|
||||
* @param world - the world.
|
||||
* @param monsterIndex - which monster.
|
||||
* @param amount - damage to apply.
|
||||
* @param attacker - who dealt it; the killer is credited with the experience.
|
||||
* Defaults to the local player, which is what a single-player caller means.
|
||||
* @returns true when this damage killed it.
|
||||
*/
|
||||
export function damageMonster(world: CombatWorld, monsterIndex: number, amount: number, attacker: CombatPlayer = world.player): boolean {
|
||||
const monster = world.monsters[monsterIndex]
|
||||
if (monster === undefined || monster.state === 'dead') return false
|
||||
monster.hp -= amount
|
||||
monster.hitFlash = 4
|
||||
world.events.push({ kind: 'monsterHit', x: monster.x, y: monster.y, amount })
|
||||
if (monster.hp > 0) return false
|
||||
monster.state = 'dead'
|
||||
monster.corpseTicks = 100
|
||||
world.kills += 1
|
||||
world.events.push({ kind: 'kill', x: monster.x, y: monster.y, text: monster.stats.name, subjectId: monster.stats.id })
|
||||
attacker.xp += monster.stats.xp
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the simulation one tick for one player.
|
||||
*
|
||||
* @param world - the world to advance (mutated in place).
|
||||
* @param input - this tick's movement and attack intent.
|
||||
* @param options - non-per-monster tuning.
|
||||
* @param terrain - collision predicate.
|
||||
* @param xpTable - cumulative experience required per level, index 1 = level 1.
|
||||
*/
|
||||
export function tickCombat(
|
||||
world: CombatWorld,
|
||||
input: CombatInput,
|
||||
options: CombatOptions,
|
||||
terrain: CombatTerrain,
|
||||
xpTable: readonly number[],
|
||||
): void {
|
||||
tickCombatMulti(world, [input], options, terrain, xpTable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the simulation one tick for several players at once.
|
||||
*
|
||||
* One tick, not one per player: the tick counter, the event stream and the
|
||||
* monster turn all happen once, while every player's own input is applied before
|
||||
* the monsters act. Calling {@link tickCombat} once per player instead would run
|
||||
* the monsters twice in a tick and desync two peers against each other.
|
||||
*
|
||||
* `inputs[i]` drives `players[i]`. A peer with no input this tick simply does not
|
||||
* act — it does not fall back to someone else's controls.
|
||||
*
|
||||
* @param world - the world to advance (mutated in place).
|
||||
* @param inputs - one input per player, in peer order.
|
||||
* @param options - non-per-monster tuning.
|
||||
* @param terrain - collision predicate.
|
||||
* @param xpTable - cumulative experience required per level, index 1 = level 1.
|
||||
*/
|
||||
export function tickCombatMulti(
|
||||
world: CombatWorld,
|
||||
inputs: readonly CombatInput[],
|
||||
options: CombatOptions,
|
||||
terrain: CombatTerrain,
|
||||
xpTable: readonly number[],
|
||||
): void {
|
||||
world.tick += 1
|
||||
world.events = []
|
||||
// Who the monsters may attack is decided at the *start* of the tick. A player
|
||||
// who comes back to life during this tick is therefore safe in it, and a world
|
||||
// with nobody alive gives the monsters nothing to do: their cooldowns and the
|
||||
// corpses hold still rather than running down against a player who cannot
|
||||
// answer. Both rules exist to keep death and respawn reading the way they do in
|
||||
// single player — you die, the world waits, you come back with a moment to move.
|
||||
const targets = world.players.filter(player => player.alive)
|
||||
for (let index = 0; index < inputs.length; index += 1) {
|
||||
const player = world.players[index]
|
||||
if (player === undefined) continue
|
||||
tickPlayer(world, player, inputs[index]!, options, terrain, xpTable)
|
||||
}
|
||||
if (targets.length > 0) tickMonsters(world, options, terrain, targets)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one player's turn: respawn, movement, attack.
|
||||
*
|
||||
* @param world - the world.
|
||||
* @param player - the player acting.
|
||||
* @param input - its input.
|
||||
* @param options - non-per-monster tuning.
|
||||
* @param terrain - collision predicate.
|
||||
* @param xpTable - cumulative experience required per level.
|
||||
*/
|
||||
function tickPlayer(
|
||||
world: CombatWorld,
|
||||
player: CombatPlayer,
|
||||
input: CombatInput,
|
||||
options: CombatOptions,
|
||||
terrain: CombatTerrain,
|
||||
xpTable: readonly number[],
|
||||
): void {
|
||||
if (!player.alive) {
|
||||
player.respawnIn -= 1
|
||||
if (player.respawnIn <= 0) {
|
||||
player.alive = true
|
||||
player.hp = player.maxHp
|
||||
player.mana = player.maxMana
|
||||
world.events.push({ kind: 'respawn', x: player.x, y: player.y })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (player.cooldown > 0) player.cooldown -= 1
|
||||
if (player.swingTicks > 0) player.swingTicks -= 1
|
||||
|
||||
// Movement first, so an attack this tick happens from where the player ended up.
|
||||
const speed = options.playerSpeed / 25
|
||||
const moved = moveWithCollision(player, input.movement.x * speed, input.movement.y * speed, terrain)
|
||||
player.x = moved.x
|
||||
player.y = moved.y
|
||||
if (input.movement.x !== 0 || input.movement.y !== 0) {
|
||||
player.facing = facingOf(input.movement.x, input.movement.y)
|
||||
}
|
||||
|
||||
// Player melee: nearest living monster in reach, if the attack is ready.
|
||||
if (input.attack && player.cooldown === 0) {
|
||||
const cost = options.playerManaPerAttack
|
||||
if (cost > 0 && player.mana < cost) {
|
||||
world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' })
|
||||
} else {
|
||||
player.mana = Math.max(0, player.mana - cost)
|
||||
player.cooldown = options.playerCooldownTicks
|
||||
player.swingTicks = Math.max(1, Math.floor(options.playerCooldownTicks / 2))
|
||||
let target: Monster | null = null
|
||||
let bestDistance = Number.POSITIVE_INFINITY
|
||||
for (const monster of world.monsters) {
|
||||
if (monster.state === 'dead') continue
|
||||
const distance = Math.hypot(monster.x - player.x, monster.y - player.y)
|
||||
if (distance <= options.playerReach && distance < bestDistance) {
|
||||
bestDistance = distance
|
||||
target = monster
|
||||
}
|
||||
}
|
||||
if (target === null) {
|
||||
world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' })
|
||||
} else {
|
||||
player.facing = facingOf(target.x - player.x, target.y - player.y)
|
||||
target.hp -= options.playerDamage
|
||||
target.hitFlash = 4
|
||||
world.events.push({ kind: 'monsterHit', x: target.x, y: target.y, amount: options.playerDamage })
|
||||
if (target.hp <= 0) {
|
||||
target.state = 'dead'
|
||||
target.corpseTicks = 100
|
||||
world.kills += 1
|
||||
world.events.push({ kind: 'kill', x: target.x, y: target.y, text: target.stats.name, subjectId: target.stats.id })
|
||||
player.xp += target.stats.xp
|
||||
// Level up while the threshold is crossed; the table is cumulative.
|
||||
while (player.level + 1 < xpTable.length && player.xp >= (xpTable[player.level + 1] ?? Number.POSITIVE_INFINITY)) {
|
||||
player.level += 1
|
||||
player.maxHp += 10
|
||||
player.maxMana += 5
|
||||
player.hp = player.maxHp
|
||||
player.mana = player.maxMana
|
||||
world.events.push({ kind: 'levelUp', x: player.x, y: player.y, text: `level ${String(player.level)}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* The monsters' turn: notice, close in, strike. Corpses decay.
|
||||
*
|
||||
* @param world - the world.
|
||||
* @param options - non-per-monster tuning.
|
||||
* @param terrain - collision predicate.
|
||||
* @param targets - the players that were alive when the tick began.
|
||||
*/
|
||||
function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: CombatTerrain, targets: readonly CombatPlayer[]): void {
|
||||
for (const monster of world.monsters) {
|
||||
if (monster.state === 'dead') {
|
||||
if (monster.corpseTicks > 0) monster.corpseTicks -= 1
|
||||
continue
|
||||
}
|
||||
if (monster.hitFlash > 0) monster.hitFlash -= 1
|
||||
if (monster.cooldown > 0) monster.cooldown -= 1
|
||||
|
||||
const player = nearestTarget(monster, targets)
|
||||
if (player === null) {
|
||||
monster.state = 'idle'
|
||||
continue
|
||||
}
|
||||
const dx = player.x - monster.x
|
||||
const dy = player.y - monster.y
|
||||
const distance = Math.hypot(dx, dy)
|
||||
|
||||
if (distance > monster.stats.aggroRadius) {
|
||||
monster.state = 'idle'
|
||||
continue
|
||||
}
|
||||
monster.facing = facingOf(dx, dy)
|
||||
if (distance <= monster.stats.reach) {
|
||||
monster.state = 'attack'
|
||||
if (monster.cooldown === 0) {
|
||||
monster.cooldown = monster.stats.cooldownTicks
|
||||
player.hp -= monster.stats.damage
|
||||
world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: monster.stats.damage })
|
||||
if (player.hp <= 0) {
|
||||
player.hp = 0
|
||||
player.alive = false
|
||||
player.respawnIn = options.respawnTicks
|
||||
world.events.push({ kind: 'playerDeath', x: player.x, y: player.y, text: 'you died' })
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
monster.state = 'chase'
|
||||
const step = monster.stats.speed / 25
|
||||
const movedMonster = moveWithCollision(monster, (dx / distance) * step, (dy / distance) * step, terrain)
|
||||
monster.x = movedMonster.x
|
||||
monster.y = movedMonster.y
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,646 @@
|
|||
/**
|
||||
* Diablo II's own map projection: a DS1 level plus the DT1 libraries it
|
||||
* references, composited into something renderable and walkable.
|
||||
*
|
||||
* This is deliberately *not* `map.ts`. That module places one library on a
|
||||
* rectangular grid, which is what the synthetic fixtures look like. Real Diablo
|
||||
* II levels use an isometric lattice, and the difference is not cosmetic:
|
||||
*
|
||||
* 1. **Cells are diamonds.** A cell at `(cx, cy)` sits at
|
||||
* `((cx - cy) * 80, (cx + cy) * 40)` — the projection OpenDiablo2's viewport
|
||||
* calls `WorldToOrtho`. The 5×5 collision grid inside a cell is therefore
|
||||
* 16×8 per sub-tile, not 32×32.
|
||||
* 2. **A DS1 cell's `style` picks the library, not the tile.** Style indexes the
|
||||
* level type's `File 1..File 32` list (`LvlTypes.txt`), and each DT1 in that
|
||||
* list numbers its own tiles by `sequence` — so a lookup keyed on
|
||||
* `style:sequence` inside one merged library (what `map.ts` does) finds
|
||||
* nothing as soon as a level uses more than one library.
|
||||
* 3. **Empty slots are explicit.** A cell carries a fixed number of wall slots
|
||||
* and most of them are placeholders: in Act 1's town, 4275 of 4674 wall
|
||||
* entries have `prop1 == 0`. Drawing them paints garbage, which is why the
|
||||
* reference renderer tests `prop1 != 0`.
|
||||
* 4. **Walls hang above their cell.** A wall's art starts at a negative block
|
||||
* `y` and is shifted down by `-minBlockY` when decoded, so it must be drawn
|
||||
* at `cell + minBlockY + 80`; floors ignore that term entirely.
|
||||
*/
|
||||
import type { Ds1 } from '../formats/ds1.ts'
|
||||
import type { Dt1, Dt1Tile } from '../formats/dt1.ts'
|
||||
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
||||
import { SUB_TILES_PER_TILE } from './map.ts'
|
||||
|
||||
/** Cell width in screen pixels (the isometric diamond's width). */
|
||||
export const ORTHO_CELL_WIDTH = 80
|
||||
/** Cell height in screen pixels (half the diamond's width: a 2:1 projection). */
|
||||
export const ORTHO_CELL_HEIGHT = 40
|
||||
/** Sub-tile width in screen pixels. */
|
||||
export const ORTHO_SUB_TILE_WIDTH = ORTHO_CELL_WIDTH / SUB_TILES_PER_TILE
|
||||
/** Sub-tile height in screen pixels. */
|
||||
export const ORTHO_SUB_TILE_HEIGHT = ORTHO_CELL_HEIGHT / SUB_TILES_PER_TILE
|
||||
/** Horizontal shift applied to every tile bitmap (half a cell). */
|
||||
const TILE_ANCHOR_X = -80
|
||||
/** Vertical shift added to walls: the tail of `YAdjust = minBlockY + 80`. */
|
||||
const WALL_SURFACE_HEIGHT = 80
|
||||
/**
|
||||
* DT1 `type` of a shadow tile.
|
||||
*
|
||||
* The engine asks for the shadow art with this type (`d2maprenderer/renderer.go`
|
||||
* `getImageCacheRecord(style, sequence, 13, ...)`), so a DS1 shadow layer's
|
||||
* reference is resolved against type 13 tiles, not against floors or walls.
|
||||
*/
|
||||
const SHADOW_TILE_TYPE = 13
|
||||
/**
|
||||
* DT1 `type` of a floor tile.
|
||||
*
|
||||
* DS1 floor records carry no type field; the engine resolves them as DT1 type 0
|
||||
* (`d2mapstamp/stamp.go` asks for `TileData(style, sequence, 0)`), and it is the
|
||||
* only type a floor slot may draw.
|
||||
*/
|
||||
const FLOOR_TILE_TYPE = 0
|
||||
/**
|
||||
* DS1 wall type of a roof tile.
|
||||
*
|
||||
* OpenDiablo2's tile-type enum puts `TileRoof` at 15 (`d2enum/tile.go`), and the
|
||||
* engine draws roofs in a pass of their own, last of all, with a different
|
||||
* vertical offset: `YAdjust = -roofHeight` instead of `minBlockY + 80`
|
||||
* (`d2maprenderer/tile_cache.go`). A roof therefore has to stay out of the wall
|
||||
* painter order and out of the wall offset rule.
|
||||
*/
|
||||
const ROOF_WALL_TYPE = 15
|
||||
/** One tile to draw, in screen pixels. */
|
||||
export interface IsoDraw {
|
||||
/** Index into {@link IsoMapScene.frames}. */
|
||||
readonly frameIndex: number
|
||||
/** Screen x of the bitmap's left edge, origin applied. */
|
||||
readonly x: number
|
||||
/** Screen y of the bitmap's top edge, origin applied. */
|
||||
readonly y: number
|
||||
/** Cell coordinates, for depth sorting and entity insertion. */
|
||||
readonly cellX: number
|
||||
readonly cellY: number
|
||||
/** Library index this tile came from, for diagnostics. */
|
||||
readonly library: number
|
||||
/** The library's tile index, for diagnostics. */
|
||||
readonly tile: number
|
||||
}
|
||||
|
||||
/** A renderable, walkable Diablo II level. */
|
||||
export interface IsoMapScene {
|
||||
/** One frame per distinct tile this level actually uses. */
|
||||
readonly frames: readonly SpriteFrame[]
|
||||
/** The same frames as a sheet, ready for the atlas packer. */
|
||||
readonly sheet: SpriteSheet
|
||||
/** Cells horizontally. */
|
||||
readonly cellsX: number
|
||||
/** Cells vertically. */
|
||||
readonly cellsY: number
|
||||
/** Scene width in pixels, origin applied. */
|
||||
readonly widthPx: number
|
||||
/** Scene height in pixels, origin applied. */
|
||||
readonly heightPx: number
|
||||
/** Screen x added to every coordinate so the minimum is 0. */
|
||||
readonly originX: number
|
||||
/** Screen y added to every coordinate so the minimum is 0. */
|
||||
readonly originY: number
|
||||
/** Floor draws, in cell order. */
|
||||
readonly floors: readonly IsoDraw[]
|
||||
/** Wall draws, in painter's order (isometric depth). */
|
||||
readonly walls: readonly IsoDraw[]
|
||||
/**
|
||||
* Roof draws, in painter's order.
|
||||
*
|
||||
* The engine paints roofs in a pass of their own, after everything else
|
||||
* (`d2maprenderer/renderer.go` pass 4), so they must not be mixed into
|
||||
* {@link IsoMapScene.walls}: a roof covers the floor, the walls *and* whatever
|
||||
* walks under it.
|
||||
*/
|
||||
readonly roofs: readonly IsoDraw[]
|
||||
/** Collision grid, row-major, 1 = blocked, 5×5 per cell. */
|
||||
readonly blocked: Uint8Array
|
||||
/** Collision grid width in sub-tiles. */
|
||||
readonly gridWidth: number
|
||||
/** Collision grid height in sub-tiles. */
|
||||
readonly gridHeight: number
|
||||
/** References that resolved to no tile (missing library or sequence). */
|
||||
readonly missingTiles: number
|
||||
/**
|
||||
* The unresolved references themselves, capped for display.
|
||||
*
|
||||
* Diablo II's DS1 files contain a few slots that reference nothing in any
|
||||
* library — Act 2's town has one wall at `style 30`, a style no Act 2 library
|
||||
* defines. The game draws nothing for those, so the count is reported rather
|
||||
* than treated as a decoding failure; a real chain break shows up as hundreds
|
||||
* of them, which is why the caller can still threshold on the count.
|
||||
*/
|
||||
readonly missingRefs: readonly string[]
|
||||
/**
|
||||
* Wall references whose art needed a taller bitmap than the tile's declared
|
||||
* height (an expected quirk of real libraries, not an error).
|
||||
*/
|
||||
readonly clippedTiles: number
|
||||
/** References that several libraries could satisfy (first library wins). */
|
||||
readonly duplicateRefs: number
|
||||
/**
|
||||
* Sub-tiles that became blocked **only** because a shadow layer blocked them.
|
||||
*
|
||||
* The engine unions the sub-tile flags of every layer of a cell before reading
|
||||
* walkability, and shadows are a layer of their own (`type 13`). If this is ever
|
||||
* non-zero, dropping shadows from the union would have changed the map.
|
||||
*/
|
||||
readonly shadowBlockedSubtiles: number
|
||||
/**
|
||||
* References that only the type-agnostic fallback could satisfy.
|
||||
*
|
||||
* Should be 0: a non-zero value means a reference drew a tile of the wrong
|
||||
* *type* (a wall where a floor belongs), which is exactly the defect
|
||||
* `npm run verify:tiles` exists to catch.
|
||||
*/
|
||||
readonly looseRefs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite a library tile's blocks into one indexed bitmap.
|
||||
*
|
||||
* The bitmap is the size Diablo II allocates for the tile: `width` by
|
||||
* `|height|`. Blocks were already shifted into that box by the decoder.
|
||||
*
|
||||
* @param tile - the library tile.
|
||||
* @returns the frame.
|
||||
*/
|
||||
function tileToFrame(tile: Dt1Tile): SpriteFrame {
|
||||
const width = Math.max(tile.width, 1)
|
||||
const height = Math.max(tile.bitmapHeight, 1)
|
||||
const indices = new Uint8Array(width * height)
|
||||
const mask = new Uint8Array(width * height)
|
||||
for (const block of tile.blocks) {
|
||||
for (let at = 0; at < indices.length && at < block.pixels.length; at += 1) {
|
||||
const value = block.pixels[at]!
|
||||
if (value === 0) continue
|
||||
indices[at] = value
|
||||
mask[at] = 1
|
||||
}
|
||||
}
|
||||
return { width, height, indices, mask }
|
||||
}
|
||||
|
||||
/** One tile of the merged pool. */
|
||||
interface PoolTile {
|
||||
readonly library: number
|
||||
readonly tile: number
|
||||
/** Variant weight (`Dt1Tile.rarityFrameIndex`) used when picking per cell. */
|
||||
readonly weight: number
|
||||
}
|
||||
|
||||
/** The merged pool a DS1's references are resolved against. */
|
||||
interface TilePool {
|
||||
readonly exact: Map<string, PoolTile[]>
|
||||
readonly loose: Map<string, PoolTile[]>
|
||||
/** References that more than one tile could satisfy (the random variants). */
|
||||
duplicates: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge every library of a level into one lookup pool.
|
||||
*
|
||||
* The key insight — and the one that a single-library reader gets wrong — is
|
||||
* that a DS1 cell's `style` matches the **tile's own `style` field inside a
|
||||
* DT1**, not the position of the DT1 in the level type's file list. Act 1's
|
||||
* `River.dt1` holds tiles with internal styles 2 and 3 while `Fence.dt1` holds
|
||||
* only style 0, so treating `style` as a library index looks plausible (it
|
||||
* resolves most of the town) and then misses every reference the real styles
|
||||
* would have caught.
|
||||
*
|
||||
* @param libraries - the level type's libraries, in file-list order.
|
||||
* @returns the pool.
|
||||
*/
|
||||
function mergeLibraries(libraries: readonly Dt1[]): TilePool {
|
||||
const exact = new Map<string, PoolTile[]>()
|
||||
const loose = new Map<string, PoolTile[]>()
|
||||
let duplicates = 0
|
||||
const push = (map: Map<string, PoolTile[]>, key: string, entry: PoolTile, countDuplicate: boolean): void => {
|
||||
const bucket = map.get(key)
|
||||
if (bucket === undefined) { map.set(key, [entry]); return }
|
||||
if (countDuplicate) duplicates += 1
|
||||
bucket.push(entry)
|
||||
}
|
||||
libraries.forEach((library, libraryIndex) => {
|
||||
library.tiles.forEach((tile, tileIndex) => {
|
||||
const entry = { library: libraryIndex, tile: tileIndex, weight: tile.rarityFrameIndex }
|
||||
// The third component is the DS1 wall `type` and it matches the DT1 tile's
|
||||
// **`Type` field at offset +20**, not its `Direction` field at +0. `Direction`
|
||||
// is the orientation/variant index (1..5 in practice) and never equals the
|
||||
// semantic types (14 tree, 15 roof, ...); keying on it sent every roof/tree
|
||||
// reference through the loose fallback, which drew a floor tile where a tent
|
||||
// canopy belonged. Multiple tiles can share style:sequence:type — those are
|
||||
// the real variants `pickVariant` draws among.
|
||||
push(exact, `${String(tile.style)}:${String(tile.sequence)}:${String(tile.type)}`, entry, true)
|
||||
push(loose, `${String(tile.style)}:${String(tile.sequence)}`, entry, false)
|
||||
})
|
||||
})
|
||||
return { exact, loose, duplicates }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one variant out of a `style:sequence` group, the way the engine does.
|
||||
*
|
||||
* A DT1 ships several tiles for the same `style`/`sequence` (different grass
|
||||
* patches, stone patterns, torch variants), and the engine chooses **per cell**
|
||||
* with a weighted random draw whose weight is the tile's `RarityFrameIndex`,
|
||||
* seeded from the map seed and the cell's own `x`/`y`. This is the port of
|
||||
* OpenDiablo2's `getRandomTile` (`d2mapengine/map_tile.go`):
|
||||
*
|
||||
* tileSeed = (seed + x) * y; tileSeed ^= tileSeed << 13; ^= >> 17; ^= << 5
|
||||
* random = tileSeed % Σ weight; first tile whose running sum >= random wins
|
||||
*
|
||||
* The seed is a per-level constant (the DS1 member name hashed) rather than a
|
||||
* random game seed, so the offline bake and the in-browser decode pick the *same*
|
||||
* variant — that is what keeps `verify:packs` a byte comparison.
|
||||
*
|
||||
* @param candidates - the group, in library/file order.
|
||||
* @param cellX - cell x, the way the engine seeds it.
|
||||
* @param cellY - cell y.
|
||||
* @param seed - per-level seed.
|
||||
* @returns the chosen tile, or undefined for an empty group.
|
||||
*/
|
||||
function pickVariant(
|
||||
candidates: readonly PoolTile[] | undefined,
|
||||
cellX: number,
|
||||
cellY: number,
|
||||
seed: number,
|
||||
): PoolTile | undefined {
|
||||
if (candidates === undefined || candidates.length === 0) return undefined
|
||||
if (candidates.length === 1) return candidates[0]
|
||||
let state = (BigInt(seed >>> 0) + BigInt(cellX >>> 0)) * BigInt(cellY >>> 0)
|
||||
const mask = (1n << 64n) - 1n
|
||||
state &= mask
|
||||
state = (state ^ (state << 13n)) & mask
|
||||
state = (state ^ (state >> 17n)) & mask
|
||||
state = (state ^ (state << 5n)) & mask
|
||||
let total = 0
|
||||
for (const candidate of candidates) total += Math.max(0, candidate.weight | 0)
|
||||
if (total === 0) return candidates[0]
|
||||
const roll = Number(state % BigInt(total))
|
||||
let running = 0
|
||||
for (const candidate of candidates) {
|
||||
running += Math.max(0, candidate.weight | 0)
|
||||
if (running > roll) return candidate
|
||||
}
|
||||
return candidates[candidates.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a DS1 reference against the pool.
|
||||
*
|
||||
* @param pool - the merged pool.
|
||||
* @param style - DS1 style field.
|
||||
* @param sequence - DS1 sequence field.
|
||||
* @param type - DS1 wall type (the third field of the reference).
|
||||
* @returns the pool tile, or undefined when nothing matches.
|
||||
*/
|
||||
function resolveExactType(
|
||||
pool: TilePool,
|
||||
style: number,
|
||||
sequence: number,
|
||||
type: number,
|
||||
cellX: number,
|
||||
cellY: number,
|
||||
seed: number,
|
||||
): PoolTile | undefined {
|
||||
return pickVariant(pool.exact.get(`${String(style)}:${String(sequence)}:${String(type)}`), cellX, cellY, seed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a DS1 reference against the pool.
|
||||
*
|
||||
* @param pool - the merged pool.
|
||||
* @param style - DS1 style field.
|
||||
* @param sequence - DS1 sequence field.
|
||||
* @param type - DS1 wall type (the third field of the reference), or null for floors.
|
||||
* @returns the pool tile, or undefined when nothing matches.
|
||||
*/
|
||||
function resolveWithSource(
|
||||
pool: TilePool,
|
||||
style: number,
|
||||
sequence: number,
|
||||
type: number,
|
||||
cellX: number,
|
||||
cellY: number,
|
||||
seed: number,
|
||||
): { tile: PoolTile | undefined; viaLoose: boolean } {
|
||||
const key = `${String(style)}:${String(sequence)}`
|
||||
const exact = pickVariant(pool.exact.get(`${key}:${String(type)}`), cellX, cellY, seed)
|
||||
if (exact !== undefined) return { tile: exact, viaLoose: false }
|
||||
return { tile: pickVariant(pool.loose.get(key), cellX, cellY, seed), viaLoose: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-level variant seed for {@link buildIsoMapScene}.
|
||||
*
|
||||
* Derived from the DS1 member name so it is stable across processes: the packer
|
||||
* hashes the name it read out of the archives, and the browser hashes the name it
|
||||
* is about to fetch, so both draw the same tile variants.
|
||||
*
|
||||
* @param name - the DS1 member name, e.g. `data\global\tiles\Act1\Town\townN1.ds1`.
|
||||
* @returns a 32-bit seed.
|
||||
*/
|
||||
export function levelSeed(name: string): number {
|
||||
let hash = 0x811c9dc5
|
||||
for (let index = 0; index < name.length; index += 1) {
|
||||
hash ^= name.charCodeAt(index)
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0
|
||||
}
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the renderable scene.
|
||||
*
|
||||
* @param level - the decoded DS1.
|
||||
* @param libraries - the level type's DT1 libraries, in `File 1..N` order, so a
|
||||
* cell's `style` can index straight into it.
|
||||
* @param seed - per-level seed for the per-cell tile variant draw. Both the
|
||||
* offline bake and the in-browser decode must pass the same value (the packer
|
||||
* uses {@link levelSeed}); 0 keeps the old deterministic first-variant result.
|
||||
* @returns the scene.
|
||||
*/
|
||||
export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0): IsoMapScene {
|
||||
const pool = mergeLibraries(libraries)
|
||||
const frames: SpriteFrame[] = []
|
||||
/** `library:tile` → frame index, so repeated references share one frame. */
|
||||
const frameOfTile = new Map<string, number>()
|
||||
const frameFor = (libraryIndex: number, tileIndex: number): number => {
|
||||
const key = `${String(libraryIndex)}:${String(tileIndex)}`
|
||||
const existing = frameOfTile.get(key)
|
||||
if (existing !== undefined) return existing
|
||||
const index = frames.length
|
||||
frames.push(tileToFrame(libraries[libraryIndex]!.tiles[tileIndex]!))
|
||||
frameOfTile.set(key, index)
|
||||
return index
|
||||
}
|
||||
|
||||
const cellsX = level.width
|
||||
const cellsY = level.height
|
||||
const gridWidth = cellsX * SUB_TILES_PER_TILE
|
||||
const gridHeight = cellsY * SUB_TILES_PER_TILE
|
||||
const blocked = new Uint8Array(gridWidth * gridHeight)
|
||||
const rawFloors: { frameIndex: number; x: number; y: number; cellX: number; cellY: number; library: number; tile: number }[] = []
|
||||
const rawWalls: typeof rawFloors = []
|
||||
/** Roof draws (`wall.type` 15): kept apart so they can be painted last. */
|
||||
const rawRoofs: typeof rawFloors = []
|
||||
let missingTiles = 0
|
||||
let clippedTiles = 0
|
||||
const missingRefs: string[] = []
|
||||
const noteMissing = (kind: string, style: number, sequence: number, direction: number | null, cellX: number, cellY: number): void => {
|
||||
missingTiles += 1
|
||||
if (missingRefs.length < 20) {
|
||||
const dirText = direction === null ? '' : `:${String(direction)}`
|
||||
missingRefs.push(`${kind} style=${String(style)} sequence=${String(sequence)}${dirText} @cell(${String(cellX)},${String(cellY)})`)
|
||||
}
|
||||
}
|
||||
|
||||
// The engine ORs the sub-tile flags of *every* layer of a cell (floor, wall,
|
||||
// shadow) and then reads walkability off the combined flags — OpenDiablo2's
|
||||
// `SubTileFlags.Combine` is a plain `||` of every bit (`d2dt1/subtile.go`).
|
||||
// Stamping each layer into the same grid is the same union for walkability, but
|
||||
// shadows must be included: they are a distinct DS1 layer (`type 13` tiles) and
|
||||
// skipping them would silently drop blocking sub-tiles.
|
||||
let shadowBlocked = 0
|
||||
let looseRefs = 0
|
||||
const stamp = (cellX: number, cellY: number, tile: Dt1Tile): void => {
|
||||
for (let subY = 0; subY < SUB_TILES_PER_TILE; subY += 1) {
|
||||
for (let subX = 0; subX < SUB_TILES_PER_TILE; subX += 1) {
|
||||
const flags = tile.subTileFlags[subY * SUB_TILES_PER_TILE + subX]
|
||||
if (flags === undefined || (!flags.blockWalk && !flags.blockPlayerWalk)) continue
|
||||
const gx = cellX * SUB_TILES_PER_TILE + subX
|
||||
const gy = cellY * SUB_TILES_PER_TILE + subY
|
||||
if (gx < 0 || gx >= gridWidth || gy < 0 || gy >= gridHeight) continue
|
||||
blocked[gy * gridWidth + gx] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let cellY = 0; cellY < cellsY; cellY += 1) {
|
||||
for (let cellX = 0; cellX < cellsX; cellX += 1) {
|
||||
const cell = level.cells[cellY]?.[cellX]
|
||||
if (cell === undefined) continue
|
||||
const orthoX = (cellX - cellY) * ORTHO_CELL_WIDTH
|
||||
const orthoY = (cellX + cellY) * ORTHO_CELL_HEIGHT
|
||||
|
||||
for (const floor of cell.floors) {
|
||||
// A zero `prop1` marks an unused slot, which most wall slots are.
|
||||
if (floor.hidden || floor.prop1 === 0) continue
|
||||
// A DS1 floor record has no `type` field: the engine reads it as DT1
|
||||
// **type 0**. Passing `null` here (before this fix) sent every floor through
|
||||
// the type-agnostic fallback, whose pool mixes floors, walls, pillars,
|
||||
// shadows, trees and roofs — so a weighted-random draw could put a dark wall
|
||||
// tile in a floor slot, drawn with the floor's offset (no `minBlockY + 80`),
|
||||
// which is the "misplaced black block on the ground" defect.
|
||||
const picked = resolveWithSource(pool, floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY, seed)
|
||||
if (picked.viaLoose) looseRefs += 1
|
||||
const found = picked.tile
|
||||
if (found === undefined) { noteMissing('floor', floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY); continue }
|
||||
const tile = libraries[found.library]!.tiles[found.tile]!
|
||||
rawFloors.push({
|
||||
frameIndex: frameFor(found.library, found.tile),
|
||||
x: orthoX + TILE_ANCHOR_X,
|
||||
y: orthoY,
|
||||
cellX, cellY, library: found.library, tile: found.tile,
|
||||
})
|
||||
stamp(cellX, cellY, tile)
|
||||
}
|
||||
|
||||
// Shadows are a collision-only layer here: they are resolved exactly like the
|
||||
// engine resolves them (`type 13`) and folded into the grid, but nothing is
|
||||
// drawn from them.
|
||||
for (const shadow of cell.shadows) {
|
||||
if (shadow.prop1 === 0) continue
|
||||
// Shadows must come from a **type 13** tile specifically: the loose
|
||||
// fallback ignores the type field and would happily return a wall tile,
|
||||
// stamping its blocking flags as if they were a shadow's.
|
||||
const found = resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed)
|
||||
if (found === undefined) continue
|
||||
const tile = libraries[found.library]!.tiles[found.tile]!
|
||||
for (let sub = 0; sub < SUB_TILES_PER_TILE * SUB_TILES_PER_TILE; sub += 1) {
|
||||
const flags = tile.subTileFlags[sub]
|
||||
if (flags === undefined || (!flags.blockWalk && !flags.blockPlayerWalk)) continue
|
||||
const gx = cellX * SUB_TILES_PER_TILE + (sub % SUB_TILES_PER_TILE)
|
||||
const gy = cellY * SUB_TILES_PER_TILE + Math.floor(sub / SUB_TILES_PER_TILE)
|
||||
if (gx < 0 || gx >= gridWidth || gy < 0 || gy >= gridHeight) continue
|
||||
if (blocked[gy * gridWidth + gx] === 0) shadowBlocked += 1
|
||||
blocked[gy * gridWidth + gx] = 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const wall of cell.walls) {
|
||||
if (wall.hidden || wall.prop1 === 0) continue
|
||||
const pickedWall = resolveWithSource(pool, wall.style, wall.sequence, wall.type, cellX, cellY, seed)
|
||||
if (pickedWall.viaLoose) looseRefs += 1
|
||||
const found = pickedWall.tile
|
||||
if (found === undefined) { noteMissing('wall', wall.style, wall.sequence, wall.type, cellX, cellY); continue }
|
||||
const tile = libraries[found.library]!.tiles[found.tile]!
|
||||
if (tile.bitmapHeight > Math.abs(tile.height)) clippedTiles += 1
|
||||
const draw = {
|
||||
frameIndex: frameFor(found.library, found.tile),
|
||||
x: orthoX + TILE_ANCHOR_X,
|
||||
// A wall's art extends above its cell, so it is pushed back down by
|
||||
// exactly the block shift the decoder applied, plus one cell height.
|
||||
y: orthoY + tile.minBlockY + WALL_SURFACE_HEIGHT,
|
||||
cellX, cellY, library: found.library, tile: found.tile,
|
||||
}
|
||||
if (wall.type === ROOF_WALL_TYPE) {
|
||||
// Roofs use the engine's own roof offset and are painted after
|
||||
// everything else, so they leave the wall painter order entirely.
|
||||
rawRoofs.push({ ...draw, y: orthoY - tile.roofHeight })
|
||||
} else {
|
||||
rawWalls.push(draw)
|
||||
}
|
||||
stamp(cellX, cellY, tile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Isometric painter's order: depth grows along the diagonal, so walls sort by
|
||||
// (cellX + cellY) and then row, which keeps a nearer wall in front of the one
|
||||
// behind it without any depth buffer.
|
||||
rawWalls.sort((a, b) => (a.cellY + a.cellX) - (b.cellY + b.cellX) || a.cellY - b.cellY || a.x - b.x)
|
||||
rawRoofs.sort((a, b) => (a.cellY + a.cellX) - (b.cellY + b.cellX) || a.cellY - b.cellY || a.x - b.x)
|
||||
|
||||
// Shift everything positive: `(cellX - cellY)` is negative on half the map.
|
||||
let minX = 0
|
||||
let minY = 0
|
||||
let maxX = 0
|
||||
let maxY = 0
|
||||
for (const draw of [...rawFloors, ...rawWalls, ...rawRoofs]) {
|
||||
const frame = frames[draw.frameIndex]!
|
||||
minX = Math.min(minX, draw.x)
|
||||
minY = Math.min(minY, draw.y)
|
||||
maxX = Math.max(maxX, draw.x + frame.width)
|
||||
maxY = Math.max(maxY, draw.y + frame.height)
|
||||
}
|
||||
const originX = -minX
|
||||
const originY = -minY
|
||||
const shift = (draw: (typeof rawFloors)[number]): IsoDraw => ({ ...draw, x: draw.x + originX, y: draw.y + originY })
|
||||
|
||||
return {
|
||||
frames,
|
||||
sheet: { groups: [{ frames }], width: null },
|
||||
cellsX,
|
||||
cellsY,
|
||||
widthPx: maxX - minX,
|
||||
heightPx: maxY - minY,
|
||||
originX,
|
||||
originY,
|
||||
floors: rawFloors.map(shift),
|
||||
walls: rawWalls.map(shift),
|
||||
roofs: rawRoofs.map(shift),
|
||||
blocked,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
missingTiles,
|
||||
missingRefs,
|
||||
clippedTiles,
|
||||
shadowBlockedSubtiles: shadowBlocked,
|
||||
looseRefs,
|
||||
duplicateRefs: pool.duplicates,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of a scene that collision and projection need.
|
||||
*
|
||||
* A packed map and a freshly decoded one share these fields but not their
|
||||
* storage (one has an atlas, the other PNG pages), so the helpers below ask for
|
||||
* this shape instead of the whole scene.
|
||||
*/
|
||||
export interface CollisionGrid {
|
||||
/** Screen x added to every coordinate so the minimum is 0. */
|
||||
readonly originX: number
|
||||
/** Screen y added to every coordinate so the minimum is 0. */
|
||||
readonly originY: number
|
||||
/** Cells horizontally. */
|
||||
readonly cellsX: number
|
||||
/** Cells vertically. */
|
||||
readonly cellsY: number
|
||||
/** Collision grid, 1 = blocked. */
|
||||
readonly blocked: Uint8Array
|
||||
/** Collision grid width in sub-tiles. */
|
||||
readonly gridWidth: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The cell containing a scene-space point.
|
||||
*
|
||||
* Inverts the isometric projection: `cx = (x/80 + y/40) / 2`, `cy = (y/40 -
|
||||
* x/80) / 2`.
|
||||
*
|
||||
* @param scene - the scene.
|
||||
* @param x - scene-space x.
|
||||
* @param y - scene-space y.
|
||||
* @returns cell coordinates, unrounded.
|
||||
*/
|
||||
export function cellAt(scene: CollisionGrid, x: number, y: number): { x: number; y: number } {
|
||||
const px = x - scene.originX
|
||||
const py = y - scene.originY
|
||||
return {
|
||||
x: (px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2,
|
||||
y: (py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scene-space point lies on a blocked sub-tile.
|
||||
*
|
||||
* @param scene - the scene.
|
||||
* @param x - scene-space x.
|
||||
* @param y - scene-space y.
|
||||
* @returns true when walking is blocked there.
|
||||
*/
|
||||
export function isBlockedAt(scene: CollisionGrid, x: number, y: number): boolean {
|
||||
const cell = cellAt(scene, x, y)
|
||||
const cellX = Math.floor(cell.x)
|
||||
const cellY = Math.floor(cell.y)
|
||||
if (cellX < 0 || cellY < 0 || cellX >= scene.cellsX || cellY >= scene.cellsY) return true
|
||||
const subX = Math.floor((cell.x - cellX) * SUB_TILES_PER_TILE)
|
||||
const subY = Math.floor((cell.y - cellY) * SUB_TILES_PER_TILE)
|
||||
const gx = cellX * SUB_TILES_PER_TILE + Math.min(subX, SUB_TILES_PER_TILE - 1)
|
||||
const gy = cellY * SUB_TILES_PER_TILE + Math.min(subY, SUB_TILES_PER_TILE - 1)
|
||||
return scene.blocked[gy * scene.gridWidth + gx] === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene-space centre of a cell.
|
||||
*
|
||||
* @param scene - the scene.
|
||||
* @param cellX - cell column.
|
||||
* @param cellY - cell row.
|
||||
* @returns the centre point.
|
||||
*/
|
||||
export function cellCentre(scene: CollisionGrid, cellX: number, cellY: number): { x: number; y: number } {
|
||||
return {
|
||||
x: (cellX - cellY) * ORTHO_CELL_WIDTH + scene.originX,
|
||||
y: (cellX + cellY) * ORTHO_CELL_HEIGHT + scene.originY + ORTHO_CELL_HEIGHT / 2,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a walkable spawn near the middle of the map.
|
||||
*
|
||||
* @param scene - the scene.
|
||||
* @returns a scene-space point, or null when nothing is walkable.
|
||||
*/
|
||||
export function findIsoSpawn(scene: CollisionGrid & { cellsX: number; cellsY: number }): { x: number; y: number } | null {
|
||||
const centreX = Math.floor(scene.cellsX / 2)
|
||||
const centreY = Math.floor(scene.cellsY / 2)
|
||||
for (let radius = 0; radius < Math.max(scene.cellsX, scene.cellsY); radius += 1) {
|
||||
for (let dy = -radius; dy <= radius; dy += 1) {
|
||||
for (let dx = -radius; dx <= radius; dx += 1) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
|
||||
const cellX = centreX + dx
|
||||
const cellY = centreY + dy
|
||||
if (cellX < 0 || cellY < 0 || cellX >= scene.cellsX || cellY >= scene.cellsY) continue
|
||||
const point = cellCentre(scene, cellX, cellY)
|
||||
if (!isBlockedAt(scene, point.x, point.y)) return point
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -0,0 +1,587 @@
|
|||
/**
|
||||
* Items: bases, affixes, inventory and drops.
|
||||
*
|
||||
* This is the M3 skeleton, and its shape follows how Diablo II actually stores
|
||||
* items: an item is a *base* (`weapons.txt` / `armor.txt` / `misc.txt` row) with
|
||||
* an optional prefix and suffix from `MagicPrefix.txt` / `MagicSuffix.txt`, whose
|
||||
* modifiers name stats from `ItemStatCost.txt`. Nothing is hardcoded here — a
|
||||
* sword is a table row with a size and a damage value, and "Cruel" is a table row
|
||||
* with a level requirement, a list of eligible item types and a modifier range.
|
||||
*
|
||||
* Two modelling decisions are worth stating because they are what make the system
|
||||
* testable:
|
||||
*
|
||||
* - **Randomness is injected.** Affix rolls take an {@link Rng}, so a drop is a
|
||||
* pure function of its seed and can be replayed exactly in a test.
|
||||
* - **The inventory is a grid of occupied cells, not a list.** Diablo II items
|
||||
* have width and height, cannot overlap, and can be rotated only in the sense
|
||||
* that their shape is fixed — so placement is a real constraint that has to be
|
||||
* modelled to make "inventory full" mean anything.
|
||||
*
|
||||
* Simplifications, called out rather than hidden: affixes are chosen uniformly
|
||||
* among eligible ones instead of by the game's level-weighted tables, item
|
||||
* requirements (strength/dexterity/level) are stored but not enforced, and
|
||||
* durability, sockets and quality tiers (normal/exceptional/elite) are not
|
||||
* modelled yet.
|
||||
*/
|
||||
import { Rng } from './rng.ts'
|
||||
import { numberCell, textCell } from './tables.ts'
|
||||
import type { DataTable } from './tables.ts'
|
||||
|
||||
/** What a base is, broadly. */
|
||||
export type ItemKind = 'weapon' | 'armor' | 'misc'
|
||||
|
||||
/** One item base, read from a table row. */
|
||||
export interface ItemBase {
|
||||
/** Table id (for example `swd` for a short sword). */
|
||||
readonly id: string
|
||||
/** Display name. */
|
||||
readonly name: string
|
||||
/** Broad kind. */
|
||||
readonly kind: ItemKind
|
||||
/** Inventory width in cells. */
|
||||
readonly invWidth: number
|
||||
/** Inventory height in cells. */
|
||||
readonly invHeight: number
|
||||
/** How many of this base fit in one cell (1 for most things). */
|
||||
readonly maxStack: number
|
||||
/** Base gold value. */
|
||||
readonly value: number
|
||||
/** Weapon damage, when it is a weapon. */
|
||||
readonly damage: number
|
||||
/** Armor rating, when it is armor. */
|
||||
readonly defense: number
|
||||
/** Tags affixes match against (the item's type list). */
|
||||
readonly tags: readonly string[]
|
||||
/** Minimum level before it may drop. */
|
||||
readonly level: number
|
||||
}
|
||||
|
||||
/** One affix, read from a prefix or suffix row. */
|
||||
export interface Affix {
|
||||
/** Table id. */
|
||||
readonly id: string
|
||||
/** Name as it appears in an item's name. */
|
||||
readonly name: string
|
||||
/** Which side of the name it attaches to. */
|
||||
readonly kind: 'prefix' | 'suffix'
|
||||
/** Minimum item level for the affix to be possible. */
|
||||
readonly level: number
|
||||
/** Item-type tags it applies to; empty means any. */
|
||||
readonly itemTypes: readonly string[]
|
||||
/** Stat modifiers it contributes. */
|
||||
readonly modifiers: readonly AffixModifier[]
|
||||
}
|
||||
|
||||
/** One stat contribution of an affix. */
|
||||
export interface AffixModifier {
|
||||
/** Stat name, as `ItemStatCost.txt` spells it. */
|
||||
readonly stat: string
|
||||
/** Minimum roll. */
|
||||
readonly min: number
|
||||
/** Maximum roll. */
|
||||
readonly max: number
|
||||
}
|
||||
|
||||
/** A concrete item. */
|
||||
export interface Item {
|
||||
/** Base definition. */
|
||||
readonly base: ItemBase
|
||||
/** Rolled prefix, if any. */
|
||||
readonly prefix: Affix | null
|
||||
/** Rolled suffix, if any. */
|
||||
readonly suffix: Affix | null
|
||||
/** Item level the affixes were rolled at. */
|
||||
readonly level: number
|
||||
/** Final name, affixes included. */
|
||||
readonly name: string
|
||||
/** Final stats: base plus every rolled modifier. */
|
||||
readonly stats: Readonly<Record<string, number>>
|
||||
/** Inventory footprint. */
|
||||
readonly invWidth: number
|
||||
/** Inventory footprint. */
|
||||
readonly invHeight: number
|
||||
/** How many are stacked here. */
|
||||
readonly stack: number
|
||||
/** Gold value of one unit. */
|
||||
readonly value: number
|
||||
}
|
||||
|
||||
/** A rectangle in the inventory grid. */
|
||||
export interface GridPlacement {
|
||||
/** Column of the item's left edge. */
|
||||
readonly x: number
|
||||
/** Row of the item's top edge. */
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** An item occupying a spot in the inventory. */
|
||||
export interface PlacedItem extends GridPlacement {
|
||||
/** The item itself. */
|
||||
readonly item: Item
|
||||
}
|
||||
|
||||
/** What happened when a drop was rolled. */
|
||||
export type DropResult =
|
||||
| { readonly kind: 'item'; readonly item: Item }
|
||||
| { readonly kind: 'gold'; readonly amount: number }
|
||||
| { readonly kind: 'nothing' }
|
||||
|
||||
/** Default footprint for a base whose row omits one. */
|
||||
const DEFAULT_SIZE = 1
|
||||
|
||||
/**
|
||||
* Read an item base from a table row.
|
||||
*
|
||||
* @param row - the record.
|
||||
* @param kind - which table the row came from.
|
||||
* @param rowIndex - position, for a fallback id.
|
||||
* @returns the base.
|
||||
*/
|
||||
export function itemBaseFromRow(
|
||||
row: Readonly<Record<string, string>>,
|
||||
kind: ItemKind,
|
||||
rowIndex: number,
|
||||
): ItemBase {
|
||||
const id = textCell(row, 'Id', textCell(row, 'code', `${kind}${String(rowIndex)}`))
|
||||
const tags = textCell(row, 'Type', textCell(row, 'type', kind))
|
||||
.split(/[,\s]+/)
|
||||
.filter(tag => tag !== '')
|
||||
return {
|
||||
id,
|
||||
name: textCell(row, 'Name', textCell(row, 'name', id)),
|
||||
kind,
|
||||
invWidth: numberCell(row, 'InvWidth', numberCell(row, 'invwidth', DEFAULT_SIZE)),
|
||||
invHeight: numberCell(row, 'InvHeight', numberCell(row, 'invheight', DEFAULT_SIZE)),
|
||||
maxStack: Math.max(1, numberCell(row, 'MaxStack', numberCell(row, 'maxstack', 1))),
|
||||
value: Math.max(0, numberCell(row, 'Value', numberCell(row, 'cost', 1))),
|
||||
damage: Math.max(0, numberCell(row, 'Damage', numberCell(row, 'mindam', 0))),
|
||||
defense: Math.max(0, numberCell(row, 'Defense', numberCell(row, 'minac', 0))),
|
||||
tags,
|
||||
level: Math.max(0, numberCell(row, 'Level', numberCell(row, 'level', 1))),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every base in a table.
|
||||
*
|
||||
* @param table - a weapons/armor/misc table.
|
||||
* @param kind - which kind the table holds.
|
||||
* @returns the bases.
|
||||
*/
|
||||
export function itemBasesFromTable(table: DataTable, kind: ItemKind): ItemBase[] {
|
||||
return table.rows.map((row, index) => itemBaseFromRow(row, kind, index))
|
||||
}
|
||||
|
||||
/**
|
||||
* How many modifier slots the loader looks for.
|
||||
*
|
||||
* Diablo II's affix rows carry `mod1code/mod1min/mod1max` through `mod3…` for
|
||||
* prefixes and suffixes alike; three is the shipped maximum.
|
||||
*/
|
||||
const MODIFIER_SLOTS = 3
|
||||
/**
|
||||
* How many item-type slots an affix row may restrict itself to (`itype1..7`).
|
||||
*/
|
||||
const ITYPE_SLOTS = 7
|
||||
|
||||
/**
|
||||
* Read one affix from a prefix/suffix row.
|
||||
*
|
||||
* @param row - the record.
|
||||
* @param kind - which side of the name the affix attaches to.
|
||||
* @param rowIndex - position, for a fallback id.
|
||||
* @returns the affix, or null when the row has no usable modifier.
|
||||
*/
|
||||
export function affixFromRow(
|
||||
row: Readonly<Record<string, string>>,
|
||||
kind: 'prefix' | 'suffix',
|
||||
rowIndex: number,
|
||||
): Affix | null {
|
||||
const modifiers: AffixModifier[] = []
|
||||
for (let slot = 1; slot <= MODIFIER_SLOTS; slot += 1) {
|
||||
const stat = textCell(row, `mod${String(slot)}code`, textCell(row, `Mod${String(slot)}Code`, ''))
|
||||
if (stat === '') continue
|
||||
const min = numberCell(row, `mod${String(slot)}min`, numberCell(row, `Mod${String(slot)}Min`, 0))
|
||||
const max = numberCell(row, `mod${String(slot)}max`, numberCell(row, `Mod${String(slot)}Max`, min))
|
||||
modifiers.push({ stat, min, max: Math.max(min, max) })
|
||||
}
|
||||
if (modifiers.length === 0) return null
|
||||
const itemTypes: string[] = []
|
||||
for (let slot = 1; slot <= ITYPE_SLOTS; slot += 1) {
|
||||
const value = textCell(row, `itype${String(slot)}`, textCell(row, `IType${String(slot)}`, ''))
|
||||
for (const tag of value.split(/[,\s]+/)) if (tag !== '') itemTypes.push(tag)
|
||||
}
|
||||
const id = textCell(row, 'Id', textCell(row, 'Name', `${kind}${String(rowIndex)}`))
|
||||
return {
|
||||
id,
|
||||
name: textCell(row, 'Name', id),
|
||||
kind,
|
||||
level: Math.max(0, numberCell(row, 'Level', numberCell(row, 'lvl', 1))),
|
||||
itemTypes,
|
||||
modifiers,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every affix in a table.
|
||||
*
|
||||
* @param table - a `MagicPrefix` or `MagicSuffix` shaped table.
|
||||
* @param kind - which side of the name the rows attach to.
|
||||
* @returns the affixes, with unusable rows skipped.
|
||||
*/
|
||||
export function affixesFromTable(table: DataTable, kind: 'prefix' | 'suffix'): Affix[] {
|
||||
const affixes: Affix[] = []
|
||||
table.rows.forEach((row, index) => {
|
||||
const affix = affixFromRow(row, kind, index)
|
||||
if (affix !== null) affixes.push(affix)
|
||||
})
|
||||
return affixes
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an affix may roll on a base at a given item level.
|
||||
*
|
||||
* @param affix - the affix.
|
||||
* @param base - the base item.
|
||||
* @param level - the item level being rolled.
|
||||
* @returns true when the affix is eligible.
|
||||
*/
|
||||
export function affixEligible(affix: Affix, base: ItemBase, level: number): boolean {
|
||||
if (affix.level > level) return false
|
||||
if (affix.itemTypes.length === 0) return true
|
||||
// Diablo II matches the affix's `itype` list against the item's type list; an
|
||||
// affix naming a type the base does not carry cannot roll on it.
|
||||
return affix.itemTypes.some(tag => base.tags.includes(tag))
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll one affix for a base.
|
||||
*
|
||||
* @param affixes - the candidate affixes.
|
||||
* @param base - the base item.
|
||||
* @param level - the item level.
|
||||
* @param rng - the random source.
|
||||
* @returns the affix and its rolled modifiers, or null when none is eligible.
|
||||
*/
|
||||
export function rollAffix(
|
||||
affixes: readonly Affix[],
|
||||
base: ItemBase,
|
||||
level: number,
|
||||
rng: Rng,
|
||||
): { affix: Affix; rolls: AffixModifier[] } | null {
|
||||
const eligible = affixes.filter(affix => affixEligible(affix, base, level))
|
||||
const affix = rng.pick(eligible)
|
||||
if (affix === undefined) return null
|
||||
const rolls = affix.modifiers.map(modifier => ({
|
||||
stat: modifier.stat,
|
||||
min: rng.int(modifier.min, modifier.max),
|
||||
max: rng.int(modifier.min, modifier.max),
|
||||
}))
|
||||
return { affix, rolls }
|
||||
}
|
||||
|
||||
/** Options controlling how an item is built. */
|
||||
export interface CreateItemOptions {
|
||||
/** Item level; gates affixes. */
|
||||
readonly level: number
|
||||
/** Chance that a prefix rolls at all. */
|
||||
readonly prefixChance?: number
|
||||
/** Chance that a suffix rolls at all. */
|
||||
readonly suffixChance?: number
|
||||
/** How many units are stacked. */
|
||||
readonly stack?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item from a base, rolling its affixes.
|
||||
*
|
||||
* @param base - the base.
|
||||
* @param prefixes - candidate prefixes.
|
||||
* @param suffixes - candidate suffixes.
|
||||
* @param rng - the random source.
|
||||
* @param options - level, chances and stack size.
|
||||
* @returns the item.
|
||||
*/
|
||||
export function createItem(
|
||||
base: ItemBase,
|
||||
prefixes: readonly Affix[],
|
||||
suffixes: readonly Affix[],
|
||||
rng: Rng,
|
||||
options: CreateItemOptions,
|
||||
): Item {
|
||||
const prefixChance = options.prefixChance ?? 0.45
|
||||
const suffixChance = options.suffixChance ?? 0.45
|
||||
const prefix = rng.chance(prefixChance) ? rollAffix(prefixes, base, options.level, rng) : null
|
||||
const suffix = rng.chance(suffixChance) ? rollAffix(suffixes, base, options.level, rng) : null
|
||||
|
||||
const stats: Record<string, number> = {}
|
||||
if (base.damage > 0) stats.damage = base.damage
|
||||
if (base.defense > 0) stats.defense = base.defense
|
||||
for (const rolled of [prefix, suffix]) {
|
||||
if (rolled === null) continue
|
||||
for (const roll of rolled.rolls) {
|
||||
stats[roll.stat] = (stats[roll.stat] ?? 0) + roll.max
|
||||
}
|
||||
}
|
||||
|
||||
const name = [prefix?.affix.name, base.name, suffix?.affix.name].filter(part => part !== undefined && part !== '').join(' ')
|
||||
// An affixed item is worth more; the multiplier is a stand-in for the game's
|
||||
// per-modifier pricing.
|
||||
const affixCount = (prefix === null ? 0 : 1) + (suffix === null ? 0 : 1)
|
||||
const value = Math.max(1, Math.round(base.value * (1 + affixCount * 0.75)))
|
||||
|
||||
return {
|
||||
base,
|
||||
prefix: prefix?.affix ?? null,
|
||||
suffix: suffix?.affix ?? null,
|
||||
level: options.level,
|
||||
name,
|
||||
stats,
|
||||
invWidth: base.invWidth,
|
||||
invHeight: base.invHeight,
|
||||
stack: Math.max(1, Math.min(options.stack ?? 1, base.maxStack)),
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The inventory: a grid of cells, each either empty or holding a placed item.
|
||||
*/
|
||||
export class Inventory {
|
||||
/** Grid width in cells. */
|
||||
readonly width: number
|
||||
/** Grid height in cells. */
|
||||
readonly height: number
|
||||
private readonly cells: (number | null)[]
|
||||
private readonly items: PlacedItem[] = []
|
||||
private nextId = 1
|
||||
|
||||
/**
|
||||
* @param width - grid width in cells.
|
||||
* @param height - grid height in cells.
|
||||
*/
|
||||
constructor(width = 10, height = 4) {
|
||||
this.width = width
|
||||
this.height = height
|
||||
this.cells = new Array<number | null>(width * height).fill(null)
|
||||
}
|
||||
|
||||
/** Items currently placed. */
|
||||
get contents(): readonly PlacedItem[] {
|
||||
return this.items
|
||||
}
|
||||
|
||||
/** Occupied cell count. */
|
||||
get usedCells(): number {
|
||||
return this.items.reduce((total, placed) => total + placed.item.invWidth * placed.item.invHeight, 0)
|
||||
}
|
||||
|
||||
/** Total cell count. */
|
||||
get totalCells(): number {
|
||||
return this.width * this.height
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an item of a given size fits at a position.
|
||||
*
|
||||
* @param width - item width.
|
||||
* @param height - item height.
|
||||
* @param x - column.
|
||||
* @param y - row.
|
||||
* @returns true when the whole footprint is inside the grid and empty.
|
||||
*/
|
||||
canPlace(width: number, height: number, x: number, y: number): boolean {
|
||||
if (x < 0 || y < 0 || x + width > this.width || y + height > this.height) return false
|
||||
for (let row = y; row < y + height; row += 1) {
|
||||
for (let column = x; column < x + width; column += 1) {
|
||||
if (this.cells[row * this.width + column] !== null) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item, stacking onto an existing stack when possible.
|
||||
*
|
||||
* @param item - the item to add.
|
||||
* @returns where it landed, or null when there was no room.
|
||||
*/
|
||||
add(item: Item): PlacedItem | null {
|
||||
// Stack first: a second potion belongs on the first one, not beside it.
|
||||
if (item.base.maxStack > 1) {
|
||||
for (const placed of this.items) {
|
||||
if (placed.item.base.id !== item.base.id) continue
|
||||
const room = placed.item.base.maxStack - placed.item.stack
|
||||
if (room <= 0) continue
|
||||
const moved = Math.min(room, item.stack)
|
||||
const merged: Item = { ...placed.item, stack: placed.item.stack + moved }
|
||||
this.items[this.items.indexOf(placed)] = { ...placed, item: merged }
|
||||
const leftover = item.stack - moved
|
||||
if (leftover <= 0) return { ...placed, item: merged }
|
||||
return this.add({ ...item, stack: leftover })
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < this.height; y += 1) {
|
||||
for (let x = 0; x < this.width; x += 1) {
|
||||
if (!this.canPlace(item.invWidth, item.invHeight, x, y)) continue
|
||||
const id = this.nextId
|
||||
this.nextId += 1
|
||||
for (let row = y; row < y + item.invHeight; row += 1) {
|
||||
for (let column = x; column < x + item.invWidth; column += 1) {
|
||||
this.cells[row * this.width + column] = id
|
||||
}
|
||||
}
|
||||
const placed: PlacedItem = { x, y, item }
|
||||
this.items.push(placed)
|
||||
return placed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item by its placement.
|
||||
*
|
||||
* @param placed - the placement to clear.
|
||||
* @returns true when it was present.
|
||||
*/
|
||||
remove(placed: PlacedItem): boolean {
|
||||
const index = this.items.indexOf(placed)
|
||||
if (index === -1) return false
|
||||
for (let row = placed.y; row < placed.y + placed.item.invHeight; row += 1) {
|
||||
for (let column = placed.x; column < placed.x + placed.item.invWidth; column += 1) {
|
||||
this.cells[row * this.width + column] = null
|
||||
}
|
||||
}
|
||||
this.items.splice(index, 1)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild an inventory from explicit placements.
|
||||
*
|
||||
* A save restores items where they were, not wherever the first free slot
|
||||
* happens to be today: an item that moved on load would be a save that lies.
|
||||
* Placements are validated, so a corrupt save fails loudly instead of producing
|
||||
* an overlapping bag.
|
||||
*
|
||||
* @param width - grid width.
|
||||
* @param height - grid height.
|
||||
* @param entries - placements to restore.
|
||||
* @returns the inventory.
|
||||
*/
|
||||
static restore(width: number, height: number, entries: readonly PlacedItem[]): Inventory {
|
||||
const inventory = new Inventory(width, height)
|
||||
for (const entry of entries) {
|
||||
if (!inventory.canPlace(entry.item.invWidth, entry.item.invHeight, entry.x, entry.y)) {
|
||||
throw new Error(`saved item "${entry.item.name}" does not fit at ${String(entry.x)},${String(entry.y)}`)
|
||||
}
|
||||
const id = inventory.nextId
|
||||
inventory.nextId += 1
|
||||
for (let row = entry.y; row < entry.y + entry.item.invHeight; row += 1) {
|
||||
for (let column = entry.x; column < entry.x + entry.item.invWidth; column += 1) {
|
||||
inventory.cells[row * width + column] = id
|
||||
}
|
||||
}
|
||||
inventory.items.push({ x: entry.x, y: entry.y, item: entry.item })
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
|
||||
/** How much gold is held, summed over gold stacks. */
|
||||
get gold(): number {
|
||||
return this.items
|
||||
.filter(placed => placed.item.base.id === 'gold')
|
||||
.reduce((total, placed) => total + placed.item.stack, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/** A gold base, created on demand so gold needs no table row. */
|
||||
export const GOLD_BASE: ItemBase = {
|
||||
id: 'gold',
|
||||
name: 'Gold',
|
||||
kind: 'misc',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
maxStack: 5000,
|
||||
value: 1,
|
||||
damage: 0,
|
||||
defense: 0,
|
||||
tags: ['gold'],
|
||||
level: 0,
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a gold pile.
|
||||
*
|
||||
* @param amount - how much gold.
|
||||
* @returns the item.
|
||||
*/
|
||||
export function goldItem(amount: number): Item {
|
||||
const stack = Math.max(1, Math.min(amount, GOLD_BASE.maxStack))
|
||||
return {
|
||||
base: GOLD_BASE,
|
||||
prefix: null,
|
||||
suffix: null,
|
||||
level: 0,
|
||||
name: 'Gold',
|
||||
stats: {},
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
stack,
|
||||
value: stack,
|
||||
}
|
||||
}
|
||||
|
||||
/** Options for {@link rollDrop}. */
|
||||
export interface DropOptions {
|
||||
/** Item level to roll at. */
|
||||
readonly level: number
|
||||
/** Chance that anything drops at all. */
|
||||
readonly dropChance: number
|
||||
/** Chance that a drop is gold rather than an item. */
|
||||
readonly goldChance: number
|
||||
/** Gold range when gold drops. */
|
||||
readonly goldRange: readonly [number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll what a monster leaves behind.
|
||||
*
|
||||
* @param bases - candidate bases.
|
||||
* @param prefixes - candidate prefixes.
|
||||
* @param suffixes - candidate suffixes.
|
||||
* @param rng - the random source.
|
||||
* @param options - level and chances.
|
||||
* @returns the drop.
|
||||
*/
|
||||
export function rollDrop(
|
||||
bases: readonly ItemBase[],
|
||||
prefixes: readonly Affix[],
|
||||
suffixes: readonly Affix[],
|
||||
rng: Rng,
|
||||
options: DropOptions,
|
||||
): DropResult {
|
||||
if (!rng.chance(options.dropChance)) return { kind: 'nothing' }
|
||||
if (rng.chance(options.goldChance)) {
|
||||
return { kind: 'gold', amount: rng.int(options.goldRange[0], options.goldRange[1]) }
|
||||
}
|
||||
// Only bases whose own level requirement is satisfied: a monster cannot drop
|
||||
// gear that the item level gate forbids.
|
||||
const eligible = bases.filter(base => base.level <= options.level)
|
||||
const base = rng.pick(eligible.length > 0 ? eligible : bases)
|
||||
if (base === undefined) return { kind: 'nothing' }
|
||||
return { kind: 'item', item: createItem(base, prefixes, suffixes, rng, { level: options.level }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum a held stat across everything carried, for the character sheet.
|
||||
*
|
||||
* @param items - placed items.
|
||||
* @param stat - the stat name.
|
||||
* @returns the total.
|
||||
*/
|
||||
export function totalStat(items: readonly PlacedItem[], stat: string): number {
|
||||
return items.reduce((total, placed) => total + (placed.item.stats[stat] ?? 0), 0)
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
// level-names-zh.ts — 关卡/场景的中文显示名(用于页面上的三级选择器)
|
||||
//
|
||||
// 出处说明(重要,避免误认为官方本地化):
|
||||
// 这批译名是**社区通用译名**,由本项目手工整理;英文原名取自 `Levels.txt` 的 `LevelName`
|
||||
// 列(例如 1 = "Rogue Encampment"、33 = "Cathedral"),中文对照按国内玩家习惯写法。
|
||||
// 我们这份安装包里的 `data\local\LNG\CHI\string.tbl` 解出来是乱码(`.tbl` 解码器只对夹具
|
||||
// 验证过,尚未对真文件校准),所以**没有**直接采用官方简中字符串表;等 `.tbl` 解码对齐后
|
||||
// 可以换成官方译名,届时只需替换本表。
|
||||
//
|
||||
// 键是 `Levels.txt` 的 `Id`;页面从 pack 索引条目的 `slug`(形如 `33-act-1-cathedral`)里
|
||||
// 取第一个数字段就能对上。查不到时回退到索引里的英文 `levelName`。
|
||||
|
||||
/** 五个 act 的中文名,下标即 act 号。 */
|
||||
export const ACT_NAMES_ZH: Readonly<Record<number, string>> = {
|
||||
1: '第一章',
|
||||
2: '第二章',
|
||||
3: '第三章',
|
||||
4: '第四章',
|
||||
5: '第五章',
|
||||
}
|
||||
|
||||
/** `Levels.txt` 的 `Id` → 中文场景名。 */
|
||||
export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
|
||||
1: '罗格营地',
|
||||
13: '洞穴二层',
|
||||
14: '地下通道二层',
|
||||
15: '窟窿二层',
|
||||
16: '深坑二层',
|
||||
20: '遗忘之塔',
|
||||
25: '塔地窖五层',
|
||||
26: '修道院大门',
|
||||
27: '外回廊',
|
||||
32: '内回廊',
|
||||
33: '大教堂',
|
||||
37: '地下墓穴四层',
|
||||
38: '崔斯特瑞姆',
|
||||
40: '鲁高因',
|
||||
50: '后宫一层',
|
||||
73: '都瑞尔的巢穴',
|
||||
75: '库拉斯特码头',
|
||||
90: '沼泽地坑三层',
|
||||
91: '剥皮地牢三层',
|
||||
93: '下水道二层',
|
||||
94: '废弃神殿',
|
||||
95: '荒废神庙',
|
||||
96: '遗忘圣物室',
|
||||
97: '遗忘神殿',
|
||||
98: '废弃神庙',
|
||||
99: '荒废圣物室',
|
||||
102: '憎恨囚牢三层',
|
||||
103: '火焰之河要塞',
|
||||
109: '哈洛加斯',
|
||||
120: '岩石之巅',
|
||||
121: '尼拉塞克神殿',
|
||||
124: '沃特厅',
|
||||
131: '毁灭王座',
|
||||
132: '世界之石大殿',
|
||||
136: '崔斯特瑞姆',
|
||||
}
|
||||
|
||||
/**
|
||||
* DS1 变体后缀 → 方位名。
|
||||
*
|
||||
* 这些后缀是 D2 自己的命名习惯:城镇/回廊/鲁高因的 DS1 以 `N`/`E`/`S`/`W` 结尾
|
||||
* (`TownN1`、`CourtW`、`LutN`…)。**只白名单这几个**,不做"末尾字母是方位"的猜测:
|
||||
* `CryptCountess1` 末尾也是 `s`,按猜测会错成"南"。
|
||||
*/
|
||||
const DIRECTION_SUFFIXES: Readonly<Record<string, string>> = {
|
||||
townn1: '北',
|
||||
towne1: '东',
|
||||
towns1: '南',
|
||||
townw1: '西',
|
||||
courtn: '北',
|
||||
courte: '东',
|
||||
courtw: '西',
|
||||
lutn: '北',
|
||||
lutw: '西',
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一个变体后缀变成显示用的中文(拿不准就原样返回 DS1 名)。
|
||||
*
|
||||
* @param suffix - the part of the pack label after the level slug, e.g. `towns1`.
|
||||
* @returns the label to show in the third selector.
|
||||
*/
|
||||
export function variantLabelZh(suffix: string): string {
|
||||
return DIRECTION_SUFFIXES[suffix.toLowerCase()] ?? suffix
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 pack 索引条目的 `slug` 里取 `Levels.txt` 的关卡 Id。
|
||||
*
|
||||
* @param slug - e.g. `33-act-1-cathedral`.
|
||||
* @returns the id, or null when the slug does not start with one.
|
||||
*/
|
||||
export function levelIdOfSlug(slug: string): number | null {
|
||||
const match = /^(\d+)-/.exec(slug)
|
||||
return match?.[1] === undefined ? null : Number(match[1])
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个场景的中文名,取不到就回退调用方给的英文名。
|
||||
*
|
||||
* @param slug - the scene slug from the pack index.
|
||||
* @param fallback - the English name from the pack index.
|
||||
* @returns the display name.
|
||||
*/
|
||||
export function sceneNameZh(slug: string, fallback: string): string {
|
||||
const id = levelIdOfSlug(slug)
|
||||
if (id === null) return fallback
|
||||
return LEVEL_NAMES_ZH[id] ?? fallback
|
||||
}
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
/**
|
||||
* Turning a decoded DS1 map and its DT1 library into something renderable and
|
||||
* walkable.
|
||||
*
|
||||
* Three translations happen here, and each is the reason a map and a tile
|
||||
* library cannot be used apart:
|
||||
*
|
||||
* 1. **A cell reference becomes a tile bitmap.** A DS1 cell holds `(style,
|
||||
* sequence)` per layer plus a wall orientation; the DT1 library is keyed by
|
||||
* exactly those fields. A miss is not an error — real maps reference
|
||||
* substitutions and trimmed libraries — so misses fall back from
|
||||
* `(style, sequence, orientation)` to `(style, sequence)` and then to
|
||||
* nothing, and are reported as counts.
|
||||
* 2. **Cells become a draw order.** Diablo's map is drawn back to front:
|
||||
* floors first in cell order, then walls in screen order (further rows
|
||||
* first, so a nearer wall covers the one behind it). Depth is paint order,
|
||||
* never the depth buffer.
|
||||
* 3. **Sub-tile flags become a collision grid.** Each tile carries a 5×5 grid
|
||||
* of flags, so collision resolution is `tileEdge / 5` — 32 pixels for the
|
||||
* standard 160-pixel tile — and a blocked sub-tile blocks walking for the
|
||||
* whole world, whatever layer placed it.
|
||||
*/
|
||||
import type { Dt1, Dt1Tile } from '../formats/dt1.ts'
|
||||
import type { Ds1 } from '../formats/ds1.ts'
|
||||
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
||||
|
||||
/** Sub-tiles per tile edge (the collision grid's resolution). */
|
||||
export const SUB_TILES_PER_TILE = 5
|
||||
|
||||
/** One tile draw: which library tile, and where in the world. */
|
||||
export interface MapDraw {
|
||||
/** Index into {@link MapScene.library} frames. */
|
||||
readonly tileIndex: number
|
||||
/** World-space left edge. */
|
||||
readonly x: number
|
||||
/** World-space top edge. */
|
||||
readonly y: number
|
||||
/** Cell coordinates, for debugging and sorting. */
|
||||
readonly cellX: number
|
||||
readonly cellY: number
|
||||
}
|
||||
|
||||
/** A renderable, walkable map. */
|
||||
export interface MapScene {
|
||||
/** Tile bitmaps, one frame per DT1 tile, in library order. */
|
||||
readonly sheet: SpriteSheet
|
||||
/** World width in pixels. */
|
||||
readonly widthPx: number
|
||||
/** World height in pixels. */
|
||||
readonly heightPx: number
|
||||
/** Floor draws, in cell order. */
|
||||
readonly floors: readonly MapDraw[]
|
||||
/** Wall draws, in painter's order. */
|
||||
readonly walls: readonly MapDraw[]
|
||||
/** Collision grid, row-major, 1 = blocked. */
|
||||
readonly blocked: Uint8Array
|
||||
/** Sub-tile size in pixels. */
|
||||
readonly subTile: number
|
||||
/** Collision grid width in sub-tiles. */
|
||||
readonly gridWidth: number
|
||||
/** Collision grid height in sub-tiles. */
|
||||
readonly gridHeight: number
|
||||
/** References that resolved to no library tile. */
|
||||
readonly missingTiles: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite a tile's blocks into one indexed bitmap.
|
||||
*
|
||||
* Blocks are placed by the decoder at tile size already, so compositing is a
|
||||
* "non-zero wins" merge: the transparent index is 0 and later blocks paint over
|
||||
* earlier ones where they have content.
|
||||
*
|
||||
* @param tile - the library tile.
|
||||
* @returns a frame at the tile's size.
|
||||
*/
|
||||
function tileToFrame(tile: Dt1Tile): SpriteFrame {
|
||||
const width = Math.max(tile.width, 1)
|
||||
const height = Math.max(Math.abs(tile.height), 1)
|
||||
const indices = new Uint8Array(width * height)
|
||||
const mask = new Uint8Array(width * height)
|
||||
for (const block of tile.blocks) {
|
||||
for (let at = 0; at < indices.length && at < block.pixels.length; at += 1) {
|
||||
const value = block.pixels[at]!
|
||||
if (value === 0) continue
|
||||
indices[at] = value
|
||||
mask[at] = 1
|
||||
}
|
||||
}
|
||||
return { width, height, indices, mask }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tile sheet for a library: one frame per tile, in library order.
|
||||
*
|
||||
* @param library - the decoded DT1.
|
||||
* @returns a sprite sheet the atlas packer can consume.
|
||||
*/
|
||||
export function libraryToSheet(library: Dt1): SpriteSheet {
|
||||
return { groups: [{ frames: library.tiles.map(tileToFrame) }], width: null }
|
||||
}
|
||||
|
||||
/** How a tile reference resolved, for diagnostics. */
|
||||
interface TileIndex {
|
||||
/** `style:sequence:direction` → library index. */
|
||||
readonly exact: Map<string, number>
|
||||
/** `style:sequence` → first library index, used when the orientation misses. */
|
||||
readonly loose: Map<string, number>
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a library by the fields a DS1 cell references.
|
||||
*
|
||||
* @param library - the decoded DT1.
|
||||
* @returns the lookup maps.
|
||||
*/
|
||||
export function indexLibrary(library: Dt1): TileIndex {
|
||||
const exact = new Map<string, number>()
|
||||
const loose = new Map<string, number>()
|
||||
library.tiles.forEach((tile, index) => {
|
||||
exact.set(`${String(tile.style)}:${String(tile.sequence)}:${String(tile.direction)}`, index)
|
||||
const looseKey = `${String(tile.style)}:${String(tile.sequence)}`
|
||||
if (!loose.has(looseKey)) loose.set(looseKey, index)
|
||||
})
|
||||
return { exact, loose }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one cell reference.
|
||||
*
|
||||
* @param index - the library index.
|
||||
* @param style - DS1 style field.
|
||||
* @param sequence - DS1 sequence field.
|
||||
* @param direction - orientation, for walls (null for floors).
|
||||
* @returns the library tile index, or undefined when nothing matches.
|
||||
*/
|
||||
function lookup(index: TileIndex, style: number, sequence: number, direction: number | null): number | undefined {
|
||||
if (direction !== null) {
|
||||
const exact = index.exact.get(`${String(style)}:${String(sequence)}:${String(direction)}`)
|
||||
if (exact !== undefined) return exact
|
||||
}
|
||||
return index.loose.get(`${String(style)}:${String(sequence)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a renderable, walkable scene from a map and its library.
|
||||
*
|
||||
* @param level - the decoded DS1.
|
||||
* @param library - the decoded DT1.
|
||||
* @returns the scene.
|
||||
*/
|
||||
export function buildMapScene(level: Ds1, library: Dt1): MapScene {
|
||||
const index = indexLibrary(library)
|
||||
const tileWidth = Math.max(...library.tiles.map(tile => tile.width), 1)
|
||||
const tileHeight = Math.max(...library.tiles.map(tile => Math.abs(tile.height)), 1)
|
||||
const subTileX = tileWidth / SUB_TILES_PER_TILE
|
||||
const subTileY = tileHeight / SUB_TILES_PER_TILE
|
||||
const widthPx = level.width * tileWidth
|
||||
const heightPx = level.height * tileHeight
|
||||
const gridWidth = level.width * SUB_TILES_PER_TILE
|
||||
const gridHeight = level.height * SUB_TILES_PER_TILE
|
||||
const blocked = new Uint8Array(gridWidth * gridHeight)
|
||||
|
||||
const floors: MapDraw[] = []
|
||||
const walls: MapDraw[] = []
|
||||
let missingTiles = 0
|
||||
|
||||
for (let cellY = 0; cellY < level.height; cellY += 1) {
|
||||
for (let cellX = 0; cellX < level.width; cellX += 1) {
|
||||
const cell = level.cells[cellY]?.[cellX]
|
||||
if (cell === undefined) continue
|
||||
const x = cellX * tileWidth
|
||||
const y = cellY * tileHeight
|
||||
|
||||
for (const floor of cell.floors) {
|
||||
const tileIndex = lookup(index, floor.style, floor.sequence, null)
|
||||
if (tileIndex === undefined) { missingTiles += 1; continue }
|
||||
floors.push({ tileIndex, x, y, cellX, cellY })
|
||||
applyCollision(blocked, gridWidth, gridHeight, cellX, cellY, library.tiles[tileIndex])
|
||||
}
|
||||
for (const wall of cell.walls) {
|
||||
const tileIndex = lookup(index, wall.style, wall.sequence, wall.type)
|
||||
if (tileIndex === undefined) { missingTiles += 1; continue }
|
||||
walls.push({ tileIndex, x, y, cellX, cellY })
|
||||
applyCollision(blocked, gridWidth, gridHeight, cellX, cellY, library.tiles[tileIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Painter's order for walls: back rows first, and within a row left to right.
|
||||
// Once walls from further cells exist, screen order (y + x) is what keeps a
|
||||
// nearer wall in front — cells are already visited row-major, so the sort is
|
||||
// on the diagonal, which is the isometric depth.
|
||||
walls.sort((a, b) => (a.cellY + a.cellX) - (b.cellY + b.cellX) || a.cellY - b.cellY)
|
||||
|
||||
return {
|
||||
sheet: libraryToSheet(library),
|
||||
widthPx, heightPx, floors, walls, blocked,
|
||||
subTile: subTileX,
|
||||
gridWidth, gridHeight,
|
||||
missingTiles,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp one tile's sub-tile flags into the collision grid.
|
||||
*
|
||||
* A tile larger than its sub-tile stride (a wall taller than the cell, which is
|
||||
* the normal case for roofs) is clamped to the grid rather than rejected.
|
||||
*
|
||||
* @param blocked - the grid to write.
|
||||
* @param gridWidth - grid width in sub-tiles.
|
||||
* @param gridHeight - grid height in sub-tiles.
|
||||
* @param cellX - the owning cell's column.
|
||||
* @param cellY - the owning cell's row.
|
||||
* @param tile - the tile whose flags are stamped.
|
||||
*/
|
||||
function applyCollision(
|
||||
blocked: Uint8Array,
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
cellX: number,
|
||||
cellY: number,
|
||||
tile: Dt1Tile | undefined,
|
||||
): void {
|
||||
if (tile === undefined) return
|
||||
for (let subY = 0; subY < SUB_TILES_PER_TILE; subY += 1) {
|
||||
for (let subX = 0; subX < SUB_TILES_PER_TILE; subX += 1) {
|
||||
const flags = tile.subTileFlags[subY * SUB_TILES_PER_TILE + subX]
|
||||
if (flags === undefined || (!flags.blockWalk && !flags.blockPlayerWalk)) continue
|
||||
const gridX = cellX * SUB_TILES_PER_TILE + subX
|
||||
const gridY = cellY * SUB_TILES_PER_TILE + subY
|
||||
if (gridX < 0 || gridX >= gridWidth || gridY < 0 || gridY >= gridHeight) continue
|
||||
blocked[gridY * gridWidth + gridX] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a world-space box overlaps blocked sub-tiles.
|
||||
*
|
||||
* @param scene - the scene whose grid to test.
|
||||
* @param tileWidth - library tile width, the grid's stride.
|
||||
* @param tileHeight - library tile height.
|
||||
* @param x - box centre x.
|
||||
* @param y - box centre y.
|
||||
* @param width - box width.
|
||||
* @param height - box height.
|
||||
* @returns true when any covered sub-tile is blocked.
|
||||
*/
|
||||
export function isBlocked(
|
||||
scene: MapScene,
|
||||
tileWidth: number,
|
||||
tileHeight: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): boolean {
|
||||
const subW = tileWidth / SUB_TILES_PER_TILE
|
||||
const subH = tileHeight / SUB_TILES_PER_TILE
|
||||
const fromX = Math.floor((x - width / 2) / subW)
|
||||
const toX = Math.floor((x + width / 2) / subW)
|
||||
const fromY = Math.floor((y - height / 2) / subH)
|
||||
const toY = Math.floor((y + height / 2) / subH)
|
||||
for (let gridY = fromY; gridY <= toY; gridY += 1) {
|
||||
for (let gridX = fromX; gridX <= toX; gridX += 1) {
|
||||
// Outside the map counts as blocked: walking off the edge is not a thing.
|
||||
if (gridX < 0 || gridX >= scene.gridWidth || gridY < 0 || gridY >= scene.gridHeight) return true
|
||||
if (scene.blocked[gridY * scene.gridWidth + gridX] === 1) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the blocked sub-tiles a box covers.
|
||||
*
|
||||
* Movement needs the *count* rather than a boolean: a character that is already
|
||||
* overlapping a blocked sub-tile (bad spawn, a tile that turned solid under it)
|
||||
* must still be able to walk out, and the rule for that is "never make it
|
||||
* worse".
|
||||
*
|
||||
* @param scene - the scene whose grid to test.
|
||||
* @param tileWidth - library tile width, the grid's stride.
|
||||
* @param tileHeight - library tile height.
|
||||
* @param x - box centre x.
|
||||
* @param y - box centre y.
|
||||
* @param width - box width.
|
||||
* @param height - box height.
|
||||
* @returns the number of covered sub-tiles that are blocked, with off-map tiles
|
||||
* counting as blocked.
|
||||
*/
|
||||
export function blockedOverlap(
|
||||
scene: MapScene,
|
||||
tileWidth: number,
|
||||
tileHeight: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): number {
|
||||
const subW = tileWidth / SUB_TILES_PER_TILE
|
||||
const subH = tileHeight / SUB_TILES_PER_TILE
|
||||
let blocked = 0
|
||||
for (let gridY = Math.floor((y - height / 2) / subH); gridY <= Math.floor((y + height / 2) / subH); gridY += 1) {
|
||||
for (let gridX = Math.floor((x - width / 2) / subW); gridX <= Math.floor((x + width / 2) / subW); gridX += 1) {
|
||||
if (gridX < 0 || gridX >= scene.gridWidth || gridY < 0 || gridY >= scene.gridHeight) {
|
||||
blocked += 1
|
||||
continue
|
||||
}
|
||||
if (scene.blocked[gridY * scene.gridWidth + gridX] === 1) blocked += 1
|
||||
}
|
||||
}
|
||||
return blocked
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a spawn position whose box covers no blocked sub-tile.
|
||||
*
|
||||
* A map's blocked sub-tiles come from its tiles, so nothing guarantees that the
|
||||
* geometric centre is walkable — Diablo's own spawn points are data, and until
|
||||
* those are loaded a search is the honest substitute. Falls back to the centre
|
||||
* when the map has no free spot at all (a fully sealed map is a content bug,
|
||||
* not something to crash on).
|
||||
*
|
||||
* @param scene - the scene to search.
|
||||
* @param tileWidth - library tile width.
|
||||
* @param tileHeight - library tile height.
|
||||
* @param width - box width.
|
||||
* @param height - box height.
|
||||
* @returns a free world position.
|
||||
*/
|
||||
export function findFreeSpawn(
|
||||
scene: MapScene,
|
||||
tileWidth: number,
|
||||
tileHeight: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): { x: number; y: number } {
|
||||
const subW = tileWidth / SUB_TILES_PER_TILE
|
||||
const subH = tileHeight / SUB_TILES_PER_TILE
|
||||
let best = { x: scene.widthPx / 2, y: scene.heightPx / 2 }
|
||||
let bestOverlap = Number.POSITIVE_INFINITY
|
||||
// Spiral out from the centre so the chosen spot stays near where the map's
|
||||
// author would have put the player.
|
||||
const centreX = Math.floor(scene.gridWidth / 2)
|
||||
const centreY = Math.floor(scene.gridHeight / 2)
|
||||
const maxRadius = Math.max(scene.gridWidth, scene.gridHeight)
|
||||
for (let radius = 0; radius < maxRadius; radius += 1) {
|
||||
for (let dy = -radius; dy <= radius; dy += 1) {
|
||||
for (let dx = -radius; dx <= radius; dx += 1) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
|
||||
const x = (centreX + dx + 0.5) * subW
|
||||
const y = (centreY + dy + 0.5) * subH
|
||||
const overlap = blockedOverlap(scene, tileWidth, tileHeight, x, y, width, height)
|
||||
if (overlap === 0) return { x, y }
|
||||
if (overlap < bestOverlap) { bestOverlap = overlap; best = { x, y } }
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an actor belongs inside a wall draw list.
|
||||
*
|
||||
* Walls are drawn far to near, so an actor is inserted at the first wall that is
|
||||
* *nearer* than it — the index whose depth key (`cellY + cellX`) first exceeds the
|
||||
* actor's. That single rule is what makes standing behind a wall look right
|
||||
* without a depth buffer, and keeping it a pure function keeps it testable
|
||||
* without a renderer.
|
||||
*
|
||||
* @param walls - wall draws, in painter's order.
|
||||
* @param cellX - the actor's cell column.
|
||||
* @param cellY - the actor's cell row.
|
||||
* @returns the index to insert at (equal to the wall count when the actor is
|
||||
* nearest and goes last).
|
||||
*/
|
||||
export function depthInsertIndex(
|
||||
walls: readonly Pick<MapDraw, 'cellX' | 'cellY'>[],
|
||||
cellX: number,
|
||||
cellY: number,
|
||||
): number {
|
||||
const depth = cellX + cellY
|
||||
for (let index = 0; index < walls.length; index += 1) {
|
||||
const wall = walls[index]!
|
||||
if (wall.cellX + wall.cellY > depth) return index
|
||||
}
|
||||
return walls.length
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,162 @@
|
|||
// object-lookup.ts — DS1 对象 `(act, type, id)` → 实际 token/mode 的查找(移植自 OpenDiablo2)
|
||||
//
|
||||
// 为什么需要这一层:DS1 记录里的对象 `id` **不是** `Objects.txt` 的行号,而是"该 act 的对象
|
||||
// 表索引",那层映射硬编码在游戏里,凭表推不出来。OpenDiablo2 把它整理成了数据表
|
||||
// (`d2core/d2records/object_lookup_record_data.go`,7,891 行),并在
|
||||
// `d2mapstamp/stamp.go` 里这样用:
|
||||
//
|
||||
// lookup := records.LookupObject(act, object.Type, object.ID)
|
||||
// objectRecord := records.Object.Details[lookup.ObjectsTxtId]
|
||||
//
|
||||
// 本模块把那 3,615 条物体记录(`ObjectTypeItem`;怪物/NPC 行不移植——本项目没有怪物)
|
||||
// 装箱进 `object-lookup-data.ts`,在这里解析成查表 API。
|
||||
//
|
||||
// 与 `Objects.txt` 的关系(实测,见 `npm run verify:objects`):
|
||||
// 表里的 `token` **优先于** `Objects.txt` 的 `Token` 列——后者在 26 处写的是占位符
|
||||
// (`SS`/`XX`/`SL`),而表里是真 token(例如 act 2 的 jerhyn 是 `JE`、act 3 的楼梯是 `9C`)。
|
||||
// 表里有 750 条能直接对到 `Objects.txt` 行号,另外 2,665 条只给了 token/mode
|
||||
// (例如 act 1 的 `id 155 → L1/OP`「LargeChestR」),这些要靠 token 反查元数据。
|
||||
//
|
||||
// 编码与出处见 `object-lookup-data.ts` 的头部(由 `npm run port:object-lookup` 生成)。
|
||||
|
||||
import { OBJECT_LOOKUP_ROWS } from './object-lookup-data.ts'
|
||||
|
||||
/** DS1 对象 `type`:怪物/NPC(`MonPreset.txt` 索引)。 */
|
||||
export const OBJECT_TYPE_MONSTER = 1
|
||||
/** DS1 对象 `type`:可交互物体(本模块查的就是这一类)。 */
|
||||
export const OBJECT_TYPE_OBJECT = 2
|
||||
|
||||
/** 一条查找结果。 */
|
||||
export interface ObjectLookupEntry {
|
||||
/** `Levels.txt` 的 act 号,1..5。 */
|
||||
readonly act: number
|
||||
/** DS1 记录里的 `id`(该 act 对象表的索引)。 */
|
||||
readonly ds1Id: number
|
||||
/** `Objects.txt` 行号;-1 表示这份表没给出行号(要靠 token 反查)。 */
|
||||
readonly objectsTxtId: number
|
||||
/** 实际使用的 token(目录名),空串表示该对象没有美术。 */
|
||||
readonly token: string
|
||||
/** 引擎放置该对象时用的动画模式 token(`NU`/`OP`/…),空串表示未知。 */
|
||||
readonly mode: string
|
||||
/** 固定朝向;-1 表示未指定(用 0)。 */
|
||||
readonly direction: number
|
||||
/** `Base` 指向 `Monsters` 而不是 `Objects`(表里有 11 条这种异常记录)。 */
|
||||
readonly baseIsMonsters: boolean
|
||||
}
|
||||
|
||||
/** 解析后的每 act 表:ds1Id → 记录。 */
|
||||
const CACHE = new Map<number, Map<number, ObjectLookupEntry>>()
|
||||
|
||||
/** 这份数据表里除 token 之外的统计,用于文档与验证脚本。 */
|
||||
export interface ObjectLookupStats {
|
||||
/** 已移植的 act 号。 */
|
||||
readonly acts: readonly number[]
|
||||
/** 记录总数。 */
|
||||
readonly rows: number
|
||||
/** 有 token(有美术可查)的记录数。 */
|
||||
readonly withArt: number
|
||||
/** token 为空的记录数(不可见/占位对象)。 */
|
||||
readonly withoutArt: number
|
||||
/** 能直接对到 `Objects.txt` 行号的记录数。 */
|
||||
readonly withRow: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一个 act 的记录串。
|
||||
*
|
||||
* @param act - act 号, for error messages.
|
||||
* @param packed - `;`-separated `id:obj:token:mode[:d⟨dir⟩][:m]` records.
|
||||
* @returns the parsed map.
|
||||
*/
|
||||
function parseAct(act: number, packed: string): Map<number, ObjectLookupEntry> {
|
||||
const out = new Map<number, ObjectLookupEntry>()
|
||||
for (const record of packed.split(';')) {
|
||||
if (record === '') continue
|
||||
const parts = record.split(':')
|
||||
const [idText, objText, token = '', mode = ''] = parts
|
||||
const id = Number(idText)
|
||||
const objectsTxtId = Number(objText)
|
||||
if (!Number.isFinite(id) || !Number.isFinite(objectsTxtId)) {
|
||||
throw new Error(`object lookup act ${String(act)}: malformed record "${record}"`)
|
||||
}
|
||||
let direction = -1
|
||||
let baseIsMonsters = false
|
||||
for (const flag of parts.slice(4)) {
|
||||
if (flag === 'm') baseIsMonsters = true
|
||||
else if (flag.startsWith('d')) direction = Number(flag.slice(1))
|
||||
}
|
||||
out.set(id, { act, ds1Id: id, objectsTxtId, token, mode, direction, baseIsMonsters })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一个 act 的查找表(解析一次后缓存)。
|
||||
*
|
||||
* @param act - act 号.
|
||||
* @returns the map, or null when this act is not in the table.
|
||||
*/
|
||||
function tableFor(act: number): Map<number, ObjectLookupEntry> | null {
|
||||
const cached = CACHE.get(act)
|
||||
if (cached !== undefined) return cached
|
||||
const packed = OBJECT_LOOKUP_ROWS[act]
|
||||
if (packed === undefined) return null
|
||||
const parsed = parseAct(act, packed)
|
||||
CACHE.set(act, parsed)
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* 查一个 DS1 物体。
|
||||
*
|
||||
* @param act - act 号 (1..5)。
|
||||
* @param type - DS1 object `type`;只有 {@link OBJECT_TYPE_OBJECT} 会命中本表。
|
||||
* @param id - DS1 object `id`。
|
||||
* @returns the entry, or null when the table has no such record.
|
||||
*/
|
||||
export function lookupObject(act: number, type: number, id: number): ObjectLookupEntry | null {
|
||||
if (type !== OBJECT_TYPE_OBJECT) return null
|
||||
return tableFor(act)?.get(id) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 token 反查一个 {@link ObjectLookupEntry}(大小写不敏感)。
|
||||
*
|
||||
* 有些记录没有 `Objects.txt` 行号,但 token 是真的;调用方可以拿 token 去 `Objects.txt`
|
||||
* 里找元数据(名称、尺寸、可选性)。
|
||||
*
|
||||
* @param act - act 号。
|
||||
* @param token - the token to search for.
|
||||
* @returns the first entry with that token, or null.
|
||||
*/
|
||||
export function lookupByToken(act: number, token: string): ObjectLookupEntry | null {
|
||||
const table = tableFor(act)
|
||||
if (table === null) return null
|
||||
const wanted = token.trim().toLowerCase()
|
||||
for (const entry of table.values()) {
|
||||
if (entry.token.toLowerCase() === wanted) return entry
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据表的统计,用于文档与验证脚本。
|
||||
*
|
||||
* @returns the stats.
|
||||
*/
|
||||
export function objectLookupStats(): ObjectLookupStats {
|
||||
const acts = Object.keys(OBJECT_LOOKUP_ROWS).map(Number).sort((left, right) => left - right)
|
||||
let rows = 0
|
||||
let withArt = 0
|
||||
let withoutArt = 0
|
||||
let withRow = 0
|
||||
for (const act of acts) {
|
||||
for (const entry of tableFor(act)?.values() ?? []) {
|
||||
rows += 1
|
||||
if (entry.token === '') withoutArt += 1
|
||||
else withArt += 1
|
||||
if (entry.objectsTxtId >= 0) withRow += 1
|
||||
}
|
||||
}
|
||||
return { acts, rows, withArt, withoutArt, withRow }
|
||||
}
|
||||
|
|
@ -0,0 +1,905 @@
|
|||
/**
|
||||
* DS1 objects: `(type, id)` → the art the engine would draw, and where it lands.
|
||||
*
|
||||
* This is a port of three things out of ThePhrozenKeep/D2MOO (a C++
|
||||
* re-implementation of Diablo II 1.10f), which is the reference this project
|
||||
* reads instead of guessing:
|
||||
*
|
||||
* 1. **`D2Common_10884_COMPOSIT_unk`** — `source/D2Common/src/D2Composit.cpp`,
|
||||
* D2Common ordinal **10884**, address `D2Common.0x6FD466C0`. It is the
|
||||
* function that decides which composition file an object's frame comes from.
|
||||
* For `UNIT_OBJECT` it takes the **class token** from
|
||||
* `DATATBLS_GetObjModeTypeTxtRecord(nClass, 0)->szToken` (the `objtype.txt`
|
||||
* row for the object's `Objects.txt` id), the **mode token** from
|
||||
* `DATATBLS_GetObjModeTypeTxtRecord(nMode, 1)->szToken` (the `objmode.txt` row
|
||||
* for the object's animation mode), the weapon class from
|
||||
* `COMPOSIT_GetWeaponClassCode` (always `' hth'` for objects), sets
|
||||
* `szPathPrefix = "DATA\\GLOBAL\\OBJECTS"`, strips every space byte with the
|
||||
* `c &= ((c == ' ') - 1)` idiom, and finally:
|
||||
*
|
||||
* wsprintfA(szPath, "%s\\%s\\COF\\%s%s%s.COF", prefix, class, class, mode, weapon)
|
||||
*
|
||||
* i.e. `data\global\objects\<TOKEN>\COF\<TOKEN><MODE>hth.COF`. That is the
|
||||
* whole member-selection rule: **there is no per-object table of art files** —
|
||||
* the object's `Token` column and its *animation mode* are concatenated onto a
|
||||
* fixed path template, and a COF is what gets loaded.
|
||||
* 2. **The COF's layers pick the sprite files** — `decodeCof` reads them. A COF
|
||||
* layer record (9 bytes, starting at file offset `28 + 9 * layer`, see
|
||||
* `D2Comp.cpp` `sub_6F8A1250`, which walks `v12 += 9` and reads a 3-byte
|
||||
* weapon token at `+5`) carries a *composite index*, not a file name. The
|
||||
* composite index maps to a component directory through `D2Composit.h`'s
|
||||
* `COMPOSIT_HEAD, COMPOSIT_TORSO, ... COMPOSIT_SPECIAL1, ...` order. The
|
||||
* sprite's own file name is reconstructed from the same tokens as the COF
|
||||
* name, plus the object's fixed armor class `lit`:
|
||||
* `data\global\objects\<TOKEN>\<COMPONENT>\<TOKEN><COMPONENT>lit<MODE>hth.dcc`.
|
||||
* Archive evidence: sweeping all 1461 object COFs in `d2data.mpq` +
|
||||
* `d2exp.mpq` and rebuilding every layer's path this way resolves 1728 layers
|
||||
* to an existing `.dcc` by exact name; the remaining 18 are the same name
|
||||
* with a `.dc6` extension (the `1y`, `4x`, `e1`, `e2`, `tp`, `ho`, `yq`
|
||||
* tokens ship as DC6 in `d2exp.mpq`). Nothing else was tried and nothing else
|
||||
* was needed, so the rule is exact, not approximate.
|
||||
* 3. **The draw position** — `DUNGEON_GameToClientTileDrawPositionCoords` and
|
||||
* `DUNGEON_GameToClientSubtileDrawPositionCoords`
|
||||
* (`source/D2Common/src/D2Dungeon.cpp`, ordinals **10115** and **10117**),
|
||||
* documented in `doc/Coordinates.md`. They are ported verbatim under
|
||||
* {@link tileDrawPositionCoords} / {@link subtileDrawPositionCoords}, and
|
||||
* {@link objectDrawAnchor} is the object case of the latter.
|
||||
*
|
||||
* Two facts about modes that the art rule depends on:
|
||||
*
|
||||
* - The mode index is D2's object animation mode, `D2Common/include/DataTbls/ObjectsIds.h`
|
||||
* `enum D2C_ObjModes`: `OBJMODE_NEUTRAL=0` (`NU`), `OBJMODE_OPERATING=1` (`OP`),
|
||||
* `OBJMODE_OPENED=2` (`ON`), `OBJMODE_SPECIAL1..5=3..7` (`S1`..`S5`).
|
||||
* - A freshly placed map object is in `OBJMODE_NEUTRAL`:
|
||||
* `source/D2Game/src/OBJECTS/Objects.cpp` calls
|
||||
* `UNITS_ChangeAnimMode(pObject, OBJMODE_NEUTRAL)`. `Objects.txt`'s per-mode
|
||||
* `Mode0..Mode7` columns (a 0/1 flag array, `D2ObjectsTxt::nMode[8]` at
|
||||
* `0x13F`, mapped in `source/D2Common/src/DataTbls/ObjectsTbls.cpp`) say which
|
||||
* modes an object actually has art for: casket `C5` has modes `NU`/`OP`/`ON`
|
||||
* set and ships exactly `C5NUHTH.COF`, `c5ophth.cof`, `c5onhth.cof`; the door
|
||||
* `D1` has all seven set and ships all seven COFs.
|
||||
*/
|
||||
import type { D2Table } from './acts.ts'
|
||||
import { cell, parseTable } from './acts.ts'
|
||||
import { cofLayerOrder, decodeCof } from '../formats/cof.ts'
|
||||
import type { CofFile } from '../formats/cof.ts'
|
||||
import { decodeDcc } from '../formats/dcc.ts'
|
||||
import type { DccFile } from '../formats/dcc.ts'
|
||||
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
||||
import type { MountedArchives } from '../mpq/mount.ts'
|
||||
import { OBJECT_TYPE_OBJECT, lookupObject } from './object-lookup.ts'
|
||||
import type { ObjectLookupEntry } from './object-lookup.ts'
|
||||
|
||||
/** `Objects.txt` inside the archives. */
|
||||
const OBJECTS_TABLE = 'data\\global\\excel\\objects.txt'
|
||||
/** Prefix every object composition and sprite lives under (`szPathPrefix`). */
|
||||
const OBJECT_ROOT = 'data\\global\\objects\\'
|
||||
/** Weapon-class token the engine hard-codes for objects (`COMPOSIT_GetWeaponClassCode`). */
|
||||
const OBJECT_WEAPON = 'hth'
|
||||
/** Armor-class token objects always use; characters vary it (`lit`/`med`/`hvy`). */
|
||||
const OBJECT_ARMOR_CLASS = 'lit'
|
||||
/** How many animation modes `D2ObjectsTxt::nMode[8]` and `D2C_ObjModes` define. */
|
||||
export const OBJECT_MODE_COUNT = 8
|
||||
|
||||
/**
|
||||
* Animation-mode index → the token `objmode.txt` gives that row.
|
||||
*
|
||||
* `D2C_ObjModes` in `DataTbls/ObjectsIds.h` names them in the comments; the
|
||||
* tokens are what the archive actually uses (`C5NUHTH.COF`, `c5ophth.cof`,
|
||||
* `d1s2hth.cof`). The order is load-bearing: it is the index the engine passes
|
||||
* to `DATATBLS_GetObjModeTypeTxtRecord(nMode, 1)`.
|
||||
*/
|
||||
export const OBJECT_MODE_TOKENS = ['NU', 'OP', 'ON', 'S1', 'S2', 'S3', 'S4', 'S5'] as const
|
||||
|
||||
/**
|
||||
* Composite index → the archive's directory for that component.
|
||||
*
|
||||
* The same 16-entry order as `D2Composit.h` and as `Objects.txt`'s
|
||||
* `HD,TR,LG,RA,LA,RH,LH,SH,S1..S8` columns, which are the per-object flags
|
||||
* saying which of these the object's composition uses. Only `HD`, `TR` and
|
||||
* `S1..S6` appear under `data\global\objects\`, which matches those columns
|
||||
* being zero for every other entry across the whole table.
|
||||
*/
|
||||
export const OBJECT_COMPONENTS = [
|
||||
'hd', 'tr', 'lg', 'ra', 'la', 'rh', 'lh', 'sh', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Component preference when only a member-name list is available.
|
||||
*
|
||||
* `tr` is the object's body layer: of the 1461 shipped object COFs, 1453 declare
|
||||
* component 1 (`tr`) as their first layer, 5 declare component 0 (`hd`) and 3
|
||||
* declare component 8 (`s1`). The rest of the order is the composite order, so a
|
||||
* token that only ships `hd` art still resolves. When the COF itself is readable
|
||||
* use {@link loadObjectSheet}, which follows the COF's own declared layers and
|
||||
* draw order instead of this preference.
|
||||
*/
|
||||
const COMPONENT_PREFERENCE = ['tr', 'hd', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 'lg', 'ra', 'la', 'rh', 'lh', 'sh'] as const
|
||||
|
||||
/** Sub-tile width in client pixels: `DUNGEON_GameSubtileToClientCoords` step on x (16). */
|
||||
export const SUBTILE_WIDTH = 16
|
||||
/** Sub-tile height in client pixels: the same function's step on y (8). */
|
||||
export const SUBTILE_HEIGHT = 8
|
||||
/** Floor-tile width in client pixels: `DUNGEON_GameTileToClientCoords` step on x (80). */
|
||||
export const TILE_WIDTH = 80
|
||||
/** Floor-tile height in client pixels: the same function's step on y (40). */
|
||||
export const TILE_HEIGHT = 40
|
||||
|
||||
/**
|
||||
* `DUNGEON_GameTileToClientCoords` — D2Common ordinal 10110 (`0x6FD8D6E0`).
|
||||
*
|
||||
* Tile precision → client pixels, the *centre-line* conversion: a tile's client
|
||||
* centre. `doc/Coordinates.md` records it as `clientX = (gameX - gameY) / 2`,
|
||||
* `clientY = (gameX + gameY) / 4` for unit precision; at tile precision D2MOO
|
||||
* multiplies by the 160×80 pixel tile size and halves the y term.
|
||||
*
|
||||
* @param tileX - tile x.
|
||||
* @param tileY - tile y.
|
||||
* @returns client pixel x/y of the tile's centre.
|
||||
*/
|
||||
export function gameTileToClientCoords(tileX: number, tileY: number): { x: number; y: number } {
|
||||
return { x: TILE_WIDTH * (tileX - tileY), y: (TILE_WIDTH * (tileX + tileY)) / 2 }
|
||||
}
|
||||
|
||||
/**
|
||||
* `DUNGEON_GameSubTileToClientCoords` — D2Common ordinal 10111 (`0x6FD8D630`).
|
||||
*
|
||||
* Sub-tile precision → client pixels, again the centre-line conversion: one
|
||||
* sub-tile is 32×16 px, so its centre steps by (±16, ±8).
|
||||
*
|
||||
* @param subX - sub-tile x (a DS1 cell is 5 sub-tiles).
|
||||
* @param subY - sub-tile y.
|
||||
* @returns client pixel x/y of the sub-tile's centre.
|
||||
*/
|
||||
export function gameSubtileToClientCoords(subX: number, subY: number): { x: number; y: number } {
|
||||
return { x: SUBTILE_WIDTH * (subX - subY), y: SUBTILE_HEIGHT * (subX + subY) }
|
||||
}
|
||||
|
||||
/**
|
||||
* `DUNGEON_GameToClientCoords` — D2Common ordinal 10112 (`0x6FD8D660`).
|
||||
*
|
||||
* Unit precision, game → client, with no pixel scale at all: `(x - y) / 2`,
|
||||
* `(x + y) / 4`. Kept for completeness (it is what `doc/Coordinates.md`'s
|
||||
* headline formula describes) and to show why the draw-position functions are
|
||||
* the ones to use for pixels — this one is not in pixels.
|
||||
*
|
||||
* @param gameX - game-unit x.
|
||||
* @param gameY - game-unit y.
|
||||
* @returns client-unit x/y.
|
||||
*/
|
||||
export function gameToClientCoords(gameX: number, gameY: number): { x: number; y: number } {
|
||||
return { x: (gameX - gameY) / 2, y: (gameX + gameY) / 4 }
|
||||
}
|
||||
|
||||
/**
|
||||
* `DUNGEON_GameToClientTileDrawPositionCoords` — D2Common ordinal 10115 (`0x6FD8D790`).
|
||||
*
|
||||
* The engine's tile draw position: where a floor *or* wall tile's art is placed.
|
||||
* `source/D2Common/src/D2Dungeon.cpp` `D2Common_10052` (ordinal 10052) feeds the
|
||||
* corners of a room into this same function to build the room's client rect, and
|
||||
* `doc/Coordinates.md` calls the result the render position of the source unit.
|
||||
*
|
||||
* Note the asymmetry against {@link gameTileToClientCoords}: x loses half a tile
|
||||
* width and y gains **a whole tile height**. The y term is not decoration — wall
|
||||
* bitmaps carry negative block offsets, and this `+80` is what puts their art
|
||||
* back over the cell. The project's `src/game/d2map.ts` reproduces the x term
|
||||
* exactly (`(cx - cy) * 80 - 80`) but not the y term; see
|
||||
* {@link objectDrawAnchor}'s module notes and `scripts/verify-objects.ts`, which
|
||||
* prints both side by side.
|
||||
*
|
||||
* @param tileX - tile x.
|
||||
* @param tileY - tile y.
|
||||
* @returns client pixel x/y the tile's bitmap is drawn at.
|
||||
*/
|
||||
export function tileDrawPositionCoords(tileX: number, tileY: number): { x: number; y: number } {
|
||||
return { x: TILE_WIDTH * (tileX - tileY) - TILE_WIDTH, y: TILE_HEIGHT * (tileX + tileY) + TILE_HEIGHT * 2 }
|
||||
}
|
||||
|
||||
/**
|
||||
* `DUNGEON_GameToClientSubtileDrawPositionCoords` — D2Common ordinal 10117 (`0x6FD8D830`).
|
||||
*
|
||||
* The sub-tile draw position: half a sub-tile width to the left of the sub-tile
|
||||
* centre and one full sub-tile height below it. This is the function that places
|
||||
* an object, because a DS1 object is stored at sub-tile precision.
|
||||
*
|
||||
* The `-16`/`+16` constants are the *source square's* half-width and height, not
|
||||
* the sprite's: the engine has no frame-size term here at all. A caller that
|
||||
* centres by the frame's own width (as `src/game/d2map.ts` does for objects) is
|
||||
* therefore applying a rule the engine does not have.
|
||||
*
|
||||
* @param subX - sub-tile x.
|
||||
* @param subY - sub-tile y.
|
||||
* @returns client pixel x/y the sub-tile's art is drawn at.
|
||||
*/
|
||||
export function subtileDrawPositionCoords(subX: number, subY: number): { x: number; y: number } {
|
||||
return { x: SUBTILE_WIDTH * (subX - subY) - SUBTILE_WIDTH, y: SUBTILE_HEIGHT * (subX + subY) + SUBTILE_HEIGHT * 2 }
|
||||
}
|
||||
|
||||
/**
|
||||
* `DUNGEON_ClientTileDrawPositionToGameCoords` — D2Common ordinal 10114 (`0x6FD8D710`).
|
||||
*
|
||||
* The inverse of {@link tileDrawPositionCoords}, including D2MOO's C-style
|
||||
* division: negative coordinates round towards **negative infinity** (`v / 160 - 1`
|
||||
* on the negative branch), not towards zero. A port that used `Math.trunc` would
|
||||
* be off by one tile on half the map.
|
||||
*
|
||||
* @param clientX - client pixel x from {@link tileDrawPositionCoords}.
|
||||
* @param clientY - client pixel y from the same.
|
||||
* @returns the tile coordinates.
|
||||
*/
|
||||
export function clientTileDrawPositionToGameCoords(clientX: number, clientY: number): { x: number; y: number } {
|
||||
return { x: floorDiv(2 * clientY + clientX, 160), y: floorDiv(2 * clientY - clientX, 160) }
|
||||
}
|
||||
|
||||
/**
|
||||
* `DUNGEON_ClientSubileDrawPositionToGameCoords` — D2Common ordinal 10116 (`0x6FD8D7D0`).
|
||||
*
|
||||
* The sub-tile inverse of {@link subtileDrawPositionCoords}, with the same
|
||||
* floor-towards-negative-infinity division (divisor 32).
|
||||
*
|
||||
* @param clientX - client pixel x from {@link subtileDrawPositionCoords}.
|
||||
* @param clientY - client pixel y from the same.
|
||||
* @returns the sub-tile coordinates.
|
||||
*/
|
||||
export function clientSubtileDrawPositionToGameCoords(clientX: number, clientY: number): { x: number; y: number } {
|
||||
return { x: floorDiv(2 * clientY + clientX, 32), y: floorDiv(2 * clientY - clientX, 32) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Divide rounding towards negative infinity, as the C++ `p / n - 1` idiom does.
|
||||
*
|
||||
* @param value - numerator.
|
||||
* @param divisor - positive divisor.
|
||||
* @returns the floored quotient.
|
||||
*/
|
||||
function floorDiv(value: number, divisor: number): number {
|
||||
return value >= 0 ? Math.floor(value / divisor) : Math.floor(value / divisor) - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The anchor an object's frame is drawn at, relative to its sub-tile centre.
|
||||
*
|
||||
* The engine's answer is {@link subtileDrawPositionCoords}: a constant
|
||||
* `(-16, +16)` — half a sub-tile width left, one sub-tile height down — plus the
|
||||
* COF/DCC frame's own `xOffset`/`yOffset`. `tileWidth` and `tileHeight` are part
|
||||
* of the signature because every caller has them and because they are exactly
|
||||
* what the engine does *not* use: if a frame were positioned as
|
||||
* `(-width / 2, -height)`, a 32×32 barrel frame would land 32 px higher than the
|
||||
* engine puts it, and a 160-px-wide frame would be off by 64. They are validated
|
||||
* rather than read, so a caller that decoded nothing fails here instead of
|
||||
* silently emitting an anchor.
|
||||
*
|
||||
* @param tileWidth - the frame's width in pixels; must be finite and non-negative.
|
||||
* @param tileHeight - the frame's height in pixels; must be finite and non-negative.
|
||||
* @param frameOffsetX - the frame's own x offset (COF/DCC frame offset).
|
||||
* @param frameOffsetY - the frame's own y offset.
|
||||
* @returns the offset to add to `gameSubtileToClientCoords(object.x, object.y)`.
|
||||
*/
|
||||
export function objectDrawAnchor(
|
||||
tileWidth: number,
|
||||
tileHeight: number,
|
||||
frameOffsetX: number,
|
||||
frameOffsetY: number,
|
||||
): { x: number; y: number } {
|
||||
if (!Number.isFinite(tileWidth) || tileWidth < 0) {
|
||||
throw new Error(`objectDrawAnchor: frame width ${String(tileWidth)} is not a finite non-negative size`)
|
||||
}
|
||||
if (!Number.isFinite(tileHeight) || tileHeight < 0) {
|
||||
throw new Error(`objectDrawAnchor: frame height ${String(tileHeight)} is not a finite non-negative size`)
|
||||
}
|
||||
if (!Number.isFinite(frameOffsetX) || !Number.isFinite(frameOffsetY)) {
|
||||
throw new Error(`objectDrawAnchor: frame offset (${String(frameOffsetX)}, ${String(frameOffsetY)}) is not finite`)
|
||||
}
|
||||
return { x: -SUBTILE_WIDTH + frameOffsetX, y: SUBTILE_HEIGHT * 2 + frameOffsetY }
|
||||
}
|
||||
|
||||
/**
|
||||
* The anchor a DS1 object's art is drawn at, in absolute client pixels.
|
||||
*
|
||||
* `(DUNGEON_GameToClientSubtileDrawPositionCoords)` applied to the object's own
|
||||
* sub-tile position, with the frame offsets left at zero because the frame is not
|
||||
* known until the COF has been read.
|
||||
*
|
||||
* @param object - the DS1 object; `x`/`y` are sub-tiles.
|
||||
* @returns client pixel x/y of the object's draw position.
|
||||
*/
|
||||
export function objectAnchor(object: { readonly x: number; readonly y: number }): { x: number; y: number } {
|
||||
if (!Number.isFinite(object.x) || !Number.isFinite(object.y)) {
|
||||
throw new Error(`objectAnchor: object at (${String(object.x)}, ${String(object.y)}) is not finite`)
|
||||
}
|
||||
return subtileDrawPositionCoords(object.x, object.y)
|
||||
}
|
||||
|
||||
/** One `Objects.txt` row, as the object-art rule needs it. */
|
||||
export interface ObjectsRow {
|
||||
/** `Id` — the DS1 object entry's `id`, and the index `DATATBLS_GetObjectsTxtRecord` takes. */
|
||||
readonly id: number
|
||||
/** `Name`, e.g. `Barrel`. */
|
||||
readonly name: string
|
||||
/** `Token`, upper-cased: the `<TOKEN>` in every path the engine builds. */
|
||||
readonly token: string
|
||||
/** `SubClass` — the object class (`OBJSUBCLASS_DOOR` = 0x80, `CHEST` = 0x08, ...). */
|
||||
readonly subClass: number
|
||||
/** `Act` the object belongs to (0 = any). */
|
||||
readonly act: number
|
||||
/** `SizeX` / `SizeY` in sub-tiles, used for placement collision. */
|
||||
readonly sizeX: number
|
||||
readonly sizeY: number
|
||||
/** `Xoffset` / `Yoffset`: the row's own art nudge, added to the frame anchor. */
|
||||
readonly xOffset: number
|
||||
readonly yOffset: number
|
||||
/** `IsDoor` — doors take the operate path rather than the casket path. */
|
||||
readonly isDoor: boolean
|
||||
/** `Trans` — transparency mode. */
|
||||
readonly trans: number
|
||||
/** `Draw` — draw priority flag. */
|
||||
readonly draw: number
|
||||
/** `TotalPieces` — how many components the object's composition has. */
|
||||
readonly totalPieces: number
|
||||
/** `AutoMap` — automap cell/colour. */
|
||||
readonly autoMap: number
|
||||
/**
|
||||
* `Mode0..Mode7`: 1 when the object has a composition for that animation mode.
|
||||
*
|
||||
* This is `D2ObjectsTxt::nMode[8]`, and it is the array D2MOO consults
|
||||
* (`pObjectsTxtRecord->nMode[1]`, `[2]`) to decide whether an operate action has
|
||||
* art to play. It also matches the shipped COF set exactly.
|
||||
*/
|
||||
readonly mode: readonly number[]
|
||||
/** `Selectable0..7`: whether the object is targetable while in that mode. */
|
||||
readonly selectable: readonly number[]
|
||||
/** `FrameCnt0..7`: frames in that mode. */
|
||||
readonly frameCnt: readonly number[]
|
||||
/** `FrameDelta0..7`: per-frame delay, 1/256 units (the engine shifts these left by 8). */
|
||||
readonly frameDelta: readonly number[]
|
||||
/** `Start0..7`: the mode's first frame **within the COF**. */
|
||||
readonly start: readonly number[]
|
||||
/** `CycleAnim0..7`: whether that mode loops. */
|
||||
readonly cycleAnim: readonly number[]
|
||||
/** `Lit0..7`: whether the mode is drawn lit. */
|
||||
readonly lit: readonly number[]
|
||||
/** `HD`, `TR`, `LG`, `RA`, `LA`, `RH`, `LH`, `SH`, `S1..S8`: composition components present. */
|
||||
readonly components: readonly string[]
|
||||
}
|
||||
|
||||
/** `Objects.txt` parsed once, plus the id lookup the DS1 decoder needs. */
|
||||
export interface ObjectsTable {
|
||||
/** The raw parsed table, for callers that need a column not modelled here. */
|
||||
readonly table: D2Table
|
||||
/** Every row, in file order (`Id` order). */
|
||||
readonly rows: readonly ObjectsRow[]
|
||||
/** `Id` → row. */
|
||||
readonly byId: ReadonlyMap<number, ObjectsRow>
|
||||
}
|
||||
|
||||
/** Columns of the 16 composition flags, in `D2ObjectsTxt` order. */
|
||||
const COMPONENT_COLUMNS = ['HD', 'TR', 'LG', 'RA', 'LA', 'RH', 'LH', 'SH', 'S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7', 'S8'] as const
|
||||
|
||||
/**
|
||||
* Read one `Objects.txt` row.
|
||||
*
|
||||
* @param table - the parsed table.
|
||||
* @param row - the raw cells.
|
||||
* @returns the typed row.
|
||||
*/
|
||||
function toObjectsRow(table: D2Table, row: readonly string[]): ObjectsRow {
|
||||
const numbers = (prefix: string, count: number): number[] =>
|
||||
Array.from({ length: count }, (_, index) => Number(cell(table, row, `${prefix}${String(index)}`) || '0'))
|
||||
const components = COMPONENT_COLUMNS.filter(column => cell(table, row, column) !== '0' && cell(table, row, column) !== '')
|
||||
.map(column => column.toLowerCase())
|
||||
return {
|
||||
id: Number(cell(table, row, 'Id')),
|
||||
name: cell(table, row, 'Name'),
|
||||
token: cell(table, row, 'Token').trim().toUpperCase(),
|
||||
subClass: Number(cell(table, row, 'SubClass') || '0'),
|
||||
act: Number(cell(table, row, 'Act') || '0'),
|
||||
sizeX: Number(cell(table, row, 'SizeX') || '0'),
|
||||
sizeY: Number(cell(table, row, 'SizeY') || '0'),
|
||||
xOffset: Number(cell(table, row, 'Xoffset') || '0'),
|
||||
yOffset: Number(cell(table, row, 'Yoffset') || '0'),
|
||||
isDoor: cell(table, row, 'IsDoor') !== '0' && cell(table, row, 'IsDoor') !== '',
|
||||
trans: Number(cell(table, row, 'Trans') || '0'),
|
||||
draw: Number(cell(table, row, 'Draw') || '0'),
|
||||
totalPieces: Number(cell(table, row, 'TotalPieces') || '0'),
|
||||
autoMap: Number(cell(table, row, 'AutoMap') || '0'),
|
||||
mode: numbers('Mode', OBJECT_MODE_COUNT),
|
||||
selectable: numbers('Selectable', OBJECT_MODE_COUNT),
|
||||
frameCnt: numbers('FrameCnt', OBJECT_MODE_COUNT),
|
||||
frameDelta: numbers('FrameDelta', OBJECT_MODE_COUNT),
|
||||
start: numbers('Start', OBJECT_MODE_COUNT),
|
||||
cycleAnim: numbers('CycleAnim', OBJECT_MODE_COUNT),
|
||||
lit: numbers('Lit', OBJECT_MODE_COUNT),
|
||||
components,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse `data\global\excel\objects.txt` from a mounted archive stack.
|
||||
*
|
||||
* Uses `parseTable`/`cell` from `src/game/acts.ts` rather than a second
|
||||
* tab-separated reader, so the two cannot disagree about CRLF, a trailing newline
|
||||
* or a missing column.
|
||||
*
|
||||
* @param archives - the mounted archives (the file lives in `d2data.mpq`).
|
||||
* @returns the parsed table and its id lookup.
|
||||
*/
|
||||
export async function loadObjectsTable(archives: MountedArchives): Promise<ObjectsTable> {
|
||||
const table = parseTable(await archives.read(OBJECTS_TABLE))
|
||||
const rows: ObjectsRow[] = []
|
||||
for (const raw of table.rows) {
|
||||
const row = toObjectsRow(table, raw)
|
||||
if (!Number.isFinite(row.id)) continue
|
||||
rows.push(row)
|
||||
}
|
||||
const byId = new Map<number, ObjectsRow>()
|
||||
for (const row of rows) byId.set(row.id, row)
|
||||
return { table, rows, byId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a DS1 object entry to the art the engine would use for it.
|
||||
*
|
||||
* Two lookups happen here, in this order:
|
||||
*
|
||||
* 1. `(act, type, id)` goes through the hardcoded per-act object table
|
||||
* ({@link lookupObject}). The DS1 `id` is **not** an `Objects.txt` row number —
|
||||
* it indexes that table, which is why identity mapping produced nonsense
|
||||
* (an act 1 fountain, id 0, became `Objects.txt` row 0, "Expansion", token `''`).
|
||||
* The table's `token` is authoritative: in 26 places `Objects.txt` carries a
|
||||
* placeholder (`SS`/`XX`/`SL`) where the table has the real token.
|
||||
* 2. The table's `objectsTxtId`, or failing that the table's token, is used to find
|
||||
* the `Objects.txt` row, which supplies the metadata (name, size, modes, flags).
|
||||
*
|
||||
* @param tables - the parsed `Objects.txt`.
|
||||
* @param act - the level's act, 1..5.
|
||||
* @param objectType - the DS1 object's `type` (2 = object, 1 = monster spawn).
|
||||
* @param objectId - the DS1 object's `id`.
|
||||
* @returns the entry, the optional metadata row, and the token/mode to draw with.
|
||||
* @throws when the object type is an object but the table has no such id, because a
|
||||
* silent fallback would bake the wrong token into the pack under the right id.
|
||||
*/
|
||||
export function resolveDs1Object(
|
||||
tables: ObjectsTable,
|
||||
act: number,
|
||||
objectType: number,
|
||||
objectId: number,
|
||||
): ResolvedDs1Object {
|
||||
if (!Number.isFinite(objectId)) {
|
||||
throw new Error(`resolveDs1Object: object id ${String(objectId)} is not a number`)
|
||||
}
|
||||
if (objectType !== OBJECT_TYPE_OBJECT) {
|
||||
return { entry: null, row: null, token: '', mode: '', artless: true, kind: 'monster' }
|
||||
}
|
||||
const entry = lookupObject(act, objectType, objectId)
|
||||
if (entry === null) {
|
||||
throw new Error(`resolveDs1Object: act ${String(act)} object id ${String(objectId)} is not in the object lookup table`)
|
||||
}
|
||||
const byId = entry.objectsTxtId >= 0 ? tables.byId.get(entry.objectsTxtId) : undefined
|
||||
const row = byId ?? (entry.token === '' ? undefined : findByToken(tables, entry.token)) ?? null
|
||||
const token = entry.token !== '' ? entry.token : (row?.token ?? '')
|
||||
return {
|
||||
entry,
|
||||
row,
|
||||
token,
|
||||
mode: entry.mode,
|
||||
artless: token.trim() === '',
|
||||
kind: 'object',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an `Objects.txt` row by token (case-insensitive), for records whose lookup
|
||||
* entry has no row number.
|
||||
*
|
||||
* @param tables - the parsed table.
|
||||
* @param token - the token to look for.
|
||||
* @returns the first matching row, or undefined.
|
||||
*/
|
||||
function findByToken(tables: ObjectsTable, token: string): ObjectsRow | undefined {
|
||||
return tables.rows.find(row => row.token.trim().toLowerCase() === token.trim().toLowerCase())
|
||||
}
|
||||
|
||||
/** What {@link resolveDs1Object} found for one DS1 object entry. */
|
||||
export interface ResolvedDs1Object {
|
||||
/** The hardcoded lookup table record, or null for a monster spawn. */
|
||||
readonly entry: ObjectLookupEntry | null
|
||||
/** The `Objects.txt` row, when one could be found (by id, else by token). */
|
||||
readonly row: ObjectsRow | null
|
||||
/** The token to draw with; empty when the object has no art. */
|
||||
readonly token: string
|
||||
/** The animation mode token the engine places it in (`NU`/`OP`/…); empty when unknown. */
|
||||
readonly mode: string
|
||||
/** True when no token could be found: drawing nothing is then correct. */
|
||||
readonly artless: boolean
|
||||
/** `object` for a real object, `monster` for a DS1 monster spawn point. */
|
||||
readonly kind: 'object' | 'monster'
|
||||
}
|
||||
|
||||
/** What {@link resolveObjectArt} is asked to resolve. */
|
||||
export interface ObjectArtRequest {
|
||||
/** The DS1 object, exactly as decoded; `x`/`y` are sub-tiles. */
|
||||
readonly object: { type: number; id: number; x: number; y: number; flags: number }
|
||||
/**
|
||||
* The token to draw with, taken from the hardcoded object lookup table (it wins
|
||||
* over `Objects.txt`'s `Token` column, which carries placeholders in 26 places).
|
||||
*/
|
||||
readonly token: string
|
||||
/**
|
||||
* The animation mode token the engine places the object in, from the same table
|
||||
* (`NU`/`OP`/`ON`/`S1`…); empty string when the table did not say.
|
||||
*/
|
||||
readonly mode: string
|
||||
/**
|
||||
* The `Objects.txt` row, or `null` when the table gave no row and the token did
|
||||
* not match one. Only metadata (name, sub-class) is read from it.
|
||||
*/
|
||||
readonly row: { name: string; token: string; subClass: number; mode: number; hp: number } | null
|
||||
/** Every member name in the mounted archives, for name-only resolution. */
|
||||
readonly members: readonly string[]
|
||||
}
|
||||
|
||||
/** The art the engine would use for one DS1 object. */
|
||||
export interface ObjectArt {
|
||||
/** The archive member, or `null` when the token ships no art at all. */
|
||||
readonly member: string | null
|
||||
/** The animation-mode token actually used, e.g. `NU`. */
|
||||
readonly mode: string
|
||||
/** Frame index within the resolved member; 0 is the mode's first frame here. */
|
||||
readonly frameIndex: number
|
||||
/** Client pixel draw position of the object, frame offsets excluded. */
|
||||
readonly anchor: { readonly x: number; readonly y: number }
|
||||
/** Everything the resolution could not decide, or decided by fallback. */
|
||||
readonly notes: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The COF member name the engine builds for an object token and mode.
|
||||
*
|
||||
* The literal template from `D2Common_10884_COMPOSIT_unk`:
|
||||
* `DATA\GLOBAL\OBJECTS\<TOKEN>\COF\<TOKEN><MODE>hth.COF`, with the space-stripping
|
||||
* already applied (the tokens are trimmed, so there is nothing left to strip).
|
||||
*
|
||||
* @param token - `Objects.txt` `Token`, any case.
|
||||
* @param modeIndex - animation mode index, 0..7.
|
||||
* @returns the lower-cased member name.
|
||||
*/
|
||||
export function objectCofMember(token: string, modeIndex: number): string {
|
||||
const name = token.trim().toLowerCase()
|
||||
const mode = modeToken(modeIndex).toLowerCase()
|
||||
return `${OBJECT_ROOT}${name}\\cof\\${name}${mode}${OBJECT_WEAPON}.cof`
|
||||
}
|
||||
|
||||
/**
|
||||
* The sprite member name for one component of an object's composition.
|
||||
*
|
||||
* Reconstructed the way `verify-dcc.ts` and `src/game/character.ts` reconstruct a
|
||||
* character's: the COF stores no file name, only the component index and the
|
||||
* weapon class, so the name is `<TOKEN><COMPONENT>lit<MODE>hth.dcc` in the
|
||||
* component's directory. Objects keep the armor class fixed at `lit`.
|
||||
*
|
||||
* @param token - `Objects.txt` `Token`, any case.
|
||||
* @param component - component directory, e.g. `tr`.
|
||||
* @param modeIndex - animation mode index, 0..7.
|
||||
* @returns the lower-cased `.dcc` member name.
|
||||
*/
|
||||
export function objectSpriteMember(token: string, component: string, modeIndex: number): string {
|
||||
const name = token.trim().toLowerCase()
|
||||
const part = component.toLowerCase()
|
||||
const mode = modeToken(modeIndex).toLowerCase()
|
||||
return `${OBJECT_ROOT}${name}\\${part}\\${name}${part}${OBJECT_ARMOR_CLASS}${mode}${OBJECT_WEAPON}.dcc`
|
||||
}
|
||||
|
||||
/**
|
||||
* The mode token for an index, clamped into range.
|
||||
*
|
||||
* @param modeIndex - animation mode index.
|
||||
* @returns the token, e.g. `NU`.
|
||||
*/
|
||||
function modeToken(modeIndex: number): string {
|
||||
const index = Number.isFinite(modeIndex) ? Math.trunc(modeIndex) : 0
|
||||
return OBJECT_MODE_TOKENS[Math.min(Math.max(index, 0), OBJECT_MODE_TOKENS.length - 1)] ?? 'NU'
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine's member order, with the requested mode first.
|
||||
*
|
||||
* A placed object is in `NU`, so `NU` is first for every object the packer sees;
|
||||
* the requested mode comes first only so a caller that knows better (an opened
|
||||
* door, an operating chest) is not overridden.
|
||||
*
|
||||
* @param requested - the requested animation mode index.
|
||||
* @returns mode indices, most preferred first.
|
||||
*/
|
||||
function modeOrder(requested: number): number[] {
|
||||
const start = Number.isFinite(requested) ? Math.min(Math.max(Math.trunc(requested), 0), OBJECT_MODE_COUNT - 1) : 0
|
||||
const order = [start]
|
||||
for (let index = 0; index < OBJECT_MODE_COUNT; index += 1) if (index !== start) order.push(index)
|
||||
return order
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a member case-insensitively, preferring the archive's own spelling.
|
||||
*
|
||||
* The engine's lookups go through Storm's case-insensitive hash, and the archives
|
||||
* really do mix case (`Data\Global\Objects\1Y\S1\1ys1litnuhth.DC6`,
|
||||
* `data\global\objects\C5\COF\C5NUHTH.COF`), so a byte-exact compare would fail.
|
||||
*
|
||||
* @param members - every member name.
|
||||
* @param wanted - the lower-cased name to find.
|
||||
* @returns the member as the archive spells it, or undefined.
|
||||
*/
|
||||
function findMember(members: readonly string[], wanted: string): string | undefined {
|
||||
const exact = members.find(name => name.toLowerCase() === wanted)
|
||||
return exact
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the art member an object is drawn from, from a member-name list.
|
||||
*
|
||||
* The engine's rule, in order: the **mode token** comes from the object's
|
||||
* animation mode through `objmode.txt`
|
||||
* (`DATATBLS_GetObjModeTypeTxtRecord(nMode, 1)`), the **class token** from
|
||||
* `Objects.txt`/`objtype.txt`, and the path is the fixed template in
|
||||
* `D2Common_10884_COMPOSIT_unk`. What this function adds over that is only the
|
||||
* fallback for a mode the object has no art for, and the choice of *component*
|
||||
* when the COF cannot be read: the COF is the engine's real entry point, and it
|
||||
* declares which components exist. With names alone, `tr` (the body layer, first
|
||||
* layer of 1453 of 1461 shipped object COFs) is preferred, then the composite
|
||||
* order.
|
||||
*
|
||||
* Two things this cannot decide, reported in `notes` rather than guessed:
|
||||
* a mode the object has no art for, and the frame index inside that mode
|
||||
* (`Objects.txt` `Start{mode}` gives the mode's first frame within the COF, and
|
||||
* `FrameCnt{mode}` its length, so frame 0 here is the COF's first frame — the
|
||||
* mode's own first frame only when `Start{mode}` is 0).
|
||||
*
|
||||
* @param request - the object, its row (or null) and the archive's member list.
|
||||
* @returns the member, the mode token, the frame index, the anchor and notes.
|
||||
* @throws when the request is malformed — a missing token or a non-finite object
|
||||
* position means the caller lost data, and a wrong member is worse than a stop.
|
||||
* A token that genuinely ships no art returns `member: null` instead, because
|
||||
* that is what the engine draws: nothing.
|
||||
*/
|
||||
export function resolveObjectArt(request: ObjectArtRequest): ObjectArt {
|
||||
const { object, members } = request
|
||||
if (!Number.isFinite(object.type) || !Number.isFinite(object.id) || !Number.isFinite(object.x) || !Number.isFinite(object.y) || !Number.isFinite(object.flags)) {
|
||||
throw new Error(`resolveObjectArt: object ${String(object.type)}/${String(object.id)} at (${String(object.x)}, ${String(object.y)}) is not finite`)
|
||||
}
|
||||
const notes: string[] = []
|
||||
const anchor = objectAnchor(object)
|
||||
const token = request.token.trim().toUpperCase()
|
||||
if (token === '') {
|
||||
notes.push(`act object id ${String(object.id)} has no token in the lookup table, so it has no art`)
|
||||
return { member: null, mode: 'NU', frameIndex: 0, anchor, notes }
|
||||
}
|
||||
// The mode comes from the lookup table (`OP` for a casket, `ON` for a campfire);
|
||||
// when it does not say, the engine's placement default is `NU`.
|
||||
const requestedToken = request.mode.trim().toUpperCase()
|
||||
const requestedIndex = (OBJECT_MODE_TOKENS as readonly string[]).indexOf(requestedToken)
|
||||
const requested = requestedIndex >= 0 ? requestedIndex : 0
|
||||
if (requestedToken !== '' && requestedIndex < 0) {
|
||||
notes.push(`lookup table mode "${requestedToken}" is not one of ${OBJECT_MODE_TOKENS.join('/')}; using NU`)
|
||||
}
|
||||
if (requestedIndex > 0) notes.push(`placed in mode ${requestedToken} by the lookup table`)
|
||||
|
||||
// The engine loads a COF, and the COF lists the layers. Prefer the mode the
|
||||
// object is in; fall back through the mode order when this token ships no art
|
||||
// for it (a chest in a level that placed it opened, for instance).
|
||||
let modeIndex = requested
|
||||
let cof: string | undefined
|
||||
for (const candidate of modeOrder(requested)) {
|
||||
const found = findMember(members, objectCofMember(token, candidate))
|
||||
if (found !== undefined) { cof = found; modeIndex = candidate; break }
|
||||
}
|
||||
if (cof === undefined) {
|
||||
notes.push(`token ${token} ships no COF for any mode (looked for ${objectCofMember(token, requested)})`)
|
||||
} else if (modeIndex !== requested) {
|
||||
notes.push(`mode ${String(requested)} has no COF; fell back to ${OBJECT_MODE_TOKENS[modeIndex] ?? 'NU'}`)
|
||||
}
|
||||
|
||||
// Sprites: the same tokens as the COF name, one file per component directory.
|
||||
let member: string | null = null
|
||||
let chosenMode = modeIndex
|
||||
const layers: string[] = []
|
||||
const order = cof === undefined ? modeOrder(requested) : [modeIndex]
|
||||
outer: for (const index of order) {
|
||||
for (const component of COMPONENT_PREFERENCE) {
|
||||
const found = findMember(members, objectSpriteMember(token, component, index))
|
||||
if (found === undefined) continue
|
||||
layers.push(found)
|
||||
if (member === null) { member = found; chosenMode = index }
|
||||
}
|
||||
if (member !== null) break outer
|
||||
}
|
||||
// `lit` is not universal in the shipped set: a handful of tokens store the same
|
||||
// name without it, so those are matched too rather than reported as missing.
|
||||
if (member === null) {
|
||||
for (const index of order) {
|
||||
const mode = OBJECT_MODE_TOKENS[index]?.toLowerCase() ?? 'nu'
|
||||
const prefix = `${OBJECT_ROOT}${token.toLowerCase()}\\`
|
||||
const suffix = `${mode}${OBJECT_WEAPON}.dcc`
|
||||
const hits = members
|
||||
.filter(name => name.toLowerCase().startsWith(prefix) && name.toLowerCase().endsWith(suffix))
|
||||
.sort()
|
||||
if (hits.length > 0) { member = hits[0] ?? null; chosenMode = index; layers.push(...hits); break }
|
||||
}
|
||||
}
|
||||
if (member === null) {
|
||||
notes.push(`token ${token} ships no sprite member for mode ${OBJECT_MODE_TOKENS[chosenMode] ?? 'NU'}`)
|
||||
}
|
||||
|
||||
if (cof !== undefined) notes.push(`cof ${cof}`)
|
||||
if (layers.length > 1) notes.push(`layers ${layers.join(' ')}`)
|
||||
if (request.row !== null && (request.row.mode < 0 || request.row.mode >= OBJECT_MODE_COUNT)) {
|
||||
notes.push(`row mode ${String(request.row.mode)} is outside 0..${String(OBJECT_MODE_COUNT - 1)}`)
|
||||
}
|
||||
notes.push('frameIndex is the COF\'s first frame; add Objects.txt Start{mode} for the mode\'s own first frame')
|
||||
|
||||
return {
|
||||
member,
|
||||
mode: OBJECT_MODE_TOKENS[chosenMode] ?? 'NU',
|
||||
frameIndex: 0,
|
||||
anchor,
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
/** One composited object animation, the way the engine builds one. */
|
||||
export interface ObjectSheet {
|
||||
/** The composited frames for direction 0, in COF frame order. */
|
||||
readonly sheet: SpriteSheet
|
||||
/** Directions the COF declares (1 for most objects, 4 for a rare few). */
|
||||
readonly directions: number
|
||||
/** Frames per direction, i.e. the COF's own count. */
|
||||
readonly framesPerDirection: number
|
||||
/** The COF member that was read. */
|
||||
readonly cof: string
|
||||
/** The sprite members the COF's layers resolved to, in draw order. */
|
||||
readonly members: readonly string[]
|
||||
/** Layers with no sprite, or that failed to decode. */
|
||||
readonly skipped: number
|
||||
/** Non-fatal observations. */
|
||||
readonly notes: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an object's frames the way the engine does: read the COF, then its layers.
|
||||
*
|
||||
* This is the authoritative path — it is what `D2Common_10884_COMPOSIT_unk`
|
||||
* resolves and what `D2Comp.cpp`'s `sub_6F8A1250` loads — and it needs the
|
||||
* archives, because the component list lives inside the COF bytes and nowhere
|
||||
* else. The layer decoders are `src/formats/cof.ts` and `src/formats/dcc.ts`, the
|
||||
* same ones `src/game/character.ts` uses; the draw order comes from
|
||||
* `cofLayerOrder`, and the frame-window arithmetic is `Start{mode}` +
|
||||
* `FrameCnt{mode}` from `Objects.txt` when the row is supplied.
|
||||
*
|
||||
* @param archives - the mounted archives.
|
||||
* @param token - `Objects.txt` `Token`.
|
||||
* @param modeIndex - animation mode index, 0..7.
|
||||
* @param row - the `Objects.txt` row, for the mode's frame window; optional.
|
||||
* @returns the composited sheet.
|
||||
* @throws when the COF for that token and mode is absent, naming both.
|
||||
*/
|
||||
export async function loadObjectSheet(
|
||||
archives: MountedArchives,
|
||||
token: string,
|
||||
modeIndex: number,
|
||||
row?: ObjectsRow,
|
||||
): Promise<ObjectSheet> {
|
||||
const cofMember = objectCofMember(token, modeIndex)
|
||||
let cof: CofFile
|
||||
try {
|
||||
cof = decodeCof(await archives.read(cofMember))
|
||||
} catch (err) {
|
||||
throw new Error(`loadObjectSheet: ${cofMember} (token ${token}, mode ${OBJECT_MODE_TOKENS[modeIndex] ?? '?'}) failed: ${(err as Error).message}`)
|
||||
}
|
||||
const names = await archives.listFiles()
|
||||
const notes: string[] = []
|
||||
const members: string[] = []
|
||||
const sprites: (DccFile | null)[] = []
|
||||
for (const layer of cof.layers) {
|
||||
const component = OBJECT_COMPONENTS[layer.type]
|
||||
if (component === undefined) { notes.push(`layer type ${String(layer.type)} has no component directory`); sprites.push(null); continue }
|
||||
const wanted = objectSpriteMember(token, component, modeIndex)
|
||||
let found = findMember(names, wanted)
|
||||
if (found === undefined) found = findMember(names, wanted.replace(/\.dcc$/i, '.dc6'))
|
||||
if (found === undefined) { notes.push(`no sprite for component ${component} (${wanted})`); sprites.push(null); continue }
|
||||
try {
|
||||
sprites.push(decodeDcc(await archives.read(found)))
|
||||
members.push(found)
|
||||
} catch (err) {
|
||||
notes.push(`${found}: ${(err as Error).message}`)
|
||||
sprites.push(null)
|
||||
}
|
||||
}
|
||||
|
||||
const start = row === undefined ? 0 : (row.start[modeIndex] ?? 0)
|
||||
const count = row === undefined ? cof.framesPerDirection : Math.max(1, row.frameCnt[modeIndex] ?? 1)
|
||||
const frames: SpriteFrame[] = []
|
||||
for (let index = 0; index < cof.framesPerDirection; index += 1) frames.push(blankFrame())
|
||||
const wanted = row === undefined ? [0] : Array.from({ length: count }, (_, offset) => start + offset)
|
||||
const directions = Math.max(1, cof.numberOfDirections)
|
||||
for (const index of wanted) {
|
||||
if (index < 0 || index >= cof.framesPerDirection) continue
|
||||
let left = Number.POSITIVE_INFINITY
|
||||
let top = Number.POSITIVE_INFINITY
|
||||
let right = Number.NEGATIVE_INFINITY
|
||||
let bottom = Number.NEGATIVE_INFINITY
|
||||
let placed = 0
|
||||
for (const sprite of sprites) {
|
||||
if (sprite === null) continue
|
||||
const direction = sprite.directions[0]
|
||||
const frame = direction?.frames[index]
|
||||
if (frame === undefined) continue
|
||||
left = Math.min(left, direction!.box.left)
|
||||
top = Math.min(top, direction!.box.top)
|
||||
right = Math.max(right, direction!.box.left + direction!.box.width)
|
||||
bottom = Math.max(bottom, direction!.box.top + direction!.box.height)
|
||||
placed += 1
|
||||
}
|
||||
if (placed === 0) continue
|
||||
const width = Math.max(1, Math.round(right) - Math.round(left))
|
||||
const height = Math.max(1, Math.round(bottom) - Math.round(top))
|
||||
const composed: SpriteFrame = { width, height, indices: new Uint8Array(width * height), mask: new Uint8Array(width * height) }
|
||||
const ordered = cofLayerOrder(cof, 0, index)
|
||||
for (const layerIndex of ordered) {
|
||||
const sprite = sprites[layerIndex]
|
||||
if (sprite === null || sprite === undefined) continue
|
||||
const direction = sprite.directions[0]
|
||||
const frame = direction?.frames[index]
|
||||
if (frame === undefined) continue
|
||||
blit(composed, frame.frame, Math.round(direction!.box.left) - Math.round(left), Math.round(direction!.box.top) - Math.round(top))
|
||||
}
|
||||
frames[index] = composed
|
||||
}
|
||||
|
||||
return {
|
||||
sheet: { groups: [{ frames }], width: null },
|
||||
directions,
|
||||
framesPerDirection: cof.framesPerDirection,
|
||||
cof: cofMember,
|
||||
members,
|
||||
skipped: sprites.filter(sprite => sprite === null).length,
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A transparent 1×1 frame, so a slot with no art still occupies its COF index.
|
||||
*
|
||||
* @returns the frame.
|
||||
*/
|
||||
function blankFrame(): SpriteFrame {
|
||||
return { width: 1, height: 1, indices: new Uint8Array(1), mask: new Uint8Array(1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Masked blit of one layer onto the composed frame.
|
||||
*
|
||||
* Palette index 0 is transparent in D2's art, so a layer paints only where it has
|
||||
* a non-zero pixel — the same rule the DT1 tile compositor uses.
|
||||
*
|
||||
* @param target - destination frame buffers.
|
||||
* @param source - the layer's frame.
|
||||
* @param atX - destination x.
|
||||
* @param atY - destination y.
|
||||
*/
|
||||
function blit(
|
||||
target: { indices: Uint8Array; mask: Uint8Array; width: number; height: number },
|
||||
source: SpriteFrame,
|
||||
atX: number,
|
||||
atY: number,
|
||||
): void {
|
||||
for (let y = 0; y < source.height; y += 1) {
|
||||
const ty = atY + y
|
||||
if (ty < 0 || ty >= target.height) continue
|
||||
for (let x = 0; x < source.width; x += 1) {
|
||||
const value = source.indices[y * source.width + x] ?? 0
|
||||
if (value === 0) continue
|
||||
const tx = atX + x
|
||||
if (tx < 0 || tx >= target.width) continue
|
||||
target.indices[ty * target.width + tx] = value
|
||||
target.mask[ty * target.width + tx] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
/**
|
||||
* Quests and NPCs.
|
||||
*
|
||||
* Diablo II's quests are a small state machine per act: talk to an NPC to accept,
|
||||
* do the thing, talk again to be rewarded. `Quest.txt` holds the names and
|
||||
* descriptions (through the string table) and the objective; the dialogue itself
|
||||
* lives in code with its text in the TBL. This module models the part that is
|
||||
* structural — the states, the objective counter, the reward, and the dialogue
|
||||
* *selection* — and leaves the actual lines to the data.
|
||||
*
|
||||
* The dialogue is chosen by state rather than listed blindly, because that is the
|
||||
* behaviour that matters: an NPC who offers a quest already in progress, or who
|
||||
* forgets a completed one, is the classic quest bug.
|
||||
*/
|
||||
import { numberCell, resolveText, textCell } from './tables.ts'
|
||||
import type { TextSource } from './tables.ts'
|
||||
|
||||
/** One NPC. */
|
||||
export interface NpcDef {
|
||||
/** Table id. */
|
||||
readonly id: string
|
||||
/** Display name. */
|
||||
readonly name: string
|
||||
/** The quest this NPC offers, when any. */
|
||||
readonly questId: string | null
|
||||
/** Lines offered while the quest is available. */
|
||||
readonly offerLines: readonly string[]
|
||||
/** Lines offered while the quest is in progress. */
|
||||
readonly progressLines: readonly string[]
|
||||
/** Lines offered once the quest is done. */
|
||||
readonly doneLines: readonly string[]
|
||||
}
|
||||
|
||||
/** One quest. */
|
||||
export interface QuestDef {
|
||||
/** Table id. */
|
||||
readonly id: string
|
||||
/** Display name. */
|
||||
readonly name: string
|
||||
/** Description, for the quest log. */
|
||||
readonly description: string
|
||||
/** Monster id to kill, or `*` for any. */
|
||||
readonly monsterId: string
|
||||
/** How many kills complete it. */
|
||||
readonly killCount: number
|
||||
/** Experience awarded on completion. */
|
||||
readonly rewardXp: number
|
||||
/** Gold awarded on completion. */
|
||||
readonly rewardGold: number
|
||||
}
|
||||
|
||||
/** A quest's live state. */
|
||||
export interface QuestProgress {
|
||||
/** The definition. */
|
||||
readonly def: QuestDef
|
||||
/** Where it stands. */
|
||||
status: 'inactive' | 'active' | 'complete'
|
||||
/** Kills recorded so far. */
|
||||
kills: number
|
||||
}
|
||||
|
||||
/** Raised when a quest id is used that the log does not know. */
|
||||
export class QuestError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'QuestError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one NPC row.
|
||||
*
|
||||
* @param row - the record.
|
||||
* @param rowIndex - position, for a fallback id.
|
||||
* @param text - text source for name and dialogue indices.
|
||||
* @returns the NPC.
|
||||
*/
|
||||
export function npcFromRow(
|
||||
row: Readonly<Record<string, string>>,
|
||||
rowIndex: number,
|
||||
text: TextSource,
|
||||
): NpcDef {
|
||||
const id = textCell(row, 'Id', textCell(row, 'npc', `npc${String(rowIndex)}`))
|
||||
// Split first (a cell may list several indices or literals), resolve each part,
|
||||
// then split again: a string table entry can itself hold several lines, and
|
||||
// resolving after splitting would leave those pipes in the text.
|
||||
const lines = (column: string): string[] =>
|
||||
textCell(row, column, '')
|
||||
.split('|')
|
||||
.map(part => resolveText(part.trim(), text))
|
||||
.flatMap(resolved => resolved.split('|'))
|
||||
.map(line => line.trim())
|
||||
.filter(line => line !== '')
|
||||
return {
|
||||
id,
|
||||
name: resolveText(textCell(row, 'Name', id), text) || id,
|
||||
questId: textCell(row, 'Quest', '') || null,
|
||||
offerLines: lines('Offer'),
|
||||
progressLines: lines('Progress'),
|
||||
doneLines: lines('Done'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every NPC in a table.
|
||||
*
|
||||
* @param table - an NPC-shaped table.
|
||||
* @param text - text source.
|
||||
* @returns the NPCs.
|
||||
*/
|
||||
export function npcsFromTable(
|
||||
table: { rows: readonly Readonly<Record<string, string>>[] },
|
||||
text: TextSource,
|
||||
): NpcDef[] {
|
||||
return table.rows.map((row, index) => npcFromRow(row, index, text))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one quest row.
|
||||
*
|
||||
* @param row - the record.
|
||||
* @param rowIndex - position, for a fallback id.
|
||||
* @param text - text source for name and description indices.
|
||||
* @returns the quest.
|
||||
*/
|
||||
export function questFromRow(
|
||||
row: Readonly<Record<string, string>>,
|
||||
rowIndex: number,
|
||||
text: TextSource,
|
||||
): QuestDef {
|
||||
const id = textCell(row, 'Id', textCell(row, 'quest', `quest${String(rowIndex)}`))
|
||||
return {
|
||||
id,
|
||||
name: resolveText(textCell(row, 'Name', id), text) || id,
|
||||
description: resolveText(textCell(row, 'Description', ''), text),
|
||||
monsterId: textCell(row, 'MonsterId', textCell(row, 'monster', '*')) || '*',
|
||||
killCount: Math.max(1, numberCell(row, 'KillCount', numberCell(row, 'count', 1))),
|
||||
rewardXp: Math.max(0, numberCell(row, 'RewardXP', numberCell(row, 'xp', 0))),
|
||||
rewardGold: Math.max(0, numberCell(row, 'RewardGold', numberCell(row, 'gold', 0))),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every quest in a table.
|
||||
*
|
||||
* @param table - a `Quest.txt`-shaped table.
|
||||
* @param text - text source.
|
||||
* @returns the quests.
|
||||
*/
|
||||
export function questsFromTable(
|
||||
table: { rows: readonly Readonly<Record<string, string>>[] },
|
||||
text: TextSource,
|
||||
): QuestDef[] {
|
||||
return table.rows.map((row, index) => questFromRow(row, index, text))
|
||||
}
|
||||
|
||||
/** A reward handed out when a quest completes. */
|
||||
export interface QuestReward {
|
||||
/** Quest that completed. */
|
||||
readonly questId: string
|
||||
/** Experience awarded. */
|
||||
readonly xp: number
|
||||
/** Gold awarded. */
|
||||
readonly gold: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The player's quest log.
|
||||
*/
|
||||
export class QuestLog {
|
||||
private readonly progress = new Map<string, QuestProgress>()
|
||||
|
||||
/**
|
||||
* @param quests - the quests the log tracks.
|
||||
*/
|
||||
constructor(quests: readonly QuestDef[]) {
|
||||
for (const quest of quests) {
|
||||
this.progress.set(quest.id, { def: quest, status: 'inactive', kills: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a log from saved progress.
|
||||
*
|
||||
* @param quests - the quest definitions.
|
||||
* @param states - saved status and counters, matched by id.
|
||||
* @returns the log.
|
||||
*/
|
||||
static restore(
|
||||
quests: readonly QuestDef[],
|
||||
states: readonly { readonly id: string; readonly status: QuestProgress['status']; readonly kills: number }[],
|
||||
): QuestLog {
|
||||
const log = new QuestLog(quests)
|
||||
for (const saved of states) {
|
||||
const entry = log.progress.get(saved.id)
|
||||
if (entry === undefined) continue
|
||||
entry.status = saved.status
|
||||
entry.kills = Math.max(0, saved.kills)
|
||||
}
|
||||
return log
|
||||
}
|
||||
|
||||
/** Every quest's progress, in table order. */
|
||||
get all(): readonly QuestProgress[] {
|
||||
return [...this.progress.values()]
|
||||
}
|
||||
|
||||
/** The quests currently in progress. */
|
||||
get active(): readonly QuestProgress[] {
|
||||
return this.all.filter(entry => entry.status === 'active')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one quest's progress.
|
||||
*
|
||||
* @param id - the quest id.
|
||||
* @returns the progress, or undefined when unknown.
|
||||
*/
|
||||
get(id: string): QuestProgress | undefined {
|
||||
return this.progress.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a quest.
|
||||
*
|
||||
* @param id - the quest id.
|
||||
* @returns true when it moved from inactive to active.
|
||||
*/
|
||||
accept(id: string): boolean {
|
||||
const entry = this.progress.get(id)
|
||||
if (entry === undefined) throw new QuestError(`unknown quest "${id}"`)
|
||||
if (entry.status !== 'inactive') return false
|
||||
entry.status = 'active'
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a kill, advancing every active quest that wants that monster.
|
||||
*
|
||||
* @param monsterId - the monster's table id.
|
||||
* @returns the quests this kill completed.
|
||||
*/
|
||||
recordKill(monsterId: string): QuestReward[] {
|
||||
const rewards: QuestReward[] = []
|
||||
for (const entry of this.progress.values()) {
|
||||
if (entry.status !== 'active') continue
|
||||
const wanted = entry.def.monsterId
|
||||
// `*` means any monster, which is how a "kill N of anything" quest reads.
|
||||
if (wanted !== '*' && wanted !== monsterId) continue
|
||||
entry.kills += 1
|
||||
if (entry.kills >= entry.def.killCount) {
|
||||
entry.status = 'complete'
|
||||
rewards.push({ questId: entry.def.id, xp: entry.def.rewardXp, gold: entry.def.rewardGold })
|
||||
}
|
||||
}
|
||||
return rewards
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines an NPC says right now.
|
||||
*
|
||||
* @param npc - the NPC.
|
||||
* @param log - the quest log.
|
||||
* @returns the lines, empty when there is nothing to say.
|
||||
*/
|
||||
export function npcDialog(npc: NpcDef, log: QuestLog): string[] {
|
||||
if (npc.questId === null) return [...npc.offerLines]
|
||||
const entry = log.get(npc.questId)
|
||||
if (entry === undefined) return [...npc.offerLines]
|
||||
switch (entry.status) {
|
||||
case 'inactive': return [...npc.offerLines]
|
||||
case 'active': return [...npc.progressLines]
|
||||
case 'complete': return [...npc.doneLines]
|
||||
/* v8 ignore next -- the status union is closed above. */
|
||||
default: return []
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Deterministic pseudo-random numbers.
|
||||
*
|
||||
* Every random decision in the game — drops, affix rolls, monster spawn jitter —
|
||||
* goes through one of these, seeded from a value the caller controls. That is not
|
||||
* about quality (this is not a cryptographic generator) but about *reproducibility*:
|
||||
* a drop table that cannot be replayed cannot be tested, and a simulation whose
|
||||
* randomness comes from `Math.random` can never be reconciled between two machines,
|
||||
* which is exactly what lockstep multiplayer needs at M5.
|
||||
*
|
||||
* The algorithm is mulberry32: 32 bits of state, a handful of integer ops, and a
|
||||
* period long enough for a game session. It is written here rather than pulled in
|
||||
* because the whole engine has no runtime dependencies.
|
||||
*/
|
||||
|
||||
/** A seeded random source. */
|
||||
export class Rng {
|
||||
private state: number
|
||||
|
||||
/**
|
||||
* @param seed - any 32-bit integer; the same seed replays the same sequence.
|
||||
*/
|
||||
constructor(seed: number) {
|
||||
this.state = seed >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The generator's current state.
|
||||
*
|
||||
* Exposed so a save can capture the stream exactly: restoring a game without its
|
||||
* random state would replay the same drops in a different order, which is the
|
||||
* kind of difference that only shows up much later.
|
||||
*/
|
||||
get seed(): number {
|
||||
return this.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the next value.
|
||||
*
|
||||
* @returns a number in `[0, 1)`.
|
||||
*/
|
||||
next(): number {
|
||||
this.state = (this.state + 0x6d2b79f5) >>> 0
|
||||
let t = this.state
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an integer in an inclusive range.
|
||||
*
|
||||
* @param min - lower bound.
|
||||
* @param max - upper bound (inclusive).
|
||||
* @returns the integer.
|
||||
*/
|
||||
int(min: number, max: number): number {
|
||||
if (max <= min) return Math.floor(min)
|
||||
return Math.floor(min + this.next() * (max - min + 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a floating-point value in a range.
|
||||
*
|
||||
* @param min - lower bound.
|
||||
* @param max - upper bound.
|
||||
* @returns the value.
|
||||
*/
|
||||
range(min: number, max: number): number {
|
||||
return min + this.next() * (max - min)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw one element.
|
||||
*
|
||||
* @param items - the candidates.
|
||||
* @returns the element, or undefined when there are none.
|
||||
*/
|
||||
pick<T>(items: readonly T[]): T | undefined {
|
||||
if (items.length === 0) return undefined
|
||||
return items[Math.floor(this.next() * items.length)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a yes/no with a probability.
|
||||
*
|
||||
* @param chance - probability in `[0, 1]`.
|
||||
* @returns true when the draw succeeds.
|
||||
*/
|
||||
chance(probability: number): boolean {
|
||||
return this.next() < probability
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive an independent stream, so one subsystem's draws cannot shift another's.
|
||||
*
|
||||
* @param label - a stable name for the stream.
|
||||
* @returns a new generator.
|
||||
*/
|
||||
fork(label: string): Rng {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < label.length; index += 1) {
|
||||
hash ^= label.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619) >>> 0
|
||||
}
|
||||
return new Rng((hash ^ this.state) >>> 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable 32-bit hash of a string, for seeding streams from names.
|
||||
*
|
||||
* @param text - the input.
|
||||
* @returns the hash.
|
||||
*/
|
||||
export function hashString32(text: string): number {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
hash ^= text.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619) >>> 0
|
||||
}
|
||||
return hash >>> 0
|
||||
}
|
||||
|
|
@ -0,0 +1,315 @@
|
|||
/**
|
||||
* Saving and loading a game.
|
||||
*
|
||||
* Two formats live here, and they answer different questions:
|
||||
*
|
||||
* - **A snapshot** ({@link captureSnapshot}) is the engine's own save: a plain,
|
||||
* versioned structure holding everything a continued simulation depends on —
|
||||
* the world, the inventory, quest progress, and *the random stream's position*.
|
||||
* That last one is the part people forget: restore a game without its RNG state
|
||||
* and the same loot rolls come out in a different order, which looks like a
|
||||
* working save until someone notices the drops.
|
||||
* - **`.d2s`** is Diablo II's own character file, which is what a save has to be
|
||||
* to be worth anything to a player. Only the parts this engine can state with
|
||||
* confidence are implemented: the header, the identity fields, the stat and
|
||||
* skill blocks, and the checksum. The item, quest, waypoint and mercenary
|
||||
* sections are not decoded — they are recorded by offset and left alone, so a
|
||||
* file this engine writes stays loadable and a file it reads keeps its data.
|
||||
*
|
||||
* The snapshot is verified the only way that means anything: by continuing the
|
||||
* simulation from a restored copy and requiring it to stay identical tick for
|
||||
* tick. The `.d2s` side is verified by round trip, which is weaker — see the
|
||||
* notes on {@link writeD2s}.
|
||||
*/
|
||||
import { rebindPlayer } from './combat.ts'
|
||||
import type { CombatWorld, Monster } from './combat.ts'
|
||||
import type { Inventory, Item, PlacedItem } from './items.ts'
|
||||
import type { QuestLog, QuestProgress } from './quests.ts'
|
||||
import type { Rng } from './rng.ts'
|
||||
|
||||
/** Snapshot format version; bumped whenever the shape changes. */
|
||||
export const SNAPSHOT_VERSION = 1
|
||||
|
||||
/** A saved game. */
|
||||
export interface GameSnapshot {
|
||||
/** Format version. */
|
||||
readonly version: number
|
||||
/** Random stream position, so drops continue rather than restart. */
|
||||
readonly rngState: number
|
||||
/** The combat world, minus its per-tick event buffer. */
|
||||
readonly world: {
|
||||
readonly tick: number
|
||||
readonly kills: number
|
||||
readonly player: CombatWorld['player']
|
||||
readonly monsters: readonly Monster[]
|
||||
}
|
||||
/** Bag contents, at their exact grid positions. */
|
||||
readonly inventory: {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly placed: readonly { readonly x: number; readonly y: number; readonly item: Item }[]
|
||||
}
|
||||
/** Quest progress. */
|
||||
readonly quests: readonly { readonly id: string; readonly status: QuestProgress['status']; readonly kills: number }[]
|
||||
/**
|
||||
* Items lying on the ground. Not part of the simulation, but part of the world
|
||||
* the player is looking at: a save that silently deletes the loot you were
|
||||
* walking towards is a save that lies.
|
||||
*/
|
||||
readonly ground: readonly { readonly x: number; readonly y: number; readonly item: Item }[]
|
||||
}
|
||||
|
||||
/** Everything a snapshot is taken from. */
|
||||
export interface SnapshotSources {
|
||||
/** The combat world. */
|
||||
readonly world: CombatWorld
|
||||
/** The loot random stream. */
|
||||
readonly rng: Rng
|
||||
/** The player's inventory. */
|
||||
readonly inventory: Inventory
|
||||
/** The quest log. */
|
||||
readonly quests: QuestLog
|
||||
/** Items on the ground. */
|
||||
readonly ground: readonly { readonly x: number; readonly y: number; readonly item: Item }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the current game.
|
||||
*
|
||||
* @param sources - the live state.
|
||||
* @returns the snapshot (a plain object, ready to serialize).
|
||||
*/
|
||||
export function captureSnapshot(sources: SnapshotSources): GameSnapshot {
|
||||
return {
|
||||
version: SNAPSHOT_VERSION,
|
||||
rngState: sources.rng.seed,
|
||||
world: {
|
||||
tick: sources.world.tick,
|
||||
kills: sources.world.kills,
|
||||
// The player and monsters are plain data; the event buffer is deliberately
|
||||
// left out, because it describes the tick that just happened rather than
|
||||
// the state the next tick starts from.
|
||||
player: { ...sources.world.player },
|
||||
monsters: sources.world.monsters.map(monster => ({ ...monster })),
|
||||
},
|
||||
inventory: {
|
||||
width: sources.inventory.width,
|
||||
height: sources.inventory.height,
|
||||
placed: sources.inventory.contents.map(entry => ({ x: entry.x, y: entry.y, item: entry.item })),
|
||||
},
|
||||
quests: sources.quests.all.map(entry => ({ id: entry.def.id, status: entry.status, kills: entry.kills })),
|
||||
ground: sources.ground.map(entry => ({ x: entry.x, y: entry.y, item: entry.item })),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a snapshot.
|
||||
*
|
||||
* @param snapshot - the snapshot.
|
||||
* @returns its JSON text.
|
||||
*/
|
||||
export function serializeSnapshot(snapshot: GameSnapshot): string {
|
||||
return JSON.stringify(snapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a snapshot, rejecting anything that is not one.
|
||||
*
|
||||
* Validation is deliberate rather than trusting `JSON.parse`: a save is user data,
|
||||
* and a malformed one should produce an error message instead of a half-built
|
||||
* world.
|
||||
*
|
||||
* @param text - the JSON text.
|
||||
* @returns the parsed snapshot.
|
||||
*/
|
||||
export function parseSnapshot(text: string): GameSnapshot {
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (typeof parsed !== 'object' || parsed === null) throw new Error('save is not an object')
|
||||
const candidate = parsed as Partial<GameSnapshot>
|
||||
if (candidate.version !== SNAPSHOT_VERSION) {
|
||||
throw new Error(`save version ${String(candidate.version ?? '?')} is not supported (expected ${String(SNAPSHOT_VERSION)})`)
|
||||
}
|
||||
if (typeof candidate.rngState !== 'number') throw new Error('save has no random state')
|
||||
if (candidate.world === undefined || typeof candidate.world.tick !== 'number') throw new Error('save has no world')
|
||||
if (candidate.world.player === undefined) throw new Error('save has no player')
|
||||
if (!Array.isArray(candidate.world.monsters)) throw new Error('save has no monster list')
|
||||
if (candidate.inventory === undefined || !Array.isArray(candidate.inventory.placed)) throw new Error('save has no inventory')
|
||||
if (!Array.isArray(candidate.quests)) throw new Error('save has no quest log')
|
||||
// Older saves predate ground items; an absent list is treated as empty rather
|
||||
// than as corruption, so the version does not have to be bumped for a field
|
||||
// that only ever adds detail.
|
||||
if (candidate.ground !== undefined && !Array.isArray(candidate.ground)) throw new Error('save has a malformed ground list')
|
||||
return { ...candidate, ground: candidate.ground ?? [] } as GameSnapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a snapshot into live objects.
|
||||
*
|
||||
* @param snapshot - the snapshot.
|
||||
* @param build - factories for the pieces that have behaviour, so this module
|
||||
* stays free of construction details.
|
||||
* @returns the restored state.
|
||||
*/
|
||||
export function restoreSnapshot(
|
||||
snapshot: GameSnapshot,
|
||||
build: {
|
||||
/** Rebuild an inventory from placements. */
|
||||
readonly inventory: (width: number, height: number, placed: readonly PlacedItem[]) => Inventory
|
||||
/** Rebuild a quest log from progress. */
|
||||
readonly quests: (states: GameSnapshot['quests']) => QuestLog
|
||||
},
|
||||
): {
|
||||
world: CombatWorld
|
||||
rngState: number
|
||||
inventory: Inventory
|
||||
quests: QuestLog
|
||||
ground: { x: number; y: number; item: Item }[]
|
||||
} {
|
||||
return {
|
||||
// A save carries one player, so the restored world is a single-player one and
|
||||
// its player list is rebuilt rather than inherited: spreading a snapshot over a
|
||||
// live world would otherwise leave the previous player list in place.
|
||||
world: rebindPlayer({ ...snapshot.world, monsters: [...snapshot.world.monsters], players: [], events: [] }),
|
||||
rngState: snapshot.rngState,
|
||||
inventory: build.inventory(snapshot.inventory.width, snapshot.inventory.height, snapshot.inventory.placed as PlacedItem[]),
|
||||
quests: build.quests(snapshot.quests),
|
||||
ground: snapshot.ground.map(entry => ({ x: entry.x, y: entry.y, item: entry.item })),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Diablo II character files (.d2s) ---------------------------------------
|
||||
|
||||
/** The `.d2s` signature. */
|
||||
const D2S_SIGNATURE = 0xaa55aa55
|
||||
/** Byte offset of the character name, and its fixed length. */
|
||||
const D2S_NAME_OFFSET = 0x14
|
||||
const D2S_NAME_LENGTH = 16
|
||||
/** Byte offset of the class and level bytes. */
|
||||
const D2S_CLASS_OFFSET = 0x28
|
||||
const D2S_LEVEL_OFFSET = 0x2b
|
||||
/** Byte offset of the header checksum. */
|
||||
const D2S_CHECKSUM_OFFSET = 0x0c
|
||||
/** Diablo II version this writer targets (1.09+ layout, no expansion-only sections). */
|
||||
export const D2S_VERSION = 96
|
||||
|
||||
/** The parts of a character file this engine understands. */
|
||||
export interface D2sCharacter {
|
||||
/** Format version word. */
|
||||
readonly version: number
|
||||
/** Character name (at most 15 characters). */
|
||||
readonly name: string
|
||||
/** Class index (0 = amazon, 1 = sorceress, 2 = necromancer, 3 = paladin, 4 = barbarian). */
|
||||
readonly classIndex: number
|
||||
/** Character level. */
|
||||
readonly level: number
|
||||
/** Raw bytes, so sections this engine does not decode are preserved on write. */
|
||||
readonly raw: Uint8Array
|
||||
}
|
||||
|
||||
/**
|
||||
* Diablo II's save checksum.
|
||||
*
|
||||
* A rotating sum: shift left, add the byte, and fold any carry back into the low
|
||||
* bit. Stored as a 32-bit value at offset 0x0C, and the game refuses a character
|
||||
* whose checksum disagrees.
|
||||
*
|
||||
* @param data - the file bytes, with the checksum field present.
|
||||
* @returns the checksum.
|
||||
*/
|
||||
export function d2sChecksum(data: Uint8Array): number {
|
||||
let sum = 0
|
||||
for (let index = 0; index < data.byteLength; index += 1) {
|
||||
// The checksum field itself counts as zero bytes, matching the game.
|
||||
const byte = index >= D2S_CHECKSUM_OFFSET && index < D2S_CHECKSUM_OFFSET + 4 ? 0 : data[index]!
|
||||
// Doubling must not use `<< 1`: that is a 32-bit signed shift, so the high bit
|
||||
// is discarded instead of being folded back, and every byte more than 32
|
||||
// shifts from the end becomes invisible to the checksum — a corruption there
|
||||
// would go undetected. Keeping the sum as a wider number and folding the
|
||||
// carry into bit 0 (which is what the algorithm specifies) makes every byte
|
||||
// matter.
|
||||
sum = sum * 2 + byte
|
||||
if (sum > 0xffffffff) sum = (sum % 0x100000000) + 1
|
||||
}
|
||||
return sum >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a `.d2s` character file.
|
||||
*
|
||||
* Only the header and identity fields are decoded; everything else is kept as raw
|
||||
* bytes. That is deliberate: the item, quest and waypoint sections are large and
|
||||
* this engine has no shipped file to check them against, so guessing at them would
|
||||
* produce a decoder that silently corrupts saves.
|
||||
*
|
||||
* @param data - the file bytes.
|
||||
* @returns the character.
|
||||
*/
|
||||
export function readD2s(data: Uint8Array): D2sCharacter {
|
||||
if (data.byteLength < 0x30) throw new Error(`character file is only ${String(data.byteLength)} bytes`)
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
const signature = view.getUint32(0, true)
|
||||
if (signature !== D2S_SIGNATURE) {
|
||||
throw new Error(`not a character file (signature 0x${signature.toString(16)})`)
|
||||
}
|
||||
const version = view.getUint32(4, true)
|
||||
const storedChecksum = view.getUint32(D2S_CHECKSUM_OFFSET, true)
|
||||
const computed = d2sChecksum(data)
|
||||
if (storedChecksum !== computed) {
|
||||
throw new Error(`checksum mismatch (stored 0x${storedChecksum.toString(16)}, computed 0x${computed.toString(16)})`)
|
||||
}
|
||||
let name = ''
|
||||
for (let index = 0; index < D2S_NAME_LENGTH; index += 1) {
|
||||
const byte = data[D2S_NAME_OFFSET + index]!
|
||||
if (byte === 0) break
|
||||
name += String.fromCharCode(byte)
|
||||
}
|
||||
return {
|
||||
version,
|
||||
name,
|
||||
classIndex: data[D2S_CLASS_OFFSET]!,
|
||||
level: data[D2S_LEVEL_OFFSET]!,
|
||||
raw: data,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a `.d2s` character file around an existing body.
|
||||
*
|
||||
* Round-trip verified only: no shipped character file has been available to check
|
||||
* the interpretation against, so treat this as "keeps the bytes it was given and
|
||||
* fixes the header", not as a save format that has been proven compatible.
|
||||
*
|
||||
* @param character - the character to write.
|
||||
* @returns the file bytes.
|
||||
*/
|
||||
export function writeD2s(character: D2sCharacter): Uint8Array {
|
||||
const out = new Uint8Array(character.raw)
|
||||
const view = new DataView(out.buffer, out.byteOffset, out.byteLength)
|
||||
view.setUint32(0, D2S_SIGNATURE, true)
|
||||
view.setUint32(4, character.version, true)
|
||||
view.setUint32(8, out.byteLength, true)
|
||||
view.setUint32(D2S_CHECKSUM_OFFSET, 0, true)
|
||||
for (let index = 0; index < D2S_NAME_LENGTH; index += 1) {
|
||||
out[D2S_NAME_OFFSET + index] = index < character.name.length
|
||||
? character.name.charCodeAt(index) & 0x7f
|
||||
: 0
|
||||
}
|
||||
out[D2S_CLASS_OFFSET] = character.classIndex & 0xff
|
||||
out[D2S_LEVEL_OFFSET] = character.level & 0xff
|
||||
view.setUint32(D2S_CHECKSUM_OFFSET, d2sChecksum(out), true)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal character file from scratch (a header plus zeroed body).
|
||||
*
|
||||
* @param name - character name.
|
||||
* @param classIndex - class index.
|
||||
* @param level - level.
|
||||
* @param bodyBytes - body size to allocate.
|
||||
* @returns the file bytes.
|
||||
*/
|
||||
export function createD2s(name: string, classIndex: number, level: number, bodyBytes = 0x100): Uint8Array {
|
||||
const body = new Uint8Array(0x30 + bodyBytes)
|
||||
return writeD2s({ version: D2S_VERSION, name, classIndex, level, raw: body })
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* Skills: table-driven definitions, casting, and projectiles.
|
||||
*
|
||||
* Diablo II skills are rows in `Skills.txt`: a mana cost, a cooldown, a range,
|
||||
* and damage that scales with the skill's level in bands. This module reads that
|
||||
* shape and turns it into something castable, in three parts:
|
||||
*
|
||||
* - a **definition** read from a table row (never a constant in code, so a real
|
||||
* `Skills.txt` changes the game without touching this file);
|
||||
* - a **cast** step that checks mana and cooldown and produces either an instant
|
||||
* effect or a projectile;
|
||||
* - a **projectile** step that flies at the simulation rate, dies on walls,
|
||||
* expires on its own timer, and reports what it hit.
|
||||
*
|
||||
* Damage progression is the one place where a simplification is visible in the
|
||||
* code: the real table scales damage through five level bands
|
||||
* (`MinLevDam1..5`), and this reads a single `PerLevel` slope instead. The band
|
||||
* columns are a mapping-layer problem — they carry no new mechanics, only a
|
||||
* different interpolation — so the formula is isolated in {@link skillDamageAt}
|
||||
* where the bands can replace it.
|
||||
*/
|
||||
import { numberCell, resolveText, textCell } from './tables.ts'
|
||||
import type { TextSource } from './tables.ts'
|
||||
import type { Rng } from './rng.ts'
|
||||
|
||||
/** One castable skill, read from a table row. */
|
||||
export interface SkillDef {
|
||||
/** Table id. */
|
||||
readonly id: string
|
||||
/** Display name, resolved through the text table when the row names an index. */
|
||||
readonly name: string
|
||||
/** Mana spent per cast. */
|
||||
readonly manaCost: number
|
||||
/** Ticks between casts. */
|
||||
readonly cooldownTicks: number
|
||||
/** Maximum cast distance; also the projectile's lifetime in pixels. */
|
||||
readonly range: number
|
||||
/** Whether the skill spawns a projectile (as opposed to striking instantly). */
|
||||
readonly projectile: boolean
|
||||
/** Projectile speed in pixels per second. */
|
||||
readonly speed: number
|
||||
/** Damage at skill level 1. */
|
||||
readonly baseMinDamage: number
|
||||
/** Damage at skill level 1. */
|
||||
readonly baseMaxDamage: number
|
||||
/** Damage added per additional skill level. */
|
||||
readonly damagePerLevel: number
|
||||
/** Radius of the instant effect, for non-projectile skills. */
|
||||
readonly radius: number
|
||||
}
|
||||
|
||||
/** A projectile in flight. */
|
||||
export interface Projectile {
|
||||
/** Skill it came from. */
|
||||
readonly skillId: string
|
||||
/** World x. */
|
||||
readonly x: number
|
||||
/** World y. */
|
||||
readonly y: number
|
||||
/** Velocity x, pixels per tick. */
|
||||
readonly vx: number
|
||||
/** Velocity y, pixels per tick. */
|
||||
readonly vy: number
|
||||
/** Damage on hit. */
|
||||
readonly damage: number
|
||||
/** Ticks left before it expires. */
|
||||
readonly ttl: number
|
||||
/** Whether the player fired it (projectiles hit the other side). */
|
||||
readonly fromPlayer: boolean
|
||||
}
|
||||
|
||||
/** What a cast produced. */
|
||||
export type CastResult =
|
||||
| { readonly kind: 'mana'; readonly cost: number }
|
||||
| { readonly kind: 'cooldown'; readonly ticksLeft: number }
|
||||
| { readonly kind: 'projectile'; readonly projectile: Projectile }
|
||||
| { readonly kind: 'instant'; readonly radius: number; readonly damage: number }
|
||||
|
||||
/** A caster's position and facing. */
|
||||
export interface Caster {
|
||||
/** World x. */
|
||||
readonly x: number
|
||||
/** World y. */
|
||||
readonly y: number
|
||||
/** Facing index (0 = south, turning west). */
|
||||
readonly facing: number
|
||||
}
|
||||
|
||||
/** Direction vectors, in the same order as sprite groups (0 = south, turning west). */
|
||||
const FACING_VECTORS: readonly (readonly [number, number])[] = [
|
||||
[0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1], [1, 0], [1, 1],
|
||||
]
|
||||
|
||||
/**
|
||||
* Read one skill from a table row.
|
||||
*
|
||||
* @param row - the record.
|
||||
* @param rowIndex - position, for a fallback id.
|
||||
* @param text - text source for name indices.
|
||||
* @returns the definition.
|
||||
*/
|
||||
export function skillFromRow(
|
||||
row: Readonly<Record<string, string>>,
|
||||
rowIndex: number,
|
||||
text: TextSource,
|
||||
): SkillDef {
|
||||
const id = textCell(row, 'Id', textCell(row, 'skill', `skill${String(rowIndex)}`))
|
||||
const rawName = textCell(row, 'Name', textCell(row, 'skilldesc', id))
|
||||
return {
|
||||
id,
|
||||
name: resolveText(rawName, text) || id,
|
||||
manaCost: Math.max(0, numberCell(row, 'ManaCost', numberCell(row, 'mana', 0))),
|
||||
cooldownTicks: Math.max(0, numberCell(row, 'CooldownTicks', numberCell(row, 'delay', 0))),
|
||||
range: Math.max(1, numberCell(row, 'Range', numberCell(row, 'range', 120))),
|
||||
// A projectile skill is one with a missile speed; otherwise it strikes where
|
||||
// the caster is facing.
|
||||
projectile: numberCell(row, 'Speed', 0) > 0,
|
||||
speed: Math.max(0, numberCell(row, 'Speed', 0)),
|
||||
baseMinDamage: Math.max(0, numberCell(row, 'MinDam', numberCell(row, 'mindam', 1))),
|
||||
baseMaxDamage: Math.max(0, numberCell(row, 'MaxDam', numberCell(row, 'maxdam', 2))),
|
||||
damagePerLevel: numberCell(row, 'PerLevel', numberCell(row, 'LevDam', 0)),
|
||||
radius: Math.max(1, numberCell(row, 'Radius', numberCell(row, 'HitRadius', 24))),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every skill in a table.
|
||||
*
|
||||
* @param table - a `Skills.txt`-shaped table.
|
||||
* @param text - text source for name indices.
|
||||
* @returns the definitions, in table order.
|
||||
*/
|
||||
export function skillsFromTable(
|
||||
table: { rows: readonly Readonly<Record<string, string>>[] },
|
||||
text: TextSource,
|
||||
): SkillDef[] {
|
||||
return table.rows.map((row, index) => skillFromRow(row, index, text))
|
||||
}
|
||||
|
||||
/**
|
||||
* Damage of a skill at a level.
|
||||
*
|
||||
* @param skill - the definition.
|
||||
* @param level - skill level, 1-based.
|
||||
* @param rng - random source for the roll inside the damage range.
|
||||
* @returns the damage.
|
||||
*/
|
||||
export function skillDamageAt(skill: SkillDef, level: number, rng: Rng): number {
|
||||
const steps = Math.max(0, level - 1)
|
||||
const min = skill.baseMinDamage + skill.damagePerLevel * steps
|
||||
const max = skill.baseMaxDamage + skill.damagePerLevel * steps
|
||||
return rng.int(Math.round(min), Math.round(Math.max(min, max)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast a skill.
|
||||
*
|
||||
* Mana and cooldown are checked here rather than by the caller so that every
|
||||
* route to casting (a hotkey, an AI, a script) obeys the same rules.
|
||||
*
|
||||
* @param skill - the definition.
|
||||
* @param caster - position and facing.
|
||||
* @param level - skill level.
|
||||
* @param rng - random source.
|
||||
* @param cooldownLeft - ticks of cooldown still owed.
|
||||
* @param mana - mana available.
|
||||
* @param aim - optional aim point; defaults to straight ahead of the facing.
|
||||
* @returns what the cast produced.
|
||||
*/
|
||||
export function castSkill(
|
||||
skill: SkillDef,
|
||||
caster: Caster,
|
||||
level: number,
|
||||
rng: Rng,
|
||||
cooldownLeft: number,
|
||||
mana: number,
|
||||
aim?: { readonly x: number; readonly y: number },
|
||||
): CastResult {
|
||||
if (cooldownLeft > 0) return { kind: 'cooldown', ticksLeft: cooldownLeft }
|
||||
if (mana < skill.manaCost) return { kind: 'mana', cost: skill.manaCost }
|
||||
const damage = skillDamageAt(skill, level, rng)
|
||||
if (!skill.projectile) return { kind: 'instant', radius: skill.radius, damage }
|
||||
|
||||
let dx: number
|
||||
let dy: number
|
||||
if (aim !== undefined) {
|
||||
dx = aim.x - caster.x
|
||||
dy = aim.y - caster.y
|
||||
} else {
|
||||
const vector = FACING_VECTORS[caster.facing] ?? FACING_VECTORS[0]!
|
||||
dx = vector[0]
|
||||
dy = vector[1]
|
||||
}
|
||||
const length = Math.hypot(dx, dy)
|
||||
if (length === 0) return { kind: 'instant', radius: skill.radius, damage }
|
||||
const perTick = skill.speed / 25
|
||||
const ttl = Math.max(1, Math.round(skill.range / perTick))
|
||||
return {
|
||||
kind: 'projectile',
|
||||
projectile: {
|
||||
skillId: skill.id,
|
||||
x: caster.x,
|
||||
y: caster.y,
|
||||
vx: (dx / length) * perTick,
|
||||
vy: (dy / length) * perTick,
|
||||
damage,
|
||||
ttl,
|
||||
fromPlayer: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A body a projectile can hit. */
|
||||
export interface ProjectileTarget {
|
||||
/** Stable identity, echoed back on a hit. */
|
||||
readonly index: number
|
||||
/** World x. */
|
||||
readonly x: number
|
||||
/** World y. */
|
||||
readonly y: number
|
||||
/** Radius for hit testing. */
|
||||
readonly radius: number
|
||||
/** Whether the target is alive; dead targets are not hit. */
|
||||
readonly alive: boolean
|
||||
}
|
||||
|
||||
/** What happened to projectiles during a tick. */
|
||||
export interface ProjectileOutcome {
|
||||
/** Projectiles still in flight. */
|
||||
readonly alive: readonly Projectile[]
|
||||
/** Hits, one per projectile that struck. */
|
||||
readonly hits: readonly { readonly targetIndex: number; readonly damage: number; readonly x: number; readonly y: number }[]
|
||||
/** Projectiles that died on a wall or expired. */
|
||||
readonly expired: number
|
||||
/** Projectiles that stopped against terrain. */
|
||||
readonly wallHits: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance every projectile one tick.
|
||||
*
|
||||
* @param projectiles - projectiles in flight.
|
||||
* @param targets - candidate targets.
|
||||
* @param options - terrain overlap test and projectile radius.
|
||||
* @returns the surviving projectiles and what they hit.
|
||||
*/
|
||||
export function tickProjectiles(
|
||||
projectiles: readonly Projectile[],
|
||||
targets: readonly ProjectileTarget[],
|
||||
options: { readonly overlap: (x: number, y: number) => number; readonly radius?: number },
|
||||
): ProjectileOutcome {
|
||||
const radius = options.radius ?? 8
|
||||
const alive: Projectile[] = []
|
||||
const hits: { targetIndex: number; damage: number; x: number; y: number }[] = []
|
||||
let expired = 0
|
||||
let wallHits = 0
|
||||
|
||||
for (const projectile of projectiles) {
|
||||
if (projectile.ttl <= 0) { expired += 1; continue }
|
||||
const x = projectile.x + projectile.vx
|
||||
const y = projectile.y + projectile.vy
|
||||
const moved: Projectile = { ...projectile, x, y, ttl: projectile.ttl - 1 }
|
||||
// Walls first: a projectile dies against scenery rather than passing through
|
||||
// it to reach a target behind.
|
||||
if (options.overlap(x, y) > 0) { wallHits += 1; continue }
|
||||
let struck = false
|
||||
for (const target of targets) {
|
||||
if (!target.alive) continue
|
||||
if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue
|
||||
hits.push({ targetIndex: target.index, damage: projectile.damage, x, y })
|
||||
struck = true
|
||||
break
|
||||
}
|
||||
if (!struck) alive.push(moved)
|
||||
}
|
||||
return { alive, hits, expired, wallHits }
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* Diablo II data tables (`data/global/excel/*.txt`).
|
||||
*
|
||||
* Every number the game balances with — monster health and AI, experience per
|
||||
* level, item affixes, skill damage — lives in tab-separated text tables inside
|
||||
* the MPQ, and the shipped `*.bin` files are just a compiled form of the same
|
||||
* rows. Reading the text form is therefore the cheapest path to real game data,
|
||||
* and it keeps the pipeline legible: a table is a header row, then records.
|
||||
*
|
||||
* Two properties of these files drive the parser:
|
||||
*
|
||||
* - cells are tab-separated and a row may be *shorter* than the header (trailing
|
||||
* empty cells are simply omitted), so missing cells are normal, not errors;
|
||||
* - empty cells and the literal `(null)` both mean "no value", and every consumer
|
||||
* wants a default rather than an empty string.
|
||||
*
|
||||
* Numeric access is deliberately forgiving for the same reason: a column that a
|
||||
* mod emptied should fall back to a default instead of producing `NaN` that
|
||||
* spreads silently through the simulation.
|
||||
*/
|
||||
|
||||
/** A parsed table: column names plus one record per row. */
|
||||
export interface DataTable {
|
||||
/** Column names, in file order. */
|
||||
readonly columns: readonly string[]
|
||||
/** Records keyed by column name. */
|
||||
readonly rows: readonly Readonly<Record<string, string>>[]
|
||||
}
|
||||
|
||||
/** Raised when a table cannot be parsed at all. */
|
||||
export class TableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'TableError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Cell text that means "no value". */
|
||||
const NULL_CELL = '(null)'
|
||||
|
||||
/**
|
||||
* Parse a tab-separated data table.
|
||||
*
|
||||
* @param text - the file's text.
|
||||
* @returns the parsed table.
|
||||
*/
|
||||
export function parseTable(text: string): DataTable {
|
||||
const lines = text.split(/\r?\n/).filter(line => line.trim() !== '')
|
||||
const header = lines[0]
|
||||
if (header === undefined) throw new TableError('table is empty')
|
||||
// A leading empty column is common: the first column of several tables is
|
||||
// unnamed. It is kept as an empty name so indices still line up.
|
||||
const columns = header.split('\t').map(name => name.trim())
|
||||
if (columns.every(name => name === '')) throw new TableError('table has no column names')
|
||||
|
||||
const rows: Record<string, string>[] = []
|
||||
for (const line of lines.slice(1)) {
|
||||
const cells = line.split('\t')
|
||||
const row: Record<string, string> = {}
|
||||
columns.forEach((column, index) => {
|
||||
if (column === '') return
|
||||
const cell = cells[index]
|
||||
if (cell === undefined) return
|
||||
const trimmed = cell.trim()
|
||||
if (trimmed === '' || trimmed === NULL_CELL) return
|
||||
row[column] = trimmed
|
||||
})
|
||||
rows.push(row)
|
||||
}
|
||||
return { columns, rows }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a numeric cell.
|
||||
*
|
||||
* @param row - the record.
|
||||
* @param column - column name.
|
||||
* @param fallback - value to use when the cell is missing or unparsable.
|
||||
* @returns the number.
|
||||
*/
|
||||
export function numberCell(row: Readonly<Record<string, string>>, column: string, fallback: number): number {
|
||||
const raw = row[column]
|
||||
if (raw === undefined) return fallback
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a text cell.
|
||||
*
|
||||
* @param row - the record.
|
||||
* @param column - column name.
|
||||
* @param fallback - value to use when the cell is missing.
|
||||
* @returns the text.
|
||||
*/
|
||||
export function textCell(row: Readonly<Record<string, string>>, column: string, fallback = ''): string {
|
||||
return row[column] ?? fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a record by one column's value.
|
||||
*
|
||||
* @param table - the table.
|
||||
* @param column - the column to match.
|
||||
* @param value - the value to match (case-insensitive).
|
||||
* @returns the record, or undefined.
|
||||
*/
|
||||
export function findRow(table: DataTable, column: string, value: string): Readonly<Record<string, string>> | undefined {
|
||||
const needle = value.toLowerCase()
|
||||
return table.rows.find(row => (row[column] ?? '').toLowerCase() === needle)
|
||||
}
|
||||
|
||||
/**
|
||||
* A source of strings addressed by index — a decoded `.tbl`.
|
||||
*/
|
||||
export interface TextSource {
|
||||
/**
|
||||
* Resolve one index.
|
||||
*
|
||||
* @param index - the string index.
|
||||
* @returns the text, or undefined when the index is unused.
|
||||
*/
|
||||
get: (index: number) => string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap decoded table entries as a text source.
|
||||
*
|
||||
* @param entries - a decoded `.tbl`, or null when the archive has none.
|
||||
* @returns the source (empty when there is no table).
|
||||
*/
|
||||
export function textSourceOf(entries: readonly (string | undefined)[] | null): TextSource {
|
||||
if (entries === null) return { get: () => undefined }
|
||||
return { get: index => (index >= 0 && index < entries.length ? entries[index] : undefined) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a table cell that holds either a literal or a string index.
|
||||
*
|
||||
* Diablo II tables mix the two: `Skills.txt` names skills by index into
|
||||
* `string.tbl`, while mods and many tools write the words directly. A numeric
|
||||
* cell is therefore tried as an index first and kept as a literal only when the
|
||||
* table has nothing at that position — which is also what makes a table usable
|
||||
* with no `.tbl` loaded at all.
|
||||
*
|
||||
* @param cell - the raw cell text.
|
||||
* @param source - the text source, when a table is loaded.
|
||||
* @returns the resolved text.
|
||||
*/
|
||||
export function resolveText(cell: string, source: TextSource): string {
|
||||
const trimmed = cell.trim()
|
||||
if (trimmed === '') return ''
|
||||
if (!/^\d+$/.test(trimmed)) return trimmed
|
||||
return source.get(Number(trimmed)) ?? trimmed
|
||||
}
|
||||
|
|
@ -0,0 +1,908 @@
|
|||
/**
|
||||
* Diablo II's wilderness level generator (`Levels.txt.DrlgType == 3`).
|
||||
*
|
||||
* 31 levels — Act 1's six wilderness areas plus the Graveyard and the Moo Moo
|
||||
* Farm, Act 2's five deserts and the Valley of the Kings, Act 3's jungles and
|
||||
* Kurast, Act 4's mesas and the Chaos Sanctum approach, and Act 5's Siege,
|
||||
* Barricade and snowfields — have no fixed layout either. Unlike a maze level
|
||||
* they are not built from a handful of sections; they are a *terrain*: a
|
||||
* rectangular field of ground tiles, walled in by cliff pieces, and dotted with
|
||||
* the objects `LvlSub.txt` describes.
|
||||
*
|
||||
* ## Provenance
|
||||
*
|
||||
* An **independent TypeScript port** of the outdoor generator in
|
||||
* **ThePhrozenKeep/D2MOO** ("Diablo II Method and Ordinal Overhaul", a C++
|
||||
* re-implementation of Diablo II 1.10f). D2MOO ships no licence file, so no code
|
||||
* was copied: every algorithm below was read from that project and re-expressed
|
||||
* here in this repository's own structure and naming, with the source function
|
||||
* cited. The algorithms are Blizzard's; D2MOO's contribution is having recovered
|
||||
* them.
|
||||
*
|
||||
* Sources, all under `source/D2Common/` in D2MOO:
|
||||
*
|
||||
* - `src/Drlg/DrlgOutdoors.cpp` — `DRLGOUTDOORS_GenerateLevel` (the entry point),
|
||||
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` (how a preset DS1 is stamped into
|
||||
* the block grid), `DRLGOUTDOORS_AddAct124SecondaryBorder`,
|
||||
* `DRLGOUTDOORS_PlaceAct1245OutdoorBorders` (in `DrlgOutPlace.cpp`),
|
||||
* `DRLGOUTWILD_InitAct1OutdoorLevel`.
|
||||
* - `src/Drlg/DrlgOutPlace.cpp` — `DRLGOUTPLACE_CreateOutdoorRoomEx`,
|
||||
* `DRLGOUTPLACE_InitOutdoorRoomGrids` (the `0x40002` ground floor flag).
|
||||
* - `src/Drlg/DrlgTileSub.cpp` — `DRLGTILESUB_PickSubThemes` (the `Prob` gate),
|
||||
* `DRLGTILESUB_DoSubstitutions` (the `Max`/`Trials` cluster loop),
|
||||
* `DRLGTILESUB_AddSecondaryBorder` (the `GridSize` stride and `BordType` cap).
|
||||
* - `include/DataTbls/LevelsTbls.h` — the `D2LvlSubTxt` field comments that fix
|
||||
* what `BordType`, `GridSize`, `Prob`, `Trials` and `Max` mean.
|
||||
* - `doc/Coordinates.md` — the coordinate systems.
|
||||
*
|
||||
* ## What D2MOO settles that this repository did not know
|
||||
*
|
||||
* **A wilderness level is a grid of 8×8-cell blocks, not a list of pieces.**
|
||||
* `DRLGOUTDOORS_GenerateLevel` sets `nGridWidth = nWidth / 8` and
|
||||
* `nGridHeight = nHeight / 8`, allocates four grids, lets a per-act initialiser
|
||||
* fill them, and then walks the grid once. Each block is either checked as
|
||||
* "stamp this `LvlPrest` preset here" or becomes a bare outdoor room of 8×8
|
||||
* ground tiles. So the placement stride is exactly 8 cells with no overlap and no
|
||||
* margin — unlike a maze, where the piece is one cell larger than its stride.
|
||||
*
|
||||
* **`LvlSub.GridSize` does not size the level.** `Levels.txt.SizeX/SizeY` is the
|
||||
* level, full stop: `DRLG_SetLevelPositionAndSize` is called only by the maze and
|
||||
* preset generators, never by the outdoor one. `GridSize` is the *substitution
|
||||
* cluster* stride — how far apart substituted pieces sit and how their origins
|
||||
* snap (`x - x % GridSize`).
|
||||
*
|
||||
* **`BordType` does not choose the border DS1.** It bounds how many substituted
|
||||
* clusters a level may take: `0` allows at most one for the whole level, `1` at
|
||||
* most one per cluster group, anything else is unlimited. The border DS1 is
|
||||
* chosen by a hardcoded per-act table, pairing a border *style* with an
|
||||
* `LvlPrest` id — see {@link WILDERNESS_DT1_MASK} for the sibling hardcode and
|
||||
* the module note on {@link generateWilderness} for what that means here.
|
||||
*
|
||||
* **`ProbN`/`TrialsN`/`MaxN` are indexed by `Levels.txt.SubTheme`, not by
|
||||
* difficulty.** The knowledge base lists them as five opaque groups. They are
|
||||
* five *themes*: `SubTheme` selects which triple applies, `Prob` is a percentage
|
||||
* gate, `Max` is how many clusters to attempt and `Trials` is how many positions
|
||||
* to try per cluster (`-1` meaning "try every free position"). The table bears
|
||||
* this out — `SubTheme` runs 0..4 across the wilderness levels, and `Prob0`
|
||||
* through `Prob4` are visibly different per theme.
|
||||
*
|
||||
* **The ground tile is not in any table.** The base floor of an outdoor room is
|
||||
* set to the packed value `0x40002` (`bIsFloor` plus a wall-layer bit) and the
|
||||
* actual DT1 style/sequence is then chosen at runtime by a rarity roll inside
|
||||
* `DRLGROOMTILE_GetTileCache`, against the libraries selected by the level type's
|
||||
* hardcoded `dwDt1Mask`. That roll cannot be reproduced from the shipped tables,
|
||||
* so this port takes the ground tile from the level's own border pieces instead
|
||||
* — see {@link WildernessRequest.groundTile}.
|
||||
*/
|
||||
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts'
|
||||
import { Rng } from './rng.ts'
|
||||
import { SUB_TILES_PER_TILE } from './map.ts'
|
||||
|
||||
/** One generator block, in cells. `DRLGOUTDOORS_GenerateLevel` divides by 8. */
|
||||
export const TILES_PER_BLOCK = 8
|
||||
|
||||
/**
|
||||
* The DT1 mask each level type's outdoor rooms use, verbatim from
|
||||
* `DRLGOUTDOORS_GenerateLevel`'s `switch (pLevel->nLevelType)`.
|
||||
*
|
||||
* This is a genuine hardcode in the shipped binary, and it is what decides which
|
||||
* of a level type's `LvlTypes.txt` `File 1..32` libraries supply the outdoor
|
||||
* ground. Note that it is much narrower than "all the level type's libraries":
|
||||
* Act 1's wilderness uses five of them (`0x44103` = slots 1, 2, 9, 15 and 19),
|
||||
* and the deserts, Kurast, the mesas and the Chaos Sanctum approach use exactly
|
||||
* one (`0x01` = slot 1).
|
||||
*/
|
||||
export const WILDERNESS_DT1_MASK: Readonly<Record<string, number>> = {
|
||||
'Act 1 - Wilderness': 0x44103,
|
||||
'Act 3 - Jungle': 0x04,
|
||||
'Act 2 - Desert': 0x01,
|
||||
'Act 3 - Kurast': 0x01,
|
||||
'Act 4 - Mesa': 0x01,
|
||||
'Act 4 - Lava': 0x01,
|
||||
'Act 5 - Siege': 0x11,
|
||||
'Act 5 - Barricade': 0x11,
|
||||
}
|
||||
|
||||
/**
|
||||
* The ground DT1 mask for a level type.
|
||||
*
|
||||
* @param levelTypeName - `LvlTypes.txt` name.
|
||||
* @returns the mask, or 0 for a level type D2MOO leaves unmatched (which loads
|
||||
* no libraries at all and therefore draws no ground).
|
||||
*/
|
||||
export function wildernessDt1Mask(levelTypeName: string): number {
|
||||
return WILDERNESS_DT1_MASK[levelTypeName] ?? 0
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Public request / response shapes
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* One `LvlPrest.txt` row that can be stamped into a wilderness level.
|
||||
*
|
||||
* These are the border and fill pieces — `Act 1 - Wild Border 1..12`,
|
||||
* `Act 1 - Wild Cliff Border 2..10`, `Act 2 - Desert Border 1..12`,
|
||||
* `Act 2 - Desert Fill *`, `Act 3 - Jungle W/E/...` and so on. Unlike a maze
|
||||
* piece, none of them carries a side token: a wilderness piece is placed by
|
||||
* *where it is*, not by which of its sides has a door.
|
||||
*/
|
||||
export interface WildernessPiece {
|
||||
/** `LvlPrest.txt` `Name`. */
|
||||
readonly name: string
|
||||
/** Decoded variants, in `File1..File6` order. */
|
||||
readonly levels: readonly Ds1[]
|
||||
/**
|
||||
* Whether the piece is part of the level's outer wall.
|
||||
*
|
||||
* D2MOO decides this from the placement table it was called with, not from the
|
||||
* name, so the caller states it. Border pieces are laid round the perimeter;
|
||||
* fill pieces are scattered inside.
|
||||
*/
|
||||
readonly border: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* One `LvlSub.txt` row: a set of objects or terrain patches the level may scatter.
|
||||
*
|
||||
* `Prob`/`Trials`/`Max` are the five per-theme parameter groups; index them with
|
||||
* `Levels.txt.SubTheme`.
|
||||
*/
|
||||
export interface WildernessSubstitution {
|
||||
/** `LvlSub.txt` `Name`, e.g. `Trees`. */
|
||||
readonly name: string
|
||||
/** `LvlSub.txt` `Type`, which `Levels.txt.SubType` (or `SubShrine`) selects. */
|
||||
readonly type: number
|
||||
/** `LvlSub.txt` `GridSize`: cluster stride and origin snap, in blocks. */
|
||||
readonly gridSize: number
|
||||
/** `LvlSub.txt` `BordType`: how many clusters the level may take. */
|
||||
readonly bordType: number
|
||||
/** `LvlSub.txt` `Dt1Mask`, OR-ed into the room's mask when the theme is picked. */
|
||||
readonly dt1Mask: number
|
||||
/** `LvlSub.txt` `Prob0..4`, in per-cent. */
|
||||
readonly prob: readonly number[]
|
||||
/** `LvlSub.txt` `Trials0..4`; `-1` means "try every free position". */
|
||||
readonly trials: readonly number[]
|
||||
/** `LvlSub.txt` `Max0..4`: clusters to attempt. */
|
||||
readonly max: readonly number[]
|
||||
/** The decoded `LvlSub.txt` `File`; index 0 is enough for every shipped row. */
|
||||
readonly levels: readonly Ds1[]
|
||||
}
|
||||
|
||||
/** What a `LvlSub` row is for, derived from its `Type` and name. */
|
||||
export type WildernessRole = 'border' | 'waypoint' | 'shrine' | 'object'
|
||||
|
||||
/** Everything {@link generateWilderness} needs. */
|
||||
export interface WildernessRequest {
|
||||
/** `Levels.txt` `Id`. */
|
||||
readonly levelId: number
|
||||
/** `Levels.txt` `Name`. */
|
||||
readonly levelName: string
|
||||
/** `LvlTypes.txt` name, e.g. `Act 1 - Wilderness`. */
|
||||
readonly levelTypeName: string
|
||||
/** `Levels.txt` `SizeX`, in cells. */
|
||||
readonly sizeX: number
|
||||
/** `Levels.txt` `SizeY`, in cells. */
|
||||
readonly sizeY: number
|
||||
/** `Levels.txt` `SubType`, selecting the `LvlSub` rows to scatter. */
|
||||
readonly subType: number
|
||||
/** `Levels.txt` `SubTheme`, indexing `Prob`/`Trials`/`Max`. */
|
||||
readonly subTheme: number
|
||||
/** Seed for the whole generator; reported back in `stats`. */
|
||||
readonly seed: number
|
||||
/** `LvlPrest` border and fill pieces for this level type. */
|
||||
readonly pieces: readonly WildernessPiece[]
|
||||
/** `LvlSub` rows whose `Type` is the level's `SubType`. */
|
||||
readonly substitutions: readonly WildernessSubstitution[]
|
||||
/** `LvlSub` rows whose `Type` is the level's `SubShrine`, if any. */
|
||||
readonly shrineSubstitutions?: readonly WildernessSubstitution[]
|
||||
/**
|
||||
* The ground tile to lay under everything, as a DT1 `style`/`sequence` pair.
|
||||
*
|
||||
* The shipped game leaves this to a runtime rarity roll over the DT1 libraries
|
||||
* the level type's mask selects, which no table records. When omitted,
|
||||
* {@link generateWilderness} derives it from the level's own pieces as the most
|
||||
* common floor a border piece uses outside the piece's own wall ring — the
|
||||
* outdoor ground those pieces sit on — and records the choice in
|
||||
* `stats.groundTile`.
|
||||
*/
|
||||
readonly groundTile?: { readonly style: number; readonly sequence: number }
|
||||
/**
|
||||
* Explicit block-grid size, for the levels `Levels.txt` leaves unset.
|
||||
*
|
||||
* Two levels, `Act 5 - Barricade 1` and `2`, ship `SizeX = SizeY = -1` because
|
||||
* the engine sizes them from the barricade piece they attach to rather than
|
||||
* from the table (`DRLGOUTSIEGE_ConnectBarricadeAndSiege`). When the table
|
||||
* gives no size, this port uses the extent of the level's barricade `LvlSub`
|
||||
* piece instead; pass this to override that.
|
||||
*/
|
||||
readonly sizeOverride?: { readonly x: number; readonly y: number }
|
||||
}
|
||||
|
||||
/** The generated level. */
|
||||
export interface WildernessResult {
|
||||
/** A single synthesized map the isometric renderer can draw unmodified. */
|
||||
readonly level: Ds1
|
||||
/** What the generator did, for reporting and tests. */
|
||||
readonly stats: Record<string, unknown>
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Classification
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Classify a `LvlSub.txt` row.
|
||||
*
|
||||
* `LvlSub.Type` is the join key against `Levels.txt.SubType` and `SubShrine`, and
|
||||
* the shipped rows fall into fixed bands: `0..3` are the four border styles,
|
||||
* `4` and `7` are waypoints, `5` and `8` are shrines, and everything else
|
||||
* (`6`, `9`, `10`, `11`, `12`) is scenery. The bands are cross-checked against
|
||||
* the row names, which spell the role out.
|
||||
*
|
||||
* @param name - `LvlSub.txt` `Name`.
|
||||
* @param type - `LvlSub.txt` `Type`.
|
||||
* @returns the role.
|
||||
*/
|
||||
export function classifySubstitutionRole(name: string, type: number): WildernessRole {
|
||||
if (type >= 0 && type <= 3) return 'border'
|
||||
if (type === 4 || type === 7) return 'waypoint'
|
||||
if (type === 5 || type === 8) return 'shrine'
|
||||
if (/barricade/i.test(name)) return 'border'
|
||||
return 'object'
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* The canvas
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** A blank wall cell, matching the decoder's placeholder. */
|
||||
function emptyWall(): Ds1Wall {
|
||||
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||||
}
|
||||
|
||||
/** A blank floor or shadow cell. */
|
||||
function emptyFloor(): Ds1Floor {
|
||||
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||||
}
|
||||
|
||||
/** The mutable map under construction. */
|
||||
interface Canvas {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly cells: Ds1Cell[][]
|
||||
readonly wallLayers: number
|
||||
readonly floorLayers: number
|
||||
readonly substitutionLayers: number
|
||||
readonly objects: Ds1Object[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty map of the given cell extent.
|
||||
*
|
||||
* @param width - cells.
|
||||
* @param height - cells.
|
||||
* @param wallLayers - wall layers every cell carries.
|
||||
* @param floorLayers - floor layers every cell carries.
|
||||
* @param substitutionLayers - substitution layers every cell carries.
|
||||
* @returns the canvas.
|
||||
*/
|
||||
function createCanvas(width: number, height: number, wallLayers: number, floorLayers: number, substitutionLayers: number): Canvas {
|
||||
const cells: Ds1Cell[][] = []
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row: Ds1Cell[] = []
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
row.push({
|
||||
walls: Array.from({ length: wallLayers }, emptyWall),
|
||||
floors: Array.from({ length: floorLayers }, emptyFloor),
|
||||
shadows: [emptyFloor()],
|
||||
substitutions: Array.from({ length: substitutionLayers }, () => ({ value: 0 })),
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
return { width, height, cells, wallLayers, floorLayers, substitutionLayers, objects: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a decoded map's cells into the canvas at a cell offset.
|
||||
*
|
||||
* Cells are copied by reference: {@link Ds1} declares its cell records
|
||||
* read-only, so nothing downstream may mutate one and sharing keeps a whole
|
||||
* generated level cheap. Anything outside the canvas is skipped rather than
|
||||
* throwing, because border pieces are deliberately overhung past the edge — the
|
||||
* game does the same when a piece straddles the level rect.
|
||||
*
|
||||
* Objects keep their sub-tile coordinates and gain the stamp origin converted
|
||||
* from cells at 5 sub-tiles per cell, which is what
|
||||
* `DRLGPRESET_AddPresetUnitToDrlgMap` does for a preset room.
|
||||
*
|
||||
* @param canvas - the canvas.
|
||||
* @param source - the piece to stamp.
|
||||
* @param originX - left edge in cells.
|
||||
* @param originY - top edge in cells.
|
||||
* @returns the number of cells actually written.
|
||||
*/
|
||||
function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number): number {
|
||||
let written = 0
|
||||
for (let y = 0; y < source.height; y += 1) {
|
||||
const targetY = originY + y
|
||||
if (targetY < 0 || targetY >= canvas.height) continue
|
||||
const sourceRow = source.cells[y]
|
||||
if (sourceRow === undefined) continue
|
||||
const targetRow = canvas.cells[targetY]!
|
||||
for (let x = 0; x < source.width; x += 1) {
|
||||
const targetX = originX + x
|
||||
if (targetX < 0 || targetX >= canvas.width) continue
|
||||
const cell = sourceRow[x]
|
||||
if (cell === undefined) continue
|
||||
targetRow[targetX] = cell
|
||||
written += 1
|
||||
}
|
||||
}
|
||||
for (const object of source.objects) {
|
||||
canvas.objects.push({
|
||||
type: object.type,
|
||||
id: object.id,
|
||||
x: originX * SUB_TILES_PER_TILE + object.x,
|
||||
y: originY * SUB_TILES_PER_TILE + object.y,
|
||||
flags: object.flags,
|
||||
})
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one floor tile into every cell of a rectangle.
|
||||
*
|
||||
* Used for the ground. The tile is a bare `style`/`sequence` reference, so it
|
||||
* resolves against the level type's libraries exactly as a decoded DS1's floor
|
||||
* would; the wall layers are left empty, which is what makes the ground walkable.
|
||||
*
|
||||
* @param canvas - the canvas.
|
||||
* @param tile - the floor reference.
|
||||
* @param x - left edge in cells.
|
||||
* @param y - top edge in cells.
|
||||
* @param width - cells.
|
||||
* @param height - cells.
|
||||
* @returns the number of cells written.
|
||||
*/
|
||||
function fillGround(
|
||||
canvas: Canvas,
|
||||
tile: { style: number; sequence: number },
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): number {
|
||||
let written = 0
|
||||
for (let cy = y; cy < y + height; cy += 1) {
|
||||
if (cy < 0 || cy >= canvas.height) continue
|
||||
const row = canvas.cells[cy]!
|
||||
for (let cx = x; cx < x + width; cx += 1) {
|
||||
if (cx < 0 || cx >= canvas.width) continue
|
||||
const cell = row[cx]!
|
||||
const floor = cell.floors[0]
|
||||
if (floor === undefined) continue
|
||||
// `prop1` 2 is the ordinary walkable floor property that the shipped
|
||||
// outdoor pieces use; walls stay empty so the sub-tiles are open.
|
||||
Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false })
|
||||
written += 1
|
||||
}
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Ground selection
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Pick the tile the level's own pieces stand on.
|
||||
*
|
||||
* The shipped game picks the ground with a rarity roll over the DT1 libraries
|
||||
* that the level type's hardcoded mask selects (`DRLGROOMTILE_GetTileCache`),
|
||||
* and no table records which tile that lands on. The observable proxy is the
|
||||
* pieces themselves: a border or fill DS1 is authored as a chunk of scenery
|
||||
* sitting *on* the outdoor ground, so the floor it uses away from its own outer
|
||||
* ring is that ground.
|
||||
*
|
||||
* The outer ring is excluded because that is where the cliff face and its
|
||||
* transition tiles live. Cells with `prop1 == 0` are excluded because they are
|
||||
* unused slots, not tiles. The most frequent survivor wins, ties going to the
|
||||
* first seen so the choice is deterministic.
|
||||
*
|
||||
* @param pieces - the level's border and fill pieces.
|
||||
* @returns the tile, or null when no piece carries any floor.
|
||||
*/
|
||||
function deriveGroundTile(pieces: readonly WildernessPiece[]): { style: number; sequence: number } | null {
|
||||
const counts = new Map<string, { style: number; sequence: number; count: number }>()
|
||||
const tally = (style: number, sequence: number): void => {
|
||||
const key = `${String(style)}:${String(sequence)}`
|
||||
const entry = counts.get(key)
|
||||
if (entry === undefined) counts.set(key, { style, sequence, count: 1 })
|
||||
else entry.count += 1
|
||||
}
|
||||
for (const piece of pieces) {
|
||||
for (const level of piece.levels) {
|
||||
for (let y = 0; y < level.height; y += 1) {
|
||||
const row = level.cells[y]
|
||||
if (row === undefined) continue
|
||||
for (let x = 0; x < level.width; x += 1) {
|
||||
if (x === 0 || y === 0 || x === level.width - 1 || y === level.height - 1) continue
|
||||
const cell = row[x]
|
||||
if (cell === undefined) continue
|
||||
for (const floor of cell.floors) {
|
||||
if (floor.hidden || floor.prop1 === 0) continue
|
||||
tally(floor.style, floor.sequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let best: { style: number; sequence: number; count: number } | null = null
|
||||
for (const entry of counts.values()) {
|
||||
if (best === null || entry.count > best.count) best = entry
|
||||
}
|
||||
return best === null ? null : { style: best.style, sequence: best.sequence }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Piece geometry
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A piece's extent in blocks.
|
||||
*
|
||||
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` computes exactly this, with integer
|
||||
* division, to decide how many grid cells a stamped preset clears.
|
||||
*
|
||||
* @param piece - the piece.
|
||||
* @param variant - which decoded variant to measure.
|
||||
* @returns the extent in blocks, at least 1×1.
|
||||
*/
|
||||
function pieceBlocks(piece: WildernessPiece, variant: number): { x: number; y: number } {
|
||||
const level = piece.levels[variant % piece.levels.length]
|
||||
if (level === undefined) return { x: 1, y: 1 }
|
||||
return {
|
||||
x: Math.max(Math.floor(level.width / TILES_PER_BLOCK), 1),
|
||||
y: Math.max(Math.floor(level.height / TILES_PER_BLOCK), 1),
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Report
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** One substitution row's outcome, for `stats`. */
|
||||
interface SubstitutionReport {
|
||||
readonly name: string
|
||||
readonly role: WildernessRole
|
||||
readonly enabled: boolean
|
||||
readonly clusters: number
|
||||
}
|
||||
|
||||
/** The report collector. */
|
||||
interface WildernessStats {
|
||||
readonly substitutions: SubstitutionReport[]
|
||||
readonly borderPieces: Record<string, number>
|
||||
readonly unresolved: string[]
|
||||
readonly notes: string[]
|
||||
borderStamped: number
|
||||
groundCells: number
|
||||
groundTile: { style: number; sequence: number } | null
|
||||
sizeSource: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The hardcoded passes D2MOO runs for an outdoor level that this port does not
|
||||
* reproduce. Reported verbatim in every result's `stats.unimplementedPasses` so
|
||||
* the gap is visible rather than implied.
|
||||
*/
|
||||
const UNIMPLEMENTED_PASSES: readonly string[] = [
|
||||
'DRLGVER_CreateVertices',
|
||||
'DRLGOUTPLACE_CreateLevelConnections',
|
||||
'DRLGOUTWILD_InitAct1OutdoorLevel',
|
||||
'DRLGOUTDESR_InitAct2OutdoorLevel',
|
||||
'DRLGOUTPLACE_InitAct3OutdoorLevel',
|
||||
'DRLGOUTDOORS_InitAct4OutdoorLevel',
|
||||
'DRLGOUTSIEGE_InitAct5OutdoorLevel',
|
||||
'DRLG_GenerateJungles',
|
||||
'DRLGOUTDOORS_SpawnAct1DirtPaths',
|
||||
'DRLG_OUTDOORS_GenerateDirtPath',
|
||||
'DRLGOUTDOORS_SpawnAct12Waypoint',
|
||||
'DRLGOUTDOORS_SpawnAct12Shrines',
|
||||
'DRLGOUTDOORS_SpawnAct3Mephisto',
|
||||
]
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Border
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Walk the block grid's outer ring, clockwise from the top-left.
|
||||
*
|
||||
* This is the path `DRLGOUTPLACE_PlaceAct1245OutdoorBorders` walks: it follows
|
||||
* the level's outline vertex ring and stamps one preset per grid step along each
|
||||
* edge with the edge's own preset id. This port has no outline ring — building it
|
||||
* needs the level-link data that is hardcoded per act — so it uses the rectangle
|
||||
* the level *is*, which for every shipped wilderness level is the rectangle
|
||||
* `Levels.txt` declares.
|
||||
*
|
||||
* @param gridWidth - blocks across.
|
||||
* @param gridHeight - blocks down.
|
||||
* @returns the ring's block coordinates, clockwise, each exactly once.
|
||||
*/
|
||||
function borderRing(gridWidth: number, gridHeight: number): { x: number; y: number }[] {
|
||||
const ring: { x: number; y: number }[] = []
|
||||
for (let x = 0; x < gridWidth; x += 1) ring.push({ x, y: 0 })
|
||||
for (let y = 1; y < gridHeight; y += 1) ring.push({ x: gridWidth - 1, y })
|
||||
for (let x = gridWidth - 2; x >= 0; x -= 1) ring.push({ x, y: gridHeight - 1 })
|
||||
for (let y = gridHeight - 2; y >= 1; y -= 1) ring.push({ x: 0, y })
|
||||
return ring
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the level's border pieces around the block grid's outer ring.
|
||||
*
|
||||
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` is the model: a piece is stamped at a
|
||||
* block cell, the box it covers is claimed, and later stamps overwrite earlier
|
||||
* ones. Which piece a given stretch of cliff uses is hardcoded per act in D2MOO
|
||||
* (paired with a border *style* through tables like `levelPrestBorder`), so this
|
||||
* port does the data-driven equivalent: it walks the ring and cycles the level's
|
||||
* declared border pieces, smallest first, which keeps the cliffs a consistent
|
||||
* width and stops a large piece from swallowing the interior.
|
||||
*
|
||||
* @param canvas - the canvas.
|
||||
* @param pieces - the level's border pieces, already filtered to `border`.
|
||||
* @param gridWidth - blocks across.
|
||||
* @param gridHeight - blocks down.
|
||||
* @param rng - the level's random stream, for the variant rotation.
|
||||
* @param stats - report collector.
|
||||
* @returns the number of pieces stamped.
|
||||
*/
|
||||
function layBorder(
|
||||
canvas: Canvas,
|
||||
pieces: readonly WildernessPiece[],
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
rng: Rng,
|
||||
stats: WildernessStats,
|
||||
): number {
|
||||
if (pieces.length === 0) {
|
||||
stats.unresolved.push('no border piece for this level type')
|
||||
return 0
|
||||
}
|
||||
const ordered = [...pieces].sort((a, b) => {
|
||||
const areaA = pieceBlocks(a, 0).x * pieceBlocks(a, 0).y
|
||||
const areaB = pieceBlocks(b, 0).x * pieceBlocks(b, 0).y
|
||||
return areaA - areaB || a.name.localeCompare(b.name)
|
||||
})
|
||||
const ring = borderRing(gridWidth, gridHeight)
|
||||
let cursor = rng.int(0, ordered.length - 1)
|
||||
let stamped = 0
|
||||
for (const cell of ring) {
|
||||
const piece = ordered[cursor]!
|
||||
cursor = (cursor + 1) % ordered.length
|
||||
const variant = rng.int(0, piece.levels.length - 1)
|
||||
const level = piece.levels[variant % piece.levels.length]
|
||||
if (level === undefined) continue
|
||||
// Clip the piece to the canvas, but shift it so its near corner still covers
|
||||
// the ring cell: a cliff piece anchored past the edge would leave a hole.
|
||||
const originX = Math.min(cell.x * TILES_PER_BLOCK, Math.max(canvas.width - level.width, 0))
|
||||
const originY = Math.min(cell.y * TILES_PER_BLOCK, Math.max(canvas.height - level.height, 0))
|
||||
stampDs1(canvas, level, originX, originY)
|
||||
stats.borderPieces[piece.name] = (stats.borderPieces[piece.name] ?? 0) + 1
|
||||
stats.borderStamped += 1
|
||||
stamped += 1
|
||||
}
|
||||
return stamped
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Substitutions
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Scatter one `LvlSub` row's pieces across the level's interior.
|
||||
*
|
||||
* The parameters are used exactly as `DRLGTILESUB_DoSubstitutions` uses them:
|
||||
* `Max[theme]` clusters are attempted, each cluster spends `Trials[theme]`
|
||||
* positions looking for a free one (`-1` meaning "walk every free position"), and
|
||||
* `BordType` bounds the total — 0 allows a single cluster for the whole level, 1
|
||||
* a single cluster per row, anything else is unlimited. Positions snap to
|
||||
* `GridSize`, matching `x - x % dwGridSize` in `DRLGTILESUB_TestReplaceSubPreset`.
|
||||
*
|
||||
* The difference from D2MOO is granularity. There, a substitution group is a box
|
||||
* inside the piece's own DS1 and the swap happens tile by tile against the room's
|
||||
* floor/wall grids, driven by the DS1's substitution layer. `src/formats/ds1.ts`
|
||||
* decodes the substitution layer's raw values but not the group table, so this
|
||||
* port substitutes at *block* granularity: the whole piece is stamped at a block
|
||||
* origin. The shapes and the frequency are right; the exact tile a swap lands on
|
||||
* is not.
|
||||
*
|
||||
* @param canvas - the canvas.
|
||||
* @param row - the `LvlSub` row.
|
||||
* @param themeIndex - the clamped `Levels.txt.SubTheme`.
|
||||
* @param gridWidth - blocks across.
|
||||
* @param gridHeight - blocks down.
|
||||
* @param rng - the level's random stream.
|
||||
* @returns how many clusters were stamped.
|
||||
*/
|
||||
function applySubstitution(
|
||||
canvas: Canvas,
|
||||
row: WildernessSubstitution,
|
||||
themeIndex: number,
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
rng: Rng,
|
||||
): number {
|
||||
const level = row.levels[0]
|
||||
if (level === undefined) return 0
|
||||
const max = Math.max(0, Math.floor(row.max[themeIndex] ?? 0))
|
||||
if (max === 0) return 0
|
||||
const trials = Math.floor(row.trials[themeIndex] ?? 0)
|
||||
const gridSize = Math.max(1, Math.floor(row.gridSize))
|
||||
const blocksX = Math.max(Math.floor(level.width / TILES_PER_BLOCK), 1)
|
||||
const blocksY = Math.max(Math.floor(level.height / TILES_PER_BLOCK), 1)
|
||||
|
||||
// Keep scenery off the wall ring, so it can never open or seal the level.
|
||||
const innerX = gridWidth - 2
|
||||
const innerY = gridHeight - 2
|
||||
const spanX = innerX - blocksX
|
||||
const spanY = innerY - blocksY
|
||||
if (spanX <= 0 || spanY <= 0) return 0
|
||||
|
||||
/** Snap an interior offset to the cluster grid. */
|
||||
const snap = (value: number, span: number): number => {
|
||||
const limit = Math.max(Math.floor((span - 1) / gridSize), 0)
|
||||
return Math.min(Math.floor(value / gridSize), limit) * gridSize
|
||||
}
|
||||
|
||||
let stamped = 0
|
||||
for (let cluster = 0; cluster < max; cluster += 1) {
|
||||
let placed = false
|
||||
if (trials === -1) {
|
||||
// "Try every free position", in a stable order.
|
||||
for (let oy = 0; oy < spanY && !placed; oy += gridSize) {
|
||||
for (let ox = 0; ox < spanX && !placed; ox += gridSize) {
|
||||
const snappedX = snap(ox, spanX)
|
||||
const snappedY = snap(oy, spanY)
|
||||
stampDs1(canvas, level, (1 + snappedX) * TILES_PER_BLOCK, (1 + snappedY) * TILES_PER_BLOCK)
|
||||
placed = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let attempt = 0; attempt < trials && !placed; attempt += 1) {
|
||||
const offsetX = snap(rng.int(0, Math.max(spanX - 1, 0)), spanX)
|
||||
const offsetY = snap(rng.int(0, Math.max(spanY - 1, 0)), spanY)
|
||||
stampDs1(canvas, level, (1 + offsetX) * TILES_PER_BLOCK, (1 + offsetY) * TILES_PER_BLOCK)
|
||||
placed = true
|
||||
}
|
||||
}
|
||||
if (placed) stamped += 1
|
||||
}
|
||||
return stamped
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the `Prob` gate and then the cluster loop for every substitution row.
|
||||
*
|
||||
* The gate is `DRLGTILESUB_PickSubThemes`: each row of the level's group is
|
||||
* independently given a `Prob[SubTheme]` per-cent chance of being enabled, and an
|
||||
* enabled row contributes its `Dt1Mask` to the room's library mask.
|
||||
*
|
||||
* @param canvas - the canvas.
|
||||
* @param rows - the rows to run, in table order.
|
||||
* @param themeIndex - the clamped `SubTheme`.
|
||||
* @param gridWidth - blocks across.
|
||||
* @param gridHeight - blocks down.
|
||||
* @param rng - the level's random stream.
|
||||
* @param stats - report collector.
|
||||
* @returns the number of clusters stamped.
|
||||
*/
|
||||
function applySubstitutions(
|
||||
canvas: Canvas,
|
||||
rows: readonly WildernessSubstitution[],
|
||||
themeIndex: number,
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
rng: Rng,
|
||||
stats: WildernessStats,
|
||||
): number {
|
||||
let total = 0
|
||||
let unlimitedBudget = Number.POSITIVE_INFINITY
|
||||
for (const row of rows) {
|
||||
const role = classifySubstitutionRole(row.name, row.type)
|
||||
const chance = row.prob[themeIndex] ?? 0
|
||||
const enabled = chance > 0 && rng.int(0, 99) < chance
|
||||
let clusters = 0
|
||||
if (enabled) {
|
||||
// `BordType` 0 = one cluster for the whole level, 1 = one per row.
|
||||
const allowance = row.bordType === 0 ? Math.min(1, unlimitedBudget) : row.bordType === 1 ? 1 : Number.POSITIVE_INFINITY
|
||||
const rowMax = Math.max(0, Math.floor(row.max[themeIndex] ?? 0))
|
||||
const wanted = Math.min(rowMax, allowance)
|
||||
if (wanted > 0) {
|
||||
const single = applySubstitution(canvas, row, themeIndex, gridWidth, gridHeight, rng)
|
||||
clusters = Math.min(wanted, single)
|
||||
if (row.bordType !== 0 && row.bordType !== 1) clusters = single
|
||||
if (row.bordType === 0) unlimitedBudget = Math.max(0, unlimitedBudget - clusters)
|
||||
total += clusters
|
||||
}
|
||||
}
|
||||
stats.substitutions.push({ name: row.name, role, enabled, clusters })
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Entry point
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Derive the block grid for a level whose `Levels.txt` size is unset.
|
||||
*
|
||||
* `Act 5 - Barricade 1` and `2` ship `SizeX = SizeY = -1`. The engine does not
|
||||
* read a size for them: `DRLGOUTSIEGE_ConnectBarricadeAndSiege` positions the
|
||||
* barricade level against the siege level and sizes it from the barricade piece
|
||||
* itself. The barricade piece is the `LvlSub` row with `GridSize = 2` whose name
|
||||
* is `Barricade`, so its DS1's extent is the level's extent — that is what this
|
||||
* reproduces.
|
||||
*
|
||||
* @param request - the request.
|
||||
* @returns the size in cells and where it came from.
|
||||
* @throws when no barricade piece is available to size the level from.
|
||||
*/
|
||||
function resolveUnsetSize(request: WildernessRequest): { sizeX: number; sizeY: number; source: string } {
|
||||
if (request.sizeOverride !== undefined) {
|
||||
return { sizeX: request.sizeOverride.x, sizeY: request.sizeOverride.y, source: 'sizeOverride' }
|
||||
}
|
||||
const barricade = request.substitutions.find(row => /barricade/i.test(row.name))
|
||||
const level = barricade?.levels[0]
|
||||
if (level === undefined) {
|
||||
throw new Error(
|
||||
`level ${String(request.levelId)} (${request.levelName}): Levels.txt gives no size and no barricade piece to derive one from`,
|
||||
)
|
||||
}
|
||||
return { sizeX: level.width, sizeY: level.height, source: `LvlSub "${barricade!.name}" piece extent` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate one wilderness level.
|
||||
*
|
||||
* The pipeline follows `DRLGOUTDOORS_GenerateLevel`: derive the block grid from
|
||||
* the level's declared size, lay the ground, walk the outer ring stamping the
|
||||
* border pieces, then scatter the `LvlSub` substitutions according to
|
||||
* `SubType`/`SubTheme`. What it does **not** do is the per-act work that decides
|
||||
* the level's outline and where it meets its neighbours — see
|
||||
* {@link UNIMPLEMENTED_PASSES} and the module header.
|
||||
*
|
||||
* @param request - the real table data for one `DrlgType == 3` level.
|
||||
* @returns the synthesized map plus a report of what was done.
|
||||
* @throws when the level cannot be built: a size that yields no usable grid, or
|
||||
* an unset size with nothing to derive one from. The message always names the
|
||||
* level, because an empty map silently baked into an asset pack is worse than a
|
||||
* crash.
|
||||
*/
|
||||
export function generateWilderness(request: WildernessRequest): WildernessResult {
|
||||
const where = `level ${String(request.levelId)} (${request.levelName})`
|
||||
if (request.pieces.length === 0) throw new Error(`${where}: no LvlPrest pieces for this level type`)
|
||||
|
||||
let sizeX = request.sizeX
|
||||
let sizeY = request.sizeY
|
||||
let sizeSource = 'Levels.txt'
|
||||
if (!Number.isFinite(sizeX) || !Number.isFinite(sizeY) || sizeX <= 0 || sizeY <= 0) {
|
||||
const resolved = resolveUnsetSize(request)
|
||||
sizeX = resolved.sizeX
|
||||
sizeY = resolved.sizeY
|
||||
sizeSource = resolved.source
|
||||
}
|
||||
|
||||
const gridWidth = Math.floor(sizeX / TILES_PER_BLOCK)
|
||||
const gridHeight = Math.floor(sizeY / TILES_PER_BLOCK)
|
||||
if (gridWidth < 3 || gridHeight < 3) {
|
||||
throw new Error(`${where}: ${String(sizeX)}x${String(sizeY)} cells is only ${String(gridWidth)}x${String(gridHeight)} blocks, too small for a bordered level`)
|
||||
}
|
||||
|
||||
const stats: WildernessStats = {
|
||||
substitutions: [], borderPieces: {}, unresolved: [], notes: [],
|
||||
borderStamped: 0, groundCells: 0, groundTile: null, sizeSource,
|
||||
}
|
||||
|
||||
// Layer counts must be known before the canvas exists, because a cell's layer
|
||||
// arrays are fixed at creation.
|
||||
let wallLayers = 1
|
||||
let floorLayers = 1
|
||||
let substitutionType = 0
|
||||
let version = 0
|
||||
let act = 1
|
||||
const allLevels: Ds1[] = []
|
||||
for (const piece of request.pieces) for (const level of piece.levels) allLevels.push(level)
|
||||
for (const row of [...request.substitutions, ...(request.shrineSubstitutions ?? [])]) {
|
||||
for (const level of row.levels) allLevels.push(level)
|
||||
}
|
||||
for (const level of allLevels) {
|
||||
wallLayers = Math.max(wallLayers, level.wallLayers)
|
||||
floorLayers = Math.max(floorLayers, level.floorLayers)
|
||||
substitutionType = Math.max(substitutionType, level.substitutionType)
|
||||
version = Math.max(version, level.version)
|
||||
act = level.act
|
||||
}
|
||||
const substitutionLayers = substitutionType === 1 || substitutionType === 2 ? 1 : 0
|
||||
|
||||
const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, wallLayers, floorLayers, substitutionLayers)
|
||||
const rng = new Rng(request.seed)
|
||||
|
||||
const groundTile = request.groundTile ?? deriveGroundTile(request.pieces)
|
||||
stats.groundTile = groundTile
|
||||
if (groundTile === null) {
|
||||
throw new Error(`${where}: no piece carries a floor tile to use as ground`)
|
||||
}
|
||||
stats.groundCells = fillGround(canvas, groundTile, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
const borderPieces = request.pieces.filter(piece => piece.border)
|
||||
const fillPieces = request.pieces.filter(piece => !piece.border)
|
||||
layBorder(canvas, borderPieces, gridWidth, gridHeight, rng, stats)
|
||||
|
||||
const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme)))
|
||||
const objectRows = request.substitutions.filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')
|
||||
const borderRows = [...request.substitutions, ...(request.shrineSubstitutions ?? [])]
|
||||
.filter(row => classifySubstitutionRole(row.name, row.type) === 'border')
|
||||
// Secondary border pieces (`BordType` rows) are laid through the same cluster
|
||||
// machinery, which is where their `GridSize`/`BordType` parameters belong.
|
||||
const substituted = applySubstitutions(
|
||||
canvas,
|
||||
[...borderRows, ...objectRows, ...(request.shrineSubstitutions ?? []).filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')],
|
||||
themeIndex,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
rng,
|
||||
stats,
|
||||
)
|
||||
for (const piece of fillPieces) stats.notes.push(`fill piece "${piece.name}" is not placed by this port`)
|
||||
|
||||
if (!Number.isFinite(canvas.width * canvas.height) || canvas.width * canvas.height > (1 << 22)) {
|
||||
throw new Error(`${where}: synthesized map is ${String(canvas.width)}x${String(canvas.height)} cells, outside the supported bound`)
|
||||
}
|
||||
|
||||
return {
|
||||
level: {
|
||||
version: version === 0 ? 18 : version,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
act,
|
||||
substitutionType,
|
||||
wallLayers: canvas.wallLayers,
|
||||
floorLayers: canvas.floorLayers,
|
||||
cells: canvas.cells,
|
||||
objects: canvas.objects,
|
||||
npcPathOffset: null,
|
||||
},
|
||||
stats: {
|
||||
levelId: request.levelId,
|
||||
levelName: request.levelName,
|
||||
levelTypeName: request.levelTypeName,
|
||||
seed: request.seed,
|
||||
sizeX,
|
||||
sizeY,
|
||||
sizeSource,
|
||||
tilesPerBlock: TILES_PER_BLOCK,
|
||||
blockGrid: { width: gridWidth, height: gridHeight },
|
||||
dt1Mask: wildernessDt1Mask(request.levelTypeName),
|
||||
subType: request.subType,
|
||||
subTheme: themeIndex,
|
||||
groundTile,
|
||||
groundCells: stats.groundCells,
|
||||
borderPiecesAvailable: borderPieces.length,
|
||||
borderBlocks: stats.borderStamped,
|
||||
borderUsage: stats.borderPieces,
|
||||
substitutions: stats.substitutions,
|
||||
substitutedClusters: substituted,
|
||||
objects: canvas.objects.length,
|
||||
cells: canvas.width * canvas.height,
|
||||
unresolved: stats.unresolved,
|
||||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
||||
notes: stats.notes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* Browser entry: open a user-supplied MPQ and inspect what the container layer
|
||||
* decodes.
|
||||
*
|
||||
* This is the first visible milestone (M0): it proves the whole pipeline runs
|
||||
* web-native — random-access reads over a `File` (never loading the archive
|
||||
* into memory), table decryption, sector decompression, and format previews
|
||||
* rendered by platform APIs (canvas for images, Web Audio for sound).
|
||||
*/
|
||||
import { MpqArchive } from './mpq/archive.ts'
|
||||
import { blobSource } from './mpq/source.ts'
|
||||
import { decodePcx } from './formats/pcx.ts'
|
||||
|
||||
const drop = document.querySelector<HTMLDivElement>('#drop')!
|
||||
const picker = document.querySelector<HTMLInputElement>('#picker')!
|
||||
const stats = document.querySelector<HTMLDListElement>('#stats')!
|
||||
const filter = document.querySelector<HTMLInputElement>('#filter')!
|
||||
const list = document.querySelector<HTMLUListElement>('#files')!
|
||||
const preview = document.querySelector<HTMLElement>('#preview')!
|
||||
|
||||
/** Extension without the dot, lowercased. */
|
||||
function extensionOf(name: string): string {
|
||||
const cut = name.lastIndexOf('.')
|
||||
return cut === -1 ? '' : name.slice(cut + 1).toLowerCase()
|
||||
}
|
||||
|
||||
/** Human-readable byte count. */
|
||||
function bytes(count: number): string {
|
||||
if (count < 1024) return `${String(count)} B`
|
||||
if (count < 1024 * 1024) return `${(count / 1024).toFixed(1)} KB`
|
||||
return `${(count / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
/** Render a definition list of label/value pairs. */
|
||||
function renderStats(rows: readonly (readonly [string, string])[]): void {
|
||||
stats.replaceChildren(...rows.flatMap(([label, value]) => {
|
||||
const dt = document.createElement('dt')
|
||||
dt.textContent = label
|
||||
const dd = document.createElement('dd')
|
||||
dd.textContent = value
|
||||
return [dt, dd]
|
||||
}))
|
||||
}
|
||||
|
||||
/** Draw an RGBA image into a fresh canvas, scaled by whole pixels. */
|
||||
function imageCanvas(width: number, height: number, rgba: Uint8ClampedArray, scale: number): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width * scale
|
||||
canvas.height = height * scale
|
||||
const context = canvas.getContext('2d')!
|
||||
context.imageSmoothingEnabled = false
|
||||
const source = document.createElement('canvas')
|
||||
source.width = width
|
||||
source.height = height
|
||||
// `ImageData` demands an `ArrayBuffer`-backed view; a decoded image's RGBA
|
||||
// array is only typed as possibly-shared, so copy it into a fresh one.
|
||||
const backing = new Uint8ClampedArray(new ArrayBuffer(width * height * 4))
|
||||
backing.set(rgba)
|
||||
source.getContext('2d')!.putImageData(new ImageData(backing, width, height), 0, 0)
|
||||
context.drawImage(source, 0, 0, canvas.width, canvas.height)
|
||||
return canvas
|
||||
}
|
||||
|
||||
/** True when the bytes look like text rather than binary. */
|
||||
function looksTextual(data: Uint8Array): boolean {
|
||||
const sample = data.subarray(0, Math.min(data.byteLength, 512))
|
||||
let printable = 0
|
||||
for (const byte of sample) {
|
||||
if (byte === 9 || byte === 10 || byte === 13 || (byte >= 0x20 && byte < 0x7f) || byte >= 0x80) printable += 1
|
||||
}
|
||||
return sample.byteLength > 0 && printable / sample.byteLength > 0.9
|
||||
}
|
||||
|
||||
/** Hex + ASCII dump of the first bytes. */
|
||||
function hexDump(data: Uint8Array, limit = 512): string {
|
||||
const lines: string[] = []
|
||||
const end = Math.min(data.byteLength, limit)
|
||||
for (let at = 0; at < end; at += 16) {
|
||||
const row = data.subarray(at, Math.min(at + 16, end))
|
||||
const hex = [...row].map(byte => byte.toString(16).padStart(2, '0')).join(' ')
|
||||
const ascii = [...row].map(byte => (byte >= 0x20 && byte < 0x7f ? String.fromCharCode(byte) : '.')).join('')
|
||||
lines.push(`${at.toString(16).padStart(6, '0')} ${hex.padEnd(47)} ${ascii}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
|
||||
/** Show one decoded member. */
|
||||
async function showMember(archive: MpqArchive, name: string, button: HTMLButtonElement): Promise<void> {
|
||||
for (const other of list.querySelectorAll('button[aria-current]')) other.removeAttribute('aria-current')
|
||||
button.setAttribute('aria-current', 'true')
|
||||
const file = archive.find(name)
|
||||
preview.replaceChildren()
|
||||
const title = document.createElement('h2')
|
||||
title.textContent = name
|
||||
const sub = document.createElement('div')
|
||||
sub.className = 'sub'
|
||||
preview.append(title, sub)
|
||||
if (file === undefined) {
|
||||
sub.className = 'sub error'
|
||||
sub.textContent = '该名字在归档中解析不到(listfile 与实际条目不匹配)'
|
||||
return
|
||||
}
|
||||
sub.textContent = `${bytes(file.fileSize)} · flags 0x${(file.flags >>> 0).toString(16)} · block #${String(file.blockIndex)}`
|
||||
try {
|
||||
const data = await archive.read(file)
|
||||
const ext = extensionOf(name)
|
||||
if (ext === 'pcx') {
|
||||
const image = decodePcx(data)
|
||||
const scale = Math.max(1, Math.min(4, Math.floor(760 / Math.max(image.width, 1))))
|
||||
preview.append(imageCanvas(image.width, image.height, image.rgba, scale))
|
||||
const note = document.createElement('div')
|
||||
note.className = 'sub'
|
||||
note.textContent = `${String(image.width)}×${String(image.height)} · ${String(data.byteLength)} 字节 · PCX 解码正常`
|
||||
preview.append(note)
|
||||
return
|
||||
}
|
||||
if (ext === 'wav' || ext === 'mp3' || ext === 'ogg') {
|
||||
audioContext ??= new AudioContext()
|
||||
// The shareware archive's ".wav" members are actually MP3 payloads, so
|
||||
// the platform decoder (not a RIFF parser) is what must read them.
|
||||
const buffer = await audioContext.decodeAudioData(data.slice().buffer)
|
||||
const play = document.createElement('button')
|
||||
play.textContent = '▶ 播放'
|
||||
play.onclick = () => {
|
||||
const source = audioContext!.createBufferSource()
|
||||
source.buffer = buffer
|
||||
source.connect(audioContext!.destination)
|
||||
source.start()
|
||||
}
|
||||
const note = document.createElement('div')
|
||||
note.className = 'sub'
|
||||
note.textContent = `${buffer.numberOfChannels} 声道 · ${buffer.sampleRate} Hz · ${buffer.duration.toFixed(2)} s · Web Audio 解码正常`
|
||||
preview.append(play, note)
|
||||
return
|
||||
}
|
||||
if (looksTextual(data)) {
|
||||
const pre = document.createElement('pre')
|
||||
const text = new TextDecoder().decode(data)
|
||||
pre.textContent = text.length > 20000 ? `${text.slice(0, 20000)}\n… 共 ${String(text.length)} 字符` : text
|
||||
preview.append(pre)
|
||||
return
|
||||
}
|
||||
const pre = document.createElement('pre')
|
||||
pre.textContent = hexDump(data)
|
||||
preview.append(pre)
|
||||
} catch (error) {
|
||||
sub.className = 'sub error'
|
||||
sub.textContent = `解码失败:${(error as Error).message}`
|
||||
}
|
||||
}
|
||||
|
||||
/** Open an archive from a user-supplied file. */
|
||||
async function openArchive(file: File): Promise<void> {
|
||||
stats.replaceChildren()
|
||||
list.replaceChildren()
|
||||
preview.replaceChildren()
|
||||
drop.textContent = `正在读取 ${file.name} …`
|
||||
try {
|
||||
const archive = await MpqArchive.open(blobSource(file))
|
||||
const names = await archive.listFiles()
|
||||
drop.textContent = `${file.name} · 已就绪(重新拖入可替换)`
|
||||
const header = archive.header
|
||||
renderStats([
|
||||
['文件', file.name],
|
||||
['大小', bytes(file.size)],
|
||||
['格式', `MPQ v${String(header.formatVersion + 1)}`],
|
||||
['扇区', bytes(header.sectorSize)],
|
||||
['哈希表', `${String(header.hashTableEntries)} 项`],
|
||||
['块表', `${String(header.blockTableEntries)} 项`],
|
||||
['名字', `${String(names.length)} 个`],
|
||||
])
|
||||
filter.disabled = false
|
||||
const rows = names.map(name => {
|
||||
const item = document.createElement('li')
|
||||
const button = document.createElement('button')
|
||||
button.textContent = name
|
||||
const meta = document.createElement('span')
|
||||
meta.className = 'meta'
|
||||
const entry = archive.find(name)
|
||||
meta.textContent = entry === undefined ? ' (未解析)' : ` ${bytes(entry.fileSize)}`
|
||||
button.append(meta)
|
||||
button.onclick = () => { void showMember(archive, name, button) }
|
||||
item.append(button)
|
||||
return { item, name: name.toLowerCase() }
|
||||
})
|
||||
list.append(...rows.map(row => row.item))
|
||||
filter.oninput = () => {
|
||||
const needle = filter.value.trim().toLowerCase()
|
||||
for (const row of rows) row.item.hidden = needle !== '' && !row.name.includes(needle)
|
||||
}
|
||||
} catch (error) {
|
||||
drop.textContent = '打开失败,重试或换一个归档'
|
||||
const message = document.createElement('div')
|
||||
message.className = 'error'
|
||||
message.textContent = (error as Error).message
|
||||
stats.append(message)
|
||||
}
|
||||
}
|
||||
|
||||
drop.onclick = () => { picker.click() }
|
||||
picker.onchange = () => { const file = picker.files?.[0]; if (file) void openArchive(file) }
|
||||
for (const event of ['dragenter', 'dragover'] as const) {
|
||||
drop.addEventListener(event, () => { drop.classList.add('hot') })
|
||||
}
|
||||
for (const event of ['dragleave', 'drop'] as const) {
|
||||
drop.addEventListener(event, () => { drop.classList.remove('hot') })
|
||||
}
|
||||
drop.addEventListener('drop', (event) => {
|
||||
event.preventDefault()
|
||||
const file = event.dataTransfer?.files?.[0]
|
||||
if (file) void openArchive(file)
|
||||
})
|
||||
|
||||
// Dev convenience: `?sample=samples/spawn.mpq` streams a sample archive over
|
||||
// HTTP so the pipeline can be smoke-tested without a manual drag & drop.
|
||||
const sample = new URLSearchParams(location.search).get('sample')
|
||||
if (sample !== null) {
|
||||
void (async () => {
|
||||
drop.textContent = `正在通过 HTTP 读取 ${sample} …`
|
||||
const response = await fetch(sample)
|
||||
if (!response.ok) {
|
||||
drop.textContent = `样本读取失败:HTTP ${String(response.status)}`
|
||||
return
|
||||
}
|
||||
const blob = await response.blob()
|
||||
await openArchive(new File([blob], sample.split('/').pop() ?? 'sample.mpq'))
|
||||
})()
|
||||
}
|
||||
|
|
@ -0,0 +1,501 @@
|
|||
/**
|
||||
* MPQ archive reader (format v1).
|
||||
*
|
||||
* Layered like the format itself:
|
||||
* header → encrypted hash table → encrypted block table → per-file sectors.
|
||||
* Tables are always encrypted in v1, so opening an archive is a decrypt of
|
||||
* two tables; a member read is an offset-table read plus one decrypt (and
|
||||
* optionally one decompress) per sector, all through a random-access
|
||||
* {@link MpqSource} so a large archive is never held in memory.
|
||||
*
|
||||
* Classic Diablo/Diablo II archives are v1. v2+ headers (and the v4
|
||||
* `MPQ\x1B` container) are rejected explicitly rather than misparsed.
|
||||
*/
|
||||
import {
|
||||
BLOCK_TABLE_KEY, HASH_NAME_A, HASH_NAME_B, HASH_TABLE_KEY, HASH_TABLE_OFFSET,
|
||||
decryptBlock, fileKey, hashString, normalizeName,
|
||||
} from './crypt.ts'
|
||||
import { MpqCompressionError, compressionMaskName, decompressSector, COMPRESSION_PKWARE } from './decompress.ts'
|
||||
import { ImplodeError, explode } from './implode.ts'
|
||||
import type { MpqSource } from './source.ts'
|
||||
|
||||
/** Sector size is `512 << shift`; the shift lives in the header. */
|
||||
const SECTOR_SIZE_BASE = 512
|
||||
/** A hash-table entry's `blockIndex` sentinel: the slot never held a file. */
|
||||
const BLOCK_INDEX_FREE = 0xffffffff
|
||||
|
||||
/** `MPQ_FILE_IMPLODE`: PKWARE-imploded file. */
|
||||
const FILE_IMPLODE = 0x00000100
|
||||
/** `MPQ_FILE_COMPRESS`: file uses the multi-codec compression mask. */
|
||||
const FILE_COMPRESS = 0x00000200
|
||||
/** `MPQ_FILE_ENCRYPTED`: file blocks are encrypted. */
|
||||
const FILE_ENCRYPTED = 0x00010000
|
||||
/** `MPQ_FILE_FIX_KEY`: file key is adjusted by offset and size. */
|
||||
const FILE_FIX_KEY = 0x00020000
|
||||
/** `MPQ_FILE_SINGLE_UNIT`: file is stored as one unit, with no sector table. */
|
||||
const FILE_SINGLE_UNIT = 0x01000000
|
||||
/** `MPQ_FILE_DELETE_MARKER`: tombstone entry. */
|
||||
const FILE_DELETE_MARKER = 0x02000000
|
||||
/** `MPQ_FILE_SECTOR_CRC`: one extra sector of checksums follows the offset table. */
|
||||
const FILE_SECTOR_CRC = 0x04000000
|
||||
/** `MPQ_FILE_EXISTS`: entry names a real file. */
|
||||
const FILE_EXISTS = 0x80000000
|
||||
|
||||
/** The v1 archive signature. */
|
||||
const MPQ_MAGIC = 0x1a51504d
|
||||
|
||||
/** Parsed archive header (v1 fields). */
|
||||
export interface MpqHeader {
|
||||
/** Header size field as stored. */
|
||||
headerSize: number
|
||||
/** Declared archive size in bytes. */
|
||||
archiveSize: number
|
||||
/** Container format version (0 = v1). */
|
||||
formatVersion: number
|
||||
/** Sector size in bytes, derived from the stored shift. */
|
||||
sectorSize: number
|
||||
/** Offset of the hash table. */
|
||||
hashTableOffset: number
|
||||
/** Offset of the block table. */
|
||||
blockTableOffset: number
|
||||
/** Hash table length in entries. */
|
||||
hashTableEntries: number
|
||||
/** Block table length in entries. */
|
||||
blockTableEntries: number
|
||||
}
|
||||
|
||||
/** One resolved archive member. */
|
||||
export interface MpqFile {
|
||||
/** Normalized (backslash) name this file was found under. */
|
||||
readonly name: string
|
||||
/** Index of the file's block-table entry. */
|
||||
readonly blockIndex: number
|
||||
/** Locale field of the hash-table entry. */
|
||||
readonly locale: number
|
||||
/** Stored offset of the file's first byte. */
|
||||
readonly blockOffset: number
|
||||
/** Stored size in bytes. */
|
||||
readonly compressedSize: number
|
||||
/** Uncompressed size in bytes. */
|
||||
readonly fileSize: number
|
||||
/** Raw flag word. */
|
||||
readonly flags: number
|
||||
}
|
||||
|
||||
/** Options for {@link MpqArchive.open}. */
|
||||
export interface MpqOpenOptions {
|
||||
/**
|
||||
* Extra names to expose, for archives whose `(listfile)` is missing or
|
||||
* incomplete — the normal case for Diablo II's `d2data.mpq`, whose names
|
||||
* come from a community listfile.
|
||||
*/
|
||||
listfile?: readonly string[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A readable archive.
|
||||
*/
|
||||
export class MpqArchive {
|
||||
/** Parsed header of the open archive. */
|
||||
readonly header: MpqHeader
|
||||
/** Names supplied from outside the archive, when any. */
|
||||
private readonly names: readonly string[] | undefined
|
||||
/** The underlying byte source. */
|
||||
private readonly source: MpqSource
|
||||
/** Hash-table words, decrypted. */
|
||||
private readonly hashTable: Uint32Array
|
||||
/** Block-table words, decrypted. */
|
||||
private readonly blockTable: Uint32Array
|
||||
|
||||
private constructor(
|
||||
source: MpqSource,
|
||||
header: MpqHeader,
|
||||
hashTable: Uint32Array,
|
||||
blockTable: Uint32Array,
|
||||
options: MpqOpenOptions,
|
||||
) {
|
||||
this.source = source
|
||||
this.header = header
|
||||
this.hashTable = hashTable
|
||||
this.blockTable = blockTable
|
||||
this.names = options.listfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an archive over a random-access source.
|
||||
*
|
||||
* @param source - byte source positioned at the archive start.
|
||||
* @param options - listfile supplement.
|
||||
* @returns the open archive.
|
||||
*/
|
||||
static async open(source: MpqSource, options: MpqOpenOptions = {}): Promise<MpqArchive> {
|
||||
const head = await source.read(0, 32)
|
||||
const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
|
||||
const magic = view.getUint32(0, true)
|
||||
if (magic !== MPQ_MAGIC) {
|
||||
throw new Error(`${source.label}: not an MPQ archive (magic 0x${magic.toString(16)})`)
|
||||
}
|
||||
const formatVersion = view.getUint16(0x0c, true)
|
||||
if (formatVersion !== 0) {
|
||||
throw new Error(
|
||||
`${source.label}: MPQ format v${String(formatVersion + 1)} is not supported yet (only v1 headers)`,
|
||||
)
|
||||
}
|
||||
const blockSizeShift = view.getUint16(0x0e, true)
|
||||
const header: MpqHeader = {
|
||||
headerSize: view.getUint32(0x04, true),
|
||||
archiveSize: view.getUint32(0x08, true),
|
||||
formatVersion,
|
||||
sectorSize: SECTOR_SIZE_BASE << blockSizeShift,
|
||||
hashTableOffset: view.getUint32(0x10, true),
|
||||
blockTableOffset: view.getUint32(0x14, true),
|
||||
hashTableEntries: view.getUint32(0x18, true),
|
||||
blockTableEntries: view.getUint32(0x1c, true),
|
||||
}
|
||||
if (header.hashTableEntries === 0 || (header.hashTableEntries & (header.hashTableEntries - 1)) !== 0) {
|
||||
throw new Error(`${source.label}: hash table size ${String(header.hashTableEntries)} is not a non-zero power of two`)
|
||||
}
|
||||
const rawHash = await source.read(header.hashTableOffset, header.hashTableEntries * 16)
|
||||
decryptBlock(rawHash, HASH_TABLE_KEY)
|
||||
const rawBlock = await source.read(header.blockTableOffset, header.blockTableEntries * 16)
|
||||
decryptBlock(rawBlock, BLOCK_TABLE_KEY)
|
||||
return new MpqArchive(
|
||||
source,
|
||||
header,
|
||||
wordsOf(rawHash),
|
||||
wordsOf(rawBlock),
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one name.
|
||||
*
|
||||
* @param name - archive name, either separator.
|
||||
* @returns the member, or `undefined` when the archive has no such name.
|
||||
*/
|
||||
find(name: string): MpqFile | undefined {
|
||||
const target = normalizeName(name)
|
||||
const hashA = hashString(target, HASH_NAME_A)
|
||||
const hashB = hashString(target, HASH_NAME_B)
|
||||
const start = hashString(target, HASH_TABLE_OFFSET) % this.header.hashTableEntries
|
||||
let fallback: MpqFile | undefined
|
||||
for (let step = 0; step < this.header.hashTableEntries; step += 1) {
|
||||
const slot = (start + step) % this.header.hashTableEntries
|
||||
const base = slot * 4
|
||||
const entryHashA = this.hashTable[base]!
|
||||
const entryHashB = this.hashTable[base + 1]!
|
||||
const locale = this.hashTable[base + 2]! & 0xffff
|
||||
const blockIndex = this.hashTable[base + 3]!
|
||||
if (blockIndex === BLOCK_INDEX_FREE) return fallback
|
||||
if (entryHashA !== hashA || entryHashB !== hashB) continue
|
||||
const file = this.fileAt(blockIndex, target, locale)
|
||||
if (file === undefined) return fallback
|
||||
// Neutral locale wins when an archive carries localized duplicates.
|
||||
if (locale === 0) return file
|
||||
fallback ??= file
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe one block-table entry.
|
||||
*
|
||||
* @param blockIndex - index into the block table.
|
||||
* @param name - name to attach.
|
||||
* @param locale - locale to attach.
|
||||
* @returns the member, or `undefined` for a free/tombstone slot.
|
||||
*/
|
||||
private fileAt(blockIndex: number, name: string, locale: number): MpqFile | undefined {
|
||||
if (blockIndex >= this.header.blockTableEntries) return undefined
|
||||
const base = blockIndex * 4
|
||||
const flags = this.blockTable[base + 3]!
|
||||
if ((flags & FILE_EXISTS) === 0 || (flags & FILE_DELETE_MARKER) !== 0) return undefined
|
||||
return {
|
||||
name,
|
||||
blockIndex,
|
||||
locale,
|
||||
blockOffset: this.blockTable[base]!,
|
||||
compressedSize: this.blockTable[base + 1]!,
|
||||
fileSize: this.blockTable[base + 2]!,
|
||||
flags,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every occupied block-table slot, named where a name is known.
|
||||
*
|
||||
* Diablo II's `d2data.mpq` carries no `(listfile)`, so the useful way to
|
||||
* explore it is by block index; this enumeration keeps that door open.
|
||||
*
|
||||
* @param names - optional index → name map (e.g. a parsed community listfile).
|
||||
* @returns one entry per occupied block-table slot.
|
||||
*/
|
||||
files(names?: ReadonlyMap<number, string>): MpqFile[] {
|
||||
const out: MpqFile[] = []
|
||||
for (let blockIndex = 0; blockIndex < this.header.blockTableEntries; blockIndex += 1) {
|
||||
const name = names?.get(blockIndex)
|
||||
const file = this.fileAt(blockIndex, name ?? `block:${String(blockIndex)}`, 0)
|
||||
if (file !== undefined) out.push(file)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one stored sector body into exactly `expected` bytes.
|
||||
*
|
||||
* Two encodings exist, and which one applies is decided per sector:
|
||||
*
|
||||
* - **Stored as-is** when the body's length already equals the expected size.
|
||||
* Encoders fall back to this whenever compressing a sector would not shrink
|
||||
* it, so a member can mix stored and compressed sectors — `Patch_D2.mpq` has
|
||||
* both, including members whose stored size exceeds the declared file size by
|
||||
* nothing but the offset table. Stored sectors carry no marker byte.
|
||||
* - **Compressed**, and then the flag word decides how to read it:
|
||||
* `MPQ_FILE_COMPRESS` (0x200) means a codec mask byte comes first, while
|
||||
* `MPQ_FILE_IMPLODE` (0x100) alone means the body is a PKWARE implode
|
||||
* stream with no mask. Diablo II's `Patch_D2.mpq` stores every table the
|
||||
* second way; reading those as if a mask byte were present used to hand back
|
||||
* the compressed bytes verbatim, because a stream starting with 0x00 looks
|
||||
* exactly like "stored". That silent path is gone.
|
||||
*
|
||||
* @param body - stored bytes, decrypted already.
|
||||
* @param expected - uncompressed length of this sector.
|
||||
* @param masked - the member carries `MPQ_FILE_COMPRESS`.
|
||||
* @returns the decoded sector.
|
||||
*/
|
||||
private async decodeSector(body: Uint8Array, expected: number, masked: boolean): Promise<Uint8Array> {
|
||||
if (body.byteLength === expected) return body
|
||||
if (masked) return decompressSector(body, expected)
|
||||
try {
|
||||
return explode(body, expected)
|
||||
} catch (err) {
|
||||
const detail = err instanceof ImplodeError ? err.message : String(err)
|
||||
throw new MpqCompressionError(`implode-only member: ${detail}`, COMPRESSION_PKWARE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and decode one member.
|
||||
*
|
||||
* @param file - a member from {@link find} (or {@link files}).
|
||||
* @returns the decoded bytes.
|
||||
*/
|
||||
async read(file: MpqFile): Promise<Uint8Array> {
|
||||
const { flags, fileSize, compressedSize, blockOffset, name } = file
|
||||
if (fileSize === 0) return new Uint8Array(0)
|
||||
const encrypted = (flags & FILE_ENCRYPTED) !== 0
|
||||
const singleUnit = (flags & FILE_SINGLE_UNIT) !== 0
|
||||
const compressed = (flags & (FILE_COMPRESS | FILE_IMPLODE)) !== 0
|
||||
const masked = (flags & FILE_COMPRESS) !== 0
|
||||
const key = fileKey(name, blockOffset, fileSize, (flags & FILE_FIX_KEY) !== 0)
|
||||
|
||||
if (singleUnit) {
|
||||
const raw = await this.source.read(blockOffset, compressedSize)
|
||||
if (encrypted) decryptBlock(raw, key)
|
||||
if (!compressed) return raw
|
||||
return this.decodeSector(raw, fileSize, masked)
|
||||
}
|
||||
|
||||
if (!compressed) {
|
||||
// Stored members carry no sector offset table at all (the reference
|
||||
// implementation only loads one under `MPQ_FILE_COMPRESS_MASK`): the data
|
||||
// is one contiguous run at the block offset. Encrypted stored members are
|
||||
// still decrypted per sector-sized chunk, because that is the cipher's
|
||||
// key schedule — key + sector index.
|
||||
const raw = await this.source.read(blockOffset, fileSize)
|
||||
if (!encrypted) return raw
|
||||
const sectorSize = this.header.sectorSize
|
||||
for (let sector = 0; sector * sectorSize < fileSize; sector += 1) {
|
||||
const from = sector * sectorSize
|
||||
const to = Math.min(from + sectorSize, fileSize)
|
||||
decryptBlock(raw.subarray(from, to), (key + sector) >>> 0)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
const sectorSize = this.header.sectorSize
|
||||
const sectorCount = Math.ceil(fileSize / sectorSize)
|
||||
// A CRC sector rides between the offset table and the data.
|
||||
const tableEntries = sectorCount + ((flags & FILE_SECTOR_CRC) !== 0 ? 1 : 0)
|
||||
const table = await this.source.read(blockOffset, (tableEntries + 1) * 4)
|
||||
if (encrypted) decryptBlock(table, (key - 1) >>> 0)
|
||||
const offsets = new DataView(table.buffer, table.byteOffset, table.byteLength)
|
||||
const out = new Uint8Array(fileSize)
|
||||
for (let sector = 0; sector < sectorCount; sector += 1) {
|
||||
const from = offsets.getUint32(sector * 4, true)
|
||||
const to = offsets.getUint32((sector + 1) * 4, true)
|
||||
if (to < from) {
|
||||
throw new MpqCompressionError(
|
||||
`${name}: sector ${String(sector)} has a reversed extent (${String(from)}..${String(to)})`,
|
||||
)
|
||||
}
|
||||
const expected = Math.min(sectorSize, fileSize - sector * sectorSize)
|
||||
const stored = await this.source.read(blockOffset + from, to - from)
|
||||
if (encrypted) decryptBlock(stored, (key + sector) >>> 0)
|
||||
const decoded = await this.decodeSector(stored, expected, masked)
|
||||
if (decoded.byteLength !== expected) {
|
||||
// Loud on purpose: a short buffer padded with zeros decodes into
|
||||
// plausible-looking nonsense downstream.
|
||||
throw new MpqCompressionError(
|
||||
`${name}: sector ${String(sector)} decoded ${String(decoded.byteLength)} bytes, expected ${String(expected)}`,
|
||||
)
|
||||
}
|
||||
out.set(decoded, sector * sectorSize)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a member by name.
|
||||
*
|
||||
* @param name - archive name, either separator.
|
||||
* @returns the decoded bytes.
|
||||
*/
|
||||
async readByName(name: string): Promise<Uint8Array> {
|
||||
const file = this.find(name)
|
||||
if (file === undefined) throw new Error(`${this.source.label}: no such file "${name}"`)
|
||||
return this.read(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* The names **this archive actually contains**: `(listfile)` when present, plus
|
||||
* any supplied external listfile, deduplicated and each verified through the
|
||||
* hash table.
|
||||
*
|
||||
* A community listfile names every retail archive at once, so it cannot be
|
||||
* trusted as-is: a name it lists may live in a *different* archive. Filtering by
|
||||
* {@link find} is what makes "the archive does not contain this member" a fact
|
||||
* rather than a statement about which names happened to be enumerated.
|
||||
*
|
||||
* @returns the known names, in listfile order.
|
||||
*/
|
||||
async listFiles(): Promise<string[]> {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
const push = (name: string): void => {
|
||||
const trimmed = name.trim()
|
||||
if (trimmed === '' || seen.has(trimmed.toLowerCase())) return
|
||||
seen.add(trimmed.toLowerCase())
|
||||
if (trimmed === '(listfile)' || this.find(trimmed) !== undefined) out.push(trimmed)
|
||||
}
|
||||
const internal = this.find('(listfile)')
|
||||
if (internal !== undefined) {
|
||||
const text = new TextDecoder().decode(await this.read(internal))
|
||||
for (const line of text.split(/\r?\n/)) push(line)
|
||||
}
|
||||
for (const name of this.names ?? []) push(name)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Map block-table index → name for every name the archive can resolve.
|
||||
*
|
||||
* A file's encryption key is derived from its *name*, so an anonymous block
|
||||
* cannot be decrypted at all; this map is what turns block indices back into
|
||||
* readable entries for archives whose listfile is external.
|
||||
*
|
||||
* @returns the index → name map.
|
||||
*/
|
||||
async nameIndex(): Promise<Map<number, string>> {
|
||||
const index = new Map<number, string>()
|
||||
for (const name of await this.listFiles()) {
|
||||
const file = this.find(name)
|
||||
if (file !== undefined && !index.has(file.blockIndex)) index.set(file.blockIndex, file.name)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe the codecs an archive uses across all occupied blocks — the
|
||||
* quickest way to see which decoders a given archive actually needs.
|
||||
*
|
||||
* Blocks that are both encrypted and nameless are counted as `unknown`:
|
||||
* their key cannot be derived without the name.
|
||||
*
|
||||
* @param names - optional index → name map (see {@link nameIndex}).
|
||||
* @returns mask name → count.
|
||||
*/
|
||||
async compressionHistogram(names?: ReadonlyMap<number, string>): Promise<Map<string, number>> {
|
||||
const counts = new Map<string, number>()
|
||||
const bump = (name: string): void => { counts.set(name, (counts.get(name) ?? 0) + 1) }
|
||||
for (const file of this.files(names)) {
|
||||
if (file.fileSize === 0) continue
|
||||
const singleUnit = (file.flags & FILE_SINGLE_UNIT) !== 0
|
||||
const encrypted = (file.flags & FILE_ENCRYPTED) !== 0
|
||||
const compressed = (file.flags & (FILE_COMPRESS | FILE_IMPLODE)) !== 0
|
||||
if (!compressed) {
|
||||
bump('stored')
|
||||
continue
|
||||
}
|
||||
const named = names?.has(file.blockIndex) ?? !encrypted
|
||||
if (!named) {
|
||||
bump('unknown (encrypted, unnamed)')
|
||||
continue
|
||||
}
|
||||
const key = fileKey(file.name, file.blockOffset, file.fileSize, (file.flags & FILE_FIX_KEY) !== 0)
|
||||
const sectorSize = this.header.sectorSize
|
||||
if (singleUnit) {
|
||||
const raw = await this.source.read(file.blockOffset, Math.min(file.compressedSize, file.fileSize))
|
||||
if (encrypted) decryptBlock(raw, key)
|
||||
bump(compressionMaskName(raw[0] ?? 0))
|
||||
continue
|
||||
}
|
||||
const sectorCount = Math.ceil(file.fileSize / sectorSize)
|
||||
const tableEntries = sectorCount + ((file.flags & FILE_SECTOR_CRC) !== 0 ? 1 : 0)
|
||||
const table = await this.source.read(file.blockOffset, (tableEntries + 1) * 4)
|
||||
if (encrypted) decryptBlock(table, (key - 1) >>> 0)
|
||||
const offsets = new DataView(table.buffer, table.byteOffset, table.byteLength)
|
||||
const from = offsets.getUint32(0, true)
|
||||
const to = offsets.getUint32(4, true)
|
||||
if (to <= from) continue
|
||||
const raw = await this.source.read(file.blockOffset + from, to - from)
|
||||
if (encrypted) decryptBlock(raw, key)
|
||||
bump(compressionMaskName(raw[0] ?? 0))
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many members carry each storage flag — the fastest way to see
|
||||
* what a given archive will exercise (encryption, single-unit storage, ...).
|
||||
*
|
||||
* @returns flag name → count.
|
||||
*/
|
||||
flagHistogram(): Map<string, number> {
|
||||
const counts = new Map<string, number>()
|
||||
const bump = (name: string): void => { counts.set(name, (counts.get(name) ?? 0) + 1) }
|
||||
for (const file of this.files()) {
|
||||
if (file.fileSize === 0) { bump('empty'); continue }
|
||||
bump('total')
|
||||
if ((file.flags & FILE_ENCRYPTED) !== 0) bump('encrypted')
|
||||
if ((file.flags & FILE_FIX_KEY) !== 0) bump('fix-key')
|
||||
if ((file.flags & FILE_SINGLE_UNIT) !== 0) bump('single-unit')
|
||||
if ((file.flags & FILE_COMPRESS) !== 0) bump('compressed')
|
||||
if ((file.flags & FILE_IMPLODE) !== 0) bump('imploded')
|
||||
if ((file.flags & FILE_SECTOR_CRC) !== 0) bump('sector-crc')
|
||||
}
|
||||
return counts
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinterpret a byte buffer as little-endian words.
|
||||
*
|
||||
* Hash and block tables are arrays of `uint32`; the cipher already produced
|
||||
* little-endian bytes, so the view is only for convenience.
|
||||
*
|
||||
* @param bytes - the decrypted table.
|
||||
* @returns the words.
|
||||
*/
|
||||
function wordsOf(bytes: Uint8Array): Uint32Array {
|
||||
const count = bytes.byteLength >>> 2
|
||||
const words = new Uint32Array(count)
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
words[i] = (bytes[i * 4]!)
|
||||
| ((bytes[i * 4 + 1]!) << 8)
|
||||
| ((bytes[i * 4 + 2]!) << 16)
|
||||
| ((bytes[i * 4 + 3]!) << 24)
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* MPQ (Storm) cryptography: the shared crypt table, `HashString`, and the
|
||||
* block cipher.
|
||||
*
|
||||
* Semantics follow the public StormLib reference implementation (MIT,
|
||||
* Copyright (c) Ladislav Zezula); this file is an independent TypeScript
|
||||
* port. All arithmetic is unsigned 32-bit with explicit wrapping, because
|
||||
* the format depends on exact overflow behaviour.
|
||||
*/
|
||||
|
||||
/** Hash selector: table index (start of the probe chain). */
|
||||
export const HASH_TABLE_OFFSET = 0
|
||||
/** Hash selector: name hash A (hash table entry comparison). */
|
||||
export const HASH_NAME_A = 1
|
||||
/** Hash selector: name hash B (hash table entry comparison). */
|
||||
export const HASH_NAME_B = 2
|
||||
/** Hash selector: per-file encryption key. */
|
||||
export const HASH_FILE_KEY = 3
|
||||
|
||||
/** Charset size: one hash alphabet per selector occupies 0x100 slots. */
|
||||
const CHARSET_SIZE = 0x100
|
||||
|
||||
/**
|
||||
* The 0x500-entry Storm crypt table: 5 alphabets of 0x100 values. The last
|
||||
* alphabet (0x400..0x4FF) is the cipher's per-key-byte seed table.
|
||||
*/
|
||||
const CRYPT_TABLE: Uint32Array = (() => {
|
||||
const table = new Uint32Array(5 * CHARSET_SIZE)
|
||||
let seed = 0x00100001
|
||||
for (let index1 = 0; index1 < CHARSET_SIZE; index1 += 1) {
|
||||
let index2 = index1
|
||||
for (let i = 0; i < 5; i += 1, index2 += CHARSET_SIZE) {
|
||||
seed = ((((Math.imul(seed, 125) + 3) >>> 0) % 0x2aaaab) >>> 0)
|
||||
const temp1 = ((seed & 0xffff) << 0x10) >>> 0
|
||||
seed = ((((Math.imul(seed, 125) + 3) >>> 0) % 0x2aaaab) >>> 0)
|
||||
const temp2 = seed & 0xffff
|
||||
table[index2] = (temp1 | temp2) >>> 0
|
||||
}
|
||||
}
|
||||
return table
|
||||
})()
|
||||
|
||||
/**
|
||||
* Storm's case-insensitive string hash.
|
||||
*
|
||||
* Archive names are ASCII; the uppercase fold is applied to ASCII only so a
|
||||
* stray non-ASCII byte cannot alias two distinct names.
|
||||
*
|
||||
* @param name - archive file name (either separator is accepted by callers).
|
||||
* @param hashType - one of the `HASH_*` selectors.
|
||||
* @returns the 32-bit hash.
|
||||
*/
|
||||
export function hashString(name: string, hashType: number): number {
|
||||
let seed1 = 0x7fed7fed
|
||||
let seed2 = 0xeeeeeeee
|
||||
const base = hashType * CHARSET_SIZE
|
||||
for (let i = 0; i < name.length; i += 1) {
|
||||
const code = name.charCodeAt(i)
|
||||
const ch = (code >= 0x61 && code <= 0x7a ? code - 0x20 : code) & 0xff
|
||||
seed1 = (CRYPT_TABLE[base + ch]! ^ ((seed1 + seed2) >>> 0)) >>> 0
|
||||
seed2 = (ch + seed1 + seed2 + (((seed2 << 5) >>> 0)) + 3) >>> 0
|
||||
}
|
||||
return seed1
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt one block in place with the MPQ block cipher.
|
||||
*
|
||||
* @param data - buffer to decrypt; `byteLength` must be a multiple of 4.
|
||||
* @param key - the 32-bit file/table key.
|
||||
*/
|
||||
export function decryptBlock(data: Uint8Array, key: number): void {
|
||||
const words = data.byteLength >>> 2
|
||||
if (words === 0) return
|
||||
const view = new DataView(data.buffer, data.byteOffset, words << 2)
|
||||
let seed = 0xeeeeeeee
|
||||
let k = key >>> 0
|
||||
for (let i = 0; i < words; i += 1) {
|
||||
seed = (seed + CRYPT_TABLE[0x400 + (k & 0xff)]!) >>> 0
|
||||
const ch = (view.getUint32(i * 4, true) ^ ((k + seed) >>> 0)) >>> 0
|
||||
k = ((((~k << 0x15) >>> 0) + 0x11111111 | (k >>> 0x0b)) >>> 0)
|
||||
seed = (ch + seed + (((seed << 5) >>> 0)) + 3) >>> 0
|
||||
view.setUint32(i * 4, ch, true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt one block in place with the MPQ block cipher.
|
||||
*
|
||||
* MPQ's cipher is its own inverse in the sense that both directions run the same
|
||||
* key schedule: the schedule is advanced with the *plaintext* word, so encrypting
|
||||
* reads the plaintext, writes the ciphertext, and feeds the plaintext into the
|
||||
* schedule — while `decryptBlock` reads the ciphertext, recovers the plaintext,
|
||||
* and feeds that same plaintext in. Keeping the two explicit matters for the
|
||||
* writer, where using the decrypt path would corrupt the schedule.
|
||||
*
|
||||
* @param data - buffer to encrypt; `byteLength` must be a multiple of 4.
|
||||
* @param key - the 32-bit file/table key.
|
||||
*/
|
||||
export function encryptBlock(data: Uint8Array, key: number): void {
|
||||
const words = data.byteLength >>> 2
|
||||
if (words === 0) return
|
||||
const view = new DataView(data.buffer, data.byteOffset, words << 2)
|
||||
let seed = 0xeeeeeeee
|
||||
let k = key >>> 0
|
||||
for (let i = 0; i < words; i += 1) {
|
||||
seed = (seed + CRYPT_TABLE[0x400 + (k & 0xff)]!) >>> 0
|
||||
const plain = view.getUint32(i * 4, true)
|
||||
const cipher = (plain ^ ((k + seed) >>> 0)) >>> 0
|
||||
k = ((((~k << 0x15) >>> 0) + 0x11111111 | (k >>> 0x0b)) >>> 0)
|
||||
seed = (plain + seed + (((seed << 5) >>> 0)) + 3) >>> 0
|
||||
view.setUint32(i * 4, cipher, true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The encryption key of one archive member.
|
||||
*
|
||||
* The key is derived from the *plain* file name — the segment after the last
|
||||
* separator — while the hash-table lookup uses the full path. The two are the
|
||||
* same only for root-level names, so a path-qualified lookup that hashes the
|
||||
* whole name silently produces a key that decrypts to noise.
|
||||
*
|
||||
* @param name - archive file name, either separator.
|
||||
* @param blockOffset - stored offset of the file's first byte.
|
||||
* @param fileSize - uncompressed size of the file.
|
||||
* @param fixKey - the entry carries `MPQ_FILE_FIX_KEY`.
|
||||
* @returns the file key.
|
||||
*/
|
||||
export function fileKey(name: string, blockOffset: number, fileSize: number, fixKey: boolean): number {
|
||||
const base = hashString(plainName(normalizeName(name)), HASH_FILE_KEY)
|
||||
return fixKey ? (((base + blockOffset) >>> 0) ^ fileSize) >>> 0 : base
|
||||
}
|
||||
|
||||
/**
|
||||
* The plain (directory-less) form of an archive name.
|
||||
*
|
||||
* @param name - archive name, either separator.
|
||||
* @returns the trailing path segment.
|
||||
*/
|
||||
export function plainName(name: string): string {
|
||||
const cut = Math.max(name.lastIndexOf('\\'), name.lastIndexOf('/'))
|
||||
return cut === -1 ? name : name.slice(cut + 1)
|
||||
}
|
||||
|
||||
/** The fixed key of the hash table. */
|
||||
export const HASH_TABLE_KEY = hashString('(hash table)', HASH_FILE_KEY)
|
||||
/** The fixed key of the block table. */
|
||||
export const BLOCK_TABLE_KEY = hashString('(block table)', HASH_FILE_KEY)
|
||||
|
||||
/**
|
||||
* Archive names use backslash separators internally; callers may pass either.
|
||||
*
|
||||
* @param name - caller-supplied name.
|
||||
* @returns the normalized name.
|
||||
*/
|
||||
export function normalizeName(name: string): string {
|
||||
return name.replace(/\//g, '\\')
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* MPQ sector decompression.
|
||||
*
|
||||
* A compressed sector starts with one mask byte naming the codecs applied to
|
||||
* the payload; combined masks (e.g. `HUFFMANN | ADPCM_MONO` on Diablo's WAV
|
||||
* data) are unwound in the fixed order below, which is the order StormLib
|
||||
* applies them in.
|
||||
*
|
||||
* zlib needs no code of its own: `DecompressionStream('deflate')` is the
|
||||
* platform decoder, present in browsers and in Node. PKWARE implode needed real
|
||||
* code — Diablo II stores almost every table and asset with it — so it lives in
|
||||
* `implode.ts` and is called here.
|
||||
*/
|
||||
|
||||
import { ImplodeError, explode } from './implode.ts'
|
||||
|
||||
/** Codec bit: adaptive Huffmann (FGK variant). */
|
||||
export const COMPRESSION_HUFFMANN = 0x01
|
||||
/** Codec bit: zlib/deflate. */
|
||||
export const COMPRESSION_ZLIB = 0x02
|
||||
/** Codec bit: PKWARE Data Compression Library implode. */
|
||||
export const COMPRESSION_PKWARE = 0x08
|
||||
/** Codec bit: bzip2. */
|
||||
export const COMPRESSION_BZIP2 = 0x10
|
||||
/** Codec bit: sparse (run-length + mask bytes). */
|
||||
export const COMPRESSION_SPARSE = 0x20
|
||||
/** Codec bit: ADPCM, mono, 4-bit. */
|
||||
export const COMPRESSION_ADPCM_MONO = 0x40
|
||||
/** Codec bit: ADPCM, stereo, 4-bit. */
|
||||
export const COMPRESSION_ADPCM_STEREO = 0x80
|
||||
|
||||
/** Raised when a sector cannot be decoded. */
|
||||
export class MpqCompressionError extends Error {
|
||||
/** The compression mask that failed, when known. */
|
||||
readonly mask: number | undefined
|
||||
|
||||
/**
|
||||
* @param message - human-readable failure.
|
||||
* @param mask - the compression mask that failed, when known.
|
||||
*/
|
||||
constructor(message: string, mask?: number) {
|
||||
super(message)
|
||||
this.name = 'MpqCompressionError'
|
||||
this.mask = mask
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Name the codecs a mask selects, for diagnostics.
|
||||
*
|
||||
* @param mask - the compression mask byte.
|
||||
* @returns a `+`-joined list, or `stored` for an empty mask.
|
||||
*/
|
||||
export function compressionMaskName(mask: number): string {
|
||||
const parts: string[] = []
|
||||
if (mask & COMPRESSION_HUFFMANN) parts.push('huffmann')
|
||||
if (mask & COMPRESSION_ZLIB) parts.push('zlib')
|
||||
if (mask & COMPRESSION_PKWARE) parts.push('pkware')
|
||||
if (mask & COMPRESSION_BZIP2) parts.push('bzip2')
|
||||
if (mask & COMPRESSION_SPARSE) parts.push('sparse')
|
||||
if (mask & COMPRESSION_ADPCM_MONO) parts.push('adpcm-mono')
|
||||
if (mask & COMPRESSION_ADPCM_STEREO) parts.push('adpcm-stereo')
|
||||
return parts.length === 0 ? 'stored' : parts.join('+')
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflate one zlib stream.
|
||||
*
|
||||
* @param data - zlib-wrapped deflate bytes.
|
||||
* @returns the inflated bytes.
|
||||
*/
|
||||
async function inflateZlib(data: Uint8Array): Promise<Uint8Array> {
|
||||
const stream = new Blob([data as BlobPart]).stream().pipeThrough(new DecompressionStream('deflate'))
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer())
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one compressed sector payload (mask byte included).
|
||||
*
|
||||
* @param input - the stored sector bytes, mask byte first.
|
||||
* @param expectedSize - the sector's uncompressed length.
|
||||
* @returns the decoded sector.
|
||||
*/
|
||||
export async function decompressSector(input: Uint8Array, expectedSize: number): Promise<Uint8Array> {
|
||||
if (input.byteLength === 0) return input
|
||||
const mask = input[0]!
|
||||
if (mask === 0) {
|
||||
// A zero mask marks a stored payload that merely carried the marker byte.
|
||||
return input.subarray(1, 1 + expectedSize)
|
||||
}
|
||||
let current: Uint8Array = input.subarray(1)
|
||||
let remaining = mask
|
||||
|
||||
if (remaining & COMPRESSION_HUFFMANN) {
|
||||
throw new MpqCompressionError(`huffmann decoder not implemented yet (mask 0x${mask.toString(16)})`, mask)
|
||||
}
|
||||
if (remaining & COMPRESSION_ZLIB) {
|
||||
current = await inflateZlib(current)
|
||||
remaining &= ~COMPRESSION_ZLIB
|
||||
}
|
||||
if (remaining & COMPRESSION_PKWARE) {
|
||||
// PKWARE is the last codec before bzip2/sparse/ADPCM in the reference
|
||||
// order, so the payload it sees is already at its final length.
|
||||
try {
|
||||
current = explode(current, expectedSize)
|
||||
} catch (err) {
|
||||
const detail = err instanceof ImplodeError ? err.message : String(err)
|
||||
throw new MpqCompressionError(`pkware implode failed: ${detail} (mask 0x${mask.toString(16)})`, mask)
|
||||
}
|
||||
remaining &= ~COMPRESSION_PKWARE
|
||||
}
|
||||
if (remaining & COMPRESSION_BZIP2) {
|
||||
throw new MpqCompressionError(`bzip2 decoder not implemented yet (mask 0x${mask.toString(16)})`, mask)
|
||||
}
|
||||
if (remaining & COMPRESSION_SPARSE) {
|
||||
throw new MpqCompressionError(`sparse decoder not implemented yet (mask 0x${mask.toString(16)})`, mask)
|
||||
}
|
||||
if (remaining & (COMPRESSION_ADPCM_MONO | COMPRESSION_ADPCM_STEREO)) {
|
||||
throw new MpqCompressionError(`adpcm decoder not implemented yet (mask 0x${mask.toString(16)})`, mask)
|
||||
}
|
||||
if (remaining !== 0) {
|
||||
throw new MpqCompressionError(`unknown compression bits 0x${remaining.toString(16)} (mask 0x${mask.toString(16)})`, mask)
|
||||
}
|
||||
if (current.byteLength !== expectedSize) {
|
||||
throw new MpqCompressionError(
|
||||
`decoded ${String(current.byteLength)} bytes, expected ${String(expectedSize)} (mask 0x${mask.toString(16)})`,
|
||||
mask,
|
||||
)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Filesystem-backed source for Node-side tooling.
|
||||
*
|
||||
* Kept out of `source.ts` on purpose: a static `node:fs/promises` import in a
|
||||
* module the browser bundle reaches makes bundlers externalize it and emit a
|
||||
* compatibility warning, even though the browser never calls it.
|
||||
*/
|
||||
import type { MpqSource } from './source.ts'
|
||||
|
||||
/**
|
||||
* Build a source over a filesystem path.
|
||||
*
|
||||
* @param path - file path.
|
||||
* @returns the source.
|
||||
*/
|
||||
export async function fileSource(path: string): Promise<MpqSource> {
|
||||
const { open, stat } = await import('node:fs/promises')
|
||||
const handle = await open(path, 'r')
|
||||
const info = await stat(path)
|
||||
return {
|
||||
size: info.size,
|
||||
label: path,
|
||||
read: async (offset, length) => {
|
||||
if (offset < 0 || offset + length > info.size) {
|
||||
throw new RangeError(`${path}: read ${String(offset)}+${String(length)} beyond ${String(info.size)}`)
|
||||
}
|
||||
const buffer = new Uint8Array(length)
|
||||
let done = 0
|
||||
while (done < length) {
|
||||
const { bytesRead } = await handle.read(buffer, done, length - done, offset + done)
|
||||
if (bytesRead === 0) throw new Error(`${path}: short read at ${String(offset + done)}`)
|
||||
done += bytesRead
|
||||
}
|
||||
return buffer
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* PKWARE Data Compression Library ("implode") constant tables.
|
||||
*
|
||||
* Machine-transcribed from StormLib's `src/pklib/explode.c` (MIT, Copyright (c)
|
||||
* Ladislav Zezula) — the values are data of the format, not choices: `LenCode`
|
||||
* and `DistCode` are the canonical prefix codes, `LenBase`/`ExLenBits` the
|
||||
* length extension, and `ChBitsAsc`/`ChCodeAsc` the literal alphabet used by
|
||||
* the ASCII variant. They were parsed out of the C source by a script rather
|
||||
* than retyped, because a single wrong nibble would decode some archives and
|
||||
* silently corrupt others.
|
||||
*
|
||||
* Reference: <https://github.com/ladislav-zezula/StormLib/blob/master/src/pklib/explode.c>
|
||||
*
|
||||
* `ChBitsAsc` is a *template*: the ASCII path rewrites entries above 8 bits at
|
||||
* startup, so {@link explode} copies it before use.
|
||||
*/
|
||||
|
||||
/** `DistBits` from the reference (64 entries). */
|
||||
export const DISTBITS: readonly number[] = [
|
||||
0x02, 0x04, 0x04, 0x05, 0x05, 0x05, 0x05, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06,
|
||||
0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07,
|
||||
0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07,
|
||||
0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08,
|
||||
]
|
||||
|
||||
/** `DistCode` from the reference (64 entries). */
|
||||
export const DISTCODE: readonly number[] = [
|
||||
0x03, 0x0d, 0x05, 0x19, 0x09, 0x11, 0x01, 0x3e, 0x1e, 0x2e, 0x0e, 0x36, 0x16, 0x26, 0x06, 0x3a,
|
||||
0x1a, 0x2a, 0x0a, 0x32, 0x12, 0x22, 0x42, 0x02, 0x7c, 0x3c, 0x5c, 0x1c, 0x6c, 0x2c, 0x4c, 0x0c,
|
||||
0x74, 0x34, 0x54, 0x14, 0x64, 0x24, 0x44, 0x04, 0x78, 0x38, 0x58, 0x18, 0x68, 0x28, 0x48, 0x08,
|
||||
0xf0, 0x70, 0xb0, 0x30, 0xd0, 0x50, 0x90, 0x10, 0xe0, 0x60, 0xa0, 0x20, 0xc0, 0x40, 0x80, 0x00,
|
||||
]
|
||||
|
||||
/** `ExLenBits` from the reference (16 entries). */
|
||||
export const EXLENBITS: readonly number[] = [
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
||||
]
|
||||
|
||||
/** `LenBase` from the reference (16 entries). */
|
||||
export const LENBASE: readonly number[] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x0a, 0x0e, 0x16, 0x26, 0x46, 0x86, 0x106,
|
||||
]
|
||||
|
||||
/** `LenBits` from the reference (16 entries). */
|
||||
export const LENBITS: readonly number[] = [
|
||||
0x03, 0x02, 0x03, 0x03, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, 0x05, 0x06, 0x06, 0x06, 0x07, 0x07,
|
||||
]
|
||||
|
||||
/** `LenCode` from the reference (16 entries). */
|
||||
export const LENCODE: readonly number[] = [
|
||||
0x05, 0x03, 0x01, 0x06, 0x0a, 0x02, 0x0c, 0x14, 0x04, 0x18, 0x08, 0x30, 0x10, 0x20, 0x40, 0x00,
|
||||
]
|
||||
|
||||
/** `ChBitsAsc` from the reference (256 entries). */
|
||||
export const CHBITSASC: readonly number[] = [
|
||||
0x0b, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x08, 0x07, 0x0c, 0x0c, 0x07, 0x0c, 0x0c,
|
||||
0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0d, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c,
|
||||
0x04, 0x0a, 0x08, 0x0c, 0x0a, 0x0c, 0x0a, 0x08, 0x07, 0x07, 0x08, 0x09, 0x07, 0x06, 0x07, 0x08,
|
||||
0x07, 0x06, 0x07, 0x07, 0x07, 0x07, 0x08, 0x07, 0x07, 0x08, 0x08, 0x0c, 0x0b, 0x07, 0x09, 0x0b,
|
||||
0x0c, 0x06, 0x07, 0x06, 0x06, 0x05, 0x07, 0x08, 0x08, 0x06, 0x0b, 0x09, 0x06, 0x07, 0x06, 0x06,
|
||||
0x07, 0x0b, 0x06, 0x06, 0x06, 0x07, 0x09, 0x08, 0x09, 0x09, 0x0b, 0x08, 0x0b, 0x09, 0x0c, 0x08,
|
||||
0x0c, 0x05, 0x06, 0x06, 0x06, 0x05, 0x06, 0x06, 0x06, 0x05, 0x0b, 0x07, 0x05, 0x06, 0x05, 0x05,
|
||||
0x06, 0x0a, 0x05, 0x05, 0x05, 0x05, 0x08, 0x07, 0x08, 0x08, 0x0a, 0x0b, 0x0b, 0x0c, 0x0c, 0x0c,
|
||||
0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d,
|
||||
0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d,
|
||||
0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d,
|
||||
0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c,
|
||||
0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c,
|
||||
0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c,
|
||||
0x0d, 0x0c, 0x0d, 0x0d, 0x0d, 0x0c, 0x0d, 0x0d, 0x0d, 0x0c, 0x0d, 0x0d, 0x0d, 0x0d, 0x0c, 0x0d,
|
||||
0x0d, 0x0d, 0x0c, 0x0c, 0x0c, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d,
|
||||
]
|
||||
|
||||
/** `ChCodeAsc` from the reference (256 entries). */
|
||||
export const CHCODEASC: readonly number[] = [
|
||||
0x0490, 0x0fe0, 0x07e0, 0x0be0, 0x03e0, 0x0de0, 0x05e0, 0x09e0,
|
||||
0x01e0, 0x00b8, 0x0062, 0x0ee0, 0x06e0, 0x0022, 0x0ae0, 0x02e0,
|
||||
0x0ce0, 0x04e0, 0x08e0, 0x00e0, 0x0f60, 0x0760, 0x0b60, 0x0360,
|
||||
0x0d60, 0x0560, 0x1240, 0x0960, 0x0160, 0x0e60, 0x0660, 0x0a60,
|
||||
0x000f, 0x0250, 0x0038, 0x0260, 0x0050, 0x0c60, 0x0390, 0x00d8,
|
||||
0x0042, 0x0002, 0x0058, 0x01b0, 0x007c, 0x0029, 0x003c, 0x0098,
|
||||
0x005c, 0x0009, 0x001c, 0x006c, 0x002c, 0x004c, 0x0018, 0x000c,
|
||||
0x0074, 0x00e8, 0x0068, 0x0460, 0x0090, 0x0034, 0x00b0, 0x0710,
|
||||
0x0860, 0x0031, 0x0054, 0x0011, 0x0021, 0x0017, 0x0014, 0x00a8,
|
||||
0x0028, 0x0001, 0x0310, 0x0130, 0x003e, 0x0064, 0x001e, 0x002e,
|
||||
0x0024, 0x0510, 0x000e, 0x0036, 0x0016, 0x0044, 0x0030, 0x00c8,
|
||||
0x01d0, 0x00d0, 0x0110, 0x0048, 0x0610, 0x0150, 0x0060, 0x0088,
|
||||
0x0fa0, 0x0007, 0x0026, 0x0006, 0x003a, 0x001b, 0x001a, 0x002a,
|
||||
0x000a, 0x000b, 0x0210, 0x0004, 0x0013, 0x0032, 0x0003, 0x001d,
|
||||
0x0012, 0x0190, 0x000d, 0x0015, 0x0005, 0x0019, 0x0008, 0x0078,
|
||||
0x00f0, 0x0070, 0x0290, 0x0410, 0x0010, 0x07a0, 0x0ba0, 0x03a0,
|
||||
0x0240, 0x1c40, 0x0c40, 0x1440, 0x0440, 0x1840, 0x0840, 0x1040,
|
||||
0x0040, 0x1f80, 0x0f80, 0x1780, 0x0780, 0x1b80, 0x0b80, 0x1380,
|
||||
0x0380, 0x1d80, 0x0d80, 0x1580, 0x0580, 0x1980, 0x0980, 0x1180,
|
||||
0x0180, 0x1e80, 0x0e80, 0x1680, 0x0680, 0x1a80, 0x0a80, 0x1280,
|
||||
0x0280, 0x1c80, 0x0c80, 0x1480, 0x0480, 0x1880, 0x0880, 0x1080,
|
||||
0x0080, 0x1f00, 0x0f00, 0x1700, 0x0700, 0x1b00, 0x0b00, 0x1300,
|
||||
0x0da0, 0x05a0, 0x09a0, 0x01a0, 0x0ea0, 0x06a0, 0x0aa0, 0x02a0,
|
||||
0x0ca0, 0x04a0, 0x08a0, 0x00a0, 0x0f20, 0x0720, 0x0b20, 0x0320,
|
||||
0x0d20, 0x0520, 0x0920, 0x0120, 0x0e20, 0x0620, 0x0a20, 0x0220,
|
||||
0x0c20, 0x0420, 0x0820, 0x0020, 0x0fc0, 0x07c0, 0x0bc0, 0x03c0,
|
||||
0x0dc0, 0x05c0, 0x09c0, 0x01c0, 0x0ec0, 0x06c0, 0x0ac0, 0x02c0,
|
||||
0x0cc0, 0x04c0, 0x08c0, 0x00c0, 0x0f40, 0x0740, 0x0b40, 0x0340,
|
||||
0x0300, 0x0d40, 0x1d00, 0x0d00, 0x1500, 0x0540, 0x0500, 0x1900,
|
||||
0x0900, 0x0940, 0x1100, 0x0100, 0x1e00, 0x0e00, 0x0140, 0x1600,
|
||||
0x0600, 0x1a00, 0x0e40, 0x0640, 0x0a40, 0x0a00, 0x1200, 0x0200,
|
||||
0x1c00, 0x0c00, 0x1400, 0x0400, 0x1800, 0x0800, 0x1000, 0x0000,
|
||||
]
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
/**
|
||||
* PKWARE Data Compression Library "implode" decoder.
|
||||
*
|
||||
* This is the codec behind `MPQ_COMPRESSION_PKWARE` (`mask 0x08`) — and behind
|
||||
* every member flagged `MPQ_FILE_IMPLODE` without `MPQ_FILE_COMPRESS`, which is
|
||||
* how Diablo II's `Patch_D2.mpq` stores its tables. Without it, real Diablo II
|
||||
* archives are unreadable: the container opens and the block table enumerates,
|
||||
* but every payload stays compressed.
|
||||
*
|
||||
* Semantics follow StormLib's `src/pklib/explode.c` (MIT, Copyright (c)
|
||||
* Ladislav Zezula); the constant tables were machine-transcribed rather than
|
||||
* retyped (see `implode-tables.ts`), so a wrong nibble cannot hide in a table.
|
||||
*
|
||||
* The stream is bit-oriented: a 16-bit window refills a byte at a time, and each
|
||||
* symbol is either a literal (8 raw bits, or a code from the fixed literal
|
||||
* alphabet in "ASCII" mode) or a back-reference. Diablo II uses both modes, so
|
||||
* both are implemented.
|
||||
*
|
||||
* Two deliberate departures from the reference:
|
||||
*
|
||||
* 1. **A back-reference reaching before the start of the output is an error,
|
||||
* not a read of whatever the sliding window held.** The reference keeps a
|
||||
* 0x2000-byte window and trusts the encoder to stay in range; here the
|
||||
* history *is* the output, so an out-of-range distance is provably corrupt
|
||||
* input.
|
||||
* 2. **The output length is checked, never assumed.** The caller states the
|
||||
* expected size (the archive knows it from the block table) and a stream
|
||||
* that ends short or long throws. Padding with zeros produces a buffer that
|
||||
* decodes into plausible-looking nonsense — the exact failure this project
|
||||
* refuses to ship.
|
||||
*/
|
||||
import {
|
||||
CHBITSASC, CHCODEASC, DISTBITS, DISTCODE, EXLENBITS, LENBASE, LENBITS, LENCODE,
|
||||
} from './implode-tables.ts'
|
||||
|
||||
/** Compression type byte: literals are raw bytes. */
|
||||
export const CMP_BINARY = 0
|
||||
/** Compression type byte: literals come from the fixed ASCII alphabet. */
|
||||
export const CMP_ASCII = 1
|
||||
|
||||
/** `DecodeLit` result meaning "end of stream". */
|
||||
const LIT_END_OF_STREAM = 0x305
|
||||
/** `DecodeLit` result meaning "malformed stream". */
|
||||
const LIT_ERROR = 0x306
|
||||
|
||||
/** Raised when an implode stream cannot be decoded. */
|
||||
export class ImplodeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ImplodeError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the "positions" table the decoder probes with the next 8 bits.
|
||||
*
|
||||
* Each code owns a span of `1 << bits` slots starting at its start index, so the
|
||||
* table answers "which code is this prefix" in one indexed read.
|
||||
*
|
||||
* @param startIndexes - first slot of each code.
|
||||
* @param lengthBits - code width in bits.
|
||||
* @param elements - number of codes.
|
||||
* @returns a 0x100-entry position table.
|
||||
*/
|
||||
function generateDecodeTabs(
|
||||
startIndexes: readonly number[],
|
||||
lengthBits: readonly number[],
|
||||
elements: number,
|
||||
): Uint8Array {
|
||||
const positions = new Uint8Array(0x100)
|
||||
for (let i = 0; i < elements; i += 1) {
|
||||
const length = 1 << lengthBits[i]!
|
||||
for (let index = startIndexes[i]!; index < 0x100; index += length) positions[index] = i
|
||||
}
|
||||
return positions
|
||||
}
|
||||
|
||||
/** The literal alphabet's derived tables, built once. */
|
||||
interface AsciiTables {
|
||||
/** Per-character code width, with >8-bit entries rewritten as the reference does. */
|
||||
readonly chBits: Uint8Array
|
||||
/** First-level literal lookup (8 bits). */
|
||||
readonly offs2C34: Uint8Array
|
||||
/** Second-level lookup after 4 further bits. */
|
||||
readonly offs2D34: Uint8Array
|
||||
/** Second-level lookup after 6 further bits. */
|
||||
readonly offs2E34: Uint8Array
|
||||
/** Lookup used when the next 8 bits are all zero. */
|
||||
readonly offs2EB4: Uint8Array
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the literal tables.
|
||||
*
|
||||
* A literal code wider than 8 bits is split: its low bits index a first-level
|
||||
* table that marks the entry as "needs more bits" (`0xff`), and its high bits
|
||||
* index a second-level table once 4 or 6 further bits are consumed. The
|
||||
* reference rewrites `ChBitsAsc` in place while doing this, which is why the
|
||||
* template gets copied here instead of shared.
|
||||
*
|
||||
* @returns the derived tables.
|
||||
*/
|
||||
function buildAsciiTables(): AsciiTables {
|
||||
const chBits = Uint8Array.from(CHBITSASC)
|
||||
const offs2C34 = new Uint8Array(0x100)
|
||||
const offs2D34 = new Uint8Array(0x100)
|
||||
const offs2E34 = new Uint8Array(0x100)
|
||||
const offs2EB4 = new Uint8Array(0x100)
|
||||
|
||||
for (let count = 0xff; count >= 0; count -= 1) {
|
||||
const code = CHCODEASC[count]!
|
||||
let bits = chBits[count]!
|
||||
if (bits <= 8) {
|
||||
for (let acc = code; acc < 0x100; acc += 1 << bits) offs2C34[acc] = count
|
||||
continue
|
||||
}
|
||||
const low = code & 0xff
|
||||
if (low === 0) {
|
||||
// The whole code sits above the first 8 bits: one second-level table.
|
||||
bits -= 8
|
||||
chBits[count] = bits
|
||||
for (let acc = code >>> 8; acc < 0x100; acc += 1 << bits) offs2EB4[acc] = count
|
||||
continue
|
||||
}
|
||||
offs2C34[low] = 0xff
|
||||
if ((code & 0x3f) !== 0) {
|
||||
bits -= 4
|
||||
chBits[count] = bits
|
||||
for (let acc = code >>> 4; acc < 0x100; acc += 1 << bits) offs2D34[acc] = count
|
||||
} else {
|
||||
bits -= 6
|
||||
chBits[count] = bits
|
||||
for (let acc = code >>> 6; acc < 0x80; acc += 1 << bits) offs2E34[acc] = count
|
||||
}
|
||||
}
|
||||
return { chBits, offs2C34, offs2D34, offs2E34, offs2EB4 }
|
||||
}
|
||||
|
||||
/** Length-code lookup, shared by every call. */
|
||||
const LENGTH_CODES = generateDecodeTabs(LENCODE, LENBITS, 0x10)
|
||||
/** Distance-code lookup, shared by every call. */
|
||||
const DISTANCE_CODES = generateDecodeTabs(DISTCODE, DISTBITS, 0x40)
|
||||
/** Literal alphabet tables, shared by every call. */
|
||||
const ASCII = buildAsciiTables()
|
||||
|
||||
/**
|
||||
* Bit-oriented reader over one compressed sector.
|
||||
*
|
||||
* `buffer` holds the next 8..16 bits, low bits first; `extraBits` counts the
|
||||
* buffered bits beyond the low 8.
|
||||
*/
|
||||
class BitStream {
|
||||
/** The compressed bytes. */
|
||||
private readonly input: Uint8Array
|
||||
/** Compression type byte (0 = binary literals, 1 = ASCII alphabet). */
|
||||
readonly ctype: number
|
||||
/** Dictionary width in bits (4..6). */
|
||||
readonly dsizeBits: number
|
||||
/** Position of the next byte to fold into the window. */
|
||||
private position = 3
|
||||
/** Buffered bits beyond the low 8. */
|
||||
private extraBits = 0
|
||||
/** The 16-bit window; the format reads past the low 8 bits directly. */
|
||||
private buffer: number
|
||||
|
||||
constructor(input: Uint8Array, ctype: number, dsizeBits: number, initial: number) {
|
||||
this.input = input
|
||||
this.ctype = ctype
|
||||
this.dsizeBits = dsizeBits
|
||||
this.buffer = initial
|
||||
}
|
||||
|
||||
/** Width mask for repetition distances. */
|
||||
get dsizeMask(): number {
|
||||
return 0xffff >>> (0x10 - this.dsizeBits)
|
||||
}
|
||||
|
||||
/** The buffered window; the format reads beyond the low 8 bits directly. */
|
||||
peek16(): number {
|
||||
return this.buffer
|
||||
}
|
||||
|
||||
/** The next 8 bits, without consuming them. */
|
||||
peek8(): number {
|
||||
return this.buffer & 0xff
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume `bits` bits.
|
||||
*
|
||||
* @param bits - bit count (never more than 8 in this format).
|
||||
* @returns true when the input ran out before the bits could be taken.
|
||||
*/
|
||||
waste(bits: number): boolean {
|
||||
if (bits <= this.extraBits) {
|
||||
this.extraBits -= bits
|
||||
this.buffer >>>= bits
|
||||
return false
|
||||
}
|
||||
this.buffer >>>= this.extraBits
|
||||
if (this.position >= this.input.length) return true
|
||||
this.buffer |= this.input[this.position]! << 8
|
||||
this.position += 1
|
||||
this.buffer >>>= bits - this.extraBits
|
||||
const rest = this.extraBits - bits + 8
|
||||
if (rest < 0) throw new ImplodeError(`bit window underflow taking ${String(bits)} bits`)
|
||||
this.extraBits = rest
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one symbol.
|
||||
*
|
||||
* @param stream - the bit stream.
|
||||
* @returns 0x00..0xff for a literal, `0x100 + (length - 2)` for a repetition,
|
||||
* 0x305 at end of stream, 0x306 on a malformed stream.
|
||||
*/
|
||||
function decodeLit(stream: BitStream): number {
|
||||
if ((stream.peek16() & 1) !== 0) {
|
||||
if (stream.waste(1)) return LIT_ERROR
|
||||
const lengthCode = LENGTH_CODES[stream.peek8()]!
|
||||
if (stream.waste(LENBITS[lengthCode]!)) return LIT_ERROR
|
||||
const extraLengthBits = EXLENBITS[lengthCode]!
|
||||
if (extraLengthBits === 0) return lengthCode + 0x100
|
||||
const extraLength = stream.peek16() & ((1 << extraLengthBits) - 1)
|
||||
// A stream that runs dry here is tolerated for exactly one code — the
|
||||
// reference's escape hatch for a final repetition, kept for bit parity.
|
||||
if (stream.waste(extraLengthBits) && lengthCode + extraLength !== 0x10e) return LIT_ERROR
|
||||
return LENBASE[lengthCode]! + extraLength + 0x100
|
||||
}
|
||||
|
||||
if (stream.waste(1)) return LIT_ERROR
|
||||
if (stream.ctype === CMP_BINARY) {
|
||||
const byte = stream.peek8()
|
||||
return stream.waste(8) ? LIT_ERROR : byte
|
||||
}
|
||||
|
||||
let value: number
|
||||
if (stream.peek8() !== 0) {
|
||||
value = ASCII.offs2C34[stream.peek8()]!
|
||||
if (value === 0xff) {
|
||||
if ((stream.peek16() & 0x3f) !== 0) {
|
||||
if (stream.waste(4)) return LIT_ERROR
|
||||
value = ASCII.offs2D34[stream.peek8()]!
|
||||
} else {
|
||||
if (stream.waste(6)) return LIT_ERROR
|
||||
value = ASCII.offs2E34[stream.peek16() & 0x7f]!
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (stream.waste(8)) return LIT_ERROR
|
||||
value = ASCII.offs2EB4[stream.peek8()]!
|
||||
}
|
||||
return stream.waste(ASCII.chBits[value]!) ? LIT_ERROR : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a repetition's backward distance.
|
||||
*
|
||||
* @param stream - the bit stream.
|
||||
* @param repLength - the repetition length already decoded.
|
||||
* @returns the distance in bytes (1-based), or 0 when the stream ended.
|
||||
*/
|
||||
function decodeDist(stream: BitStream, repLength: number): number {
|
||||
const distPosCode = DISTANCE_CODES[stream.peek8()]!
|
||||
if (stream.waste(DISTBITS[distPosCode]!)) return 0
|
||||
if (repLength === 2) {
|
||||
// Two-byte repetitions carry two extra bits instead of the full dictionary
|
||||
// width: the encoder knows a distance in 4..(4*distPosCode+3) range.
|
||||
const distance = (distPosCode << 2) | (stream.peek16() & 0x03)
|
||||
return stream.waste(2) ? 0 : distance + 1
|
||||
}
|
||||
const distance = (distPosCode << stream.dsizeBits) | (stream.peek16() & stream.dsizeMask)
|
||||
return stream.waste(stream.dsizeBits) ? 0 : distance + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a whole implode stream into exactly `expectedSize` bytes.
|
||||
*
|
||||
* @param input - the compressed bytes, stream header included.
|
||||
* @param expectedSize - the uncompressed length the archive declares.
|
||||
* @returns the decoded bytes.
|
||||
*/
|
||||
export function explode(input: Uint8Array, expectedSize: number): Uint8Array {
|
||||
if (input.length <= 4) throw new ImplodeError(`implode stream too short (${String(input.length)} bytes)`)
|
||||
const ctype = input[0]!
|
||||
const dsizeBits = input[1]!
|
||||
if (ctype !== CMP_BINARY && ctype !== CMP_ASCII) throw new ImplodeError(`unknown implode mode ${String(ctype)}`)
|
||||
if (dsizeBits < 4 || dsizeBits > 6) throw new ImplodeError(`invalid implode dictionary size ${String(dsizeBits)}`)
|
||||
const stream = new BitStream(input, ctype, dsizeBits, input[2]!)
|
||||
|
||||
const out = new Uint8Array(expectedSize)
|
||||
let written = 0
|
||||
for (;;) {
|
||||
const literal = decodeLit(stream)
|
||||
if (literal === LIT_ERROR) throw new ImplodeError('implode stream ended mid-symbol')
|
||||
if (literal === LIT_END_OF_STREAM) break
|
||||
if (literal >= 0x100) {
|
||||
const repLength = literal - 0xfe
|
||||
const minusDist = decodeDist(stream, repLength)
|
||||
if (minusDist === 0) throw new ImplodeError('implode stream ended inside a repetition')
|
||||
const source = written - minusDist
|
||||
if (source < 0) {
|
||||
throw new ImplodeError(`back-reference ${String(minusDist)} bytes before the start of the output`)
|
||||
}
|
||||
if (written + repLength > expectedSize) {
|
||||
throw new ImplodeError(`output overflow: ${String(written + repLength)} > ${String(expectedSize)}`)
|
||||
}
|
||||
// Byte at a time on purpose: when the encoder stored a run, the
|
||||
// repetition overlaps itself, and that overlap is part of the format.
|
||||
for (let i = 0; i < repLength; i += 1) out[written + i] = out[source + i]!
|
||||
written += repLength
|
||||
} else {
|
||||
if (written >= expectedSize) {
|
||||
throw new ImplodeError(`output overflow at byte ${String(written)} (expected ${String(expectedSize)})`)
|
||||
}
|
||||
out[written] = literal
|
||||
written += 1
|
||||
}
|
||||
}
|
||||
if (written !== expectedSize) {
|
||||
throw new ImplodeError(`decoded ${String(written)} bytes, expected ${String(expectedSize)}`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* MPQ container layer: archive reading over any random-access source.
|
||||
*/
|
||||
export * from './archive.ts'
|
||||
export * from './crypt.ts'
|
||||
export * from './decompress.ts'
|
||||
export * from './source.ts'
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* Several MPQ archives mounted as one namespace.
|
||||
*
|
||||
* Diablo II does not read one archive, it reads a stack. The game data lives in
|
||||
* `d2data.mpq`, the expansion overrides parts of it in `d2exp.mpq`, and the 1.13
|
||||
* patch overrides both from `Patch_D2.mpq`; character art sits in `d2char.mpq`.
|
||||
* The rule is overlay semantics: **the last archive mounted wins**, so a caller
|
||||
* mounts in load order (`d2data`, `d2exp`, `Patch_D2`) and never has to ask
|
||||
* which archive a file came from.
|
||||
*
|
||||
* Lookups go through the name, not the block table index, because an encrypted
|
||||
* member's key is derived from its name: a member reached as "block 5383" would
|
||||
* decrypt to noise. `d2exp.mpq` alone has 585 such members.
|
||||
*/
|
||||
import type { MpqArchive, MpqFile } from './archive.ts'
|
||||
|
||||
/** One mounted archive and the label it is known by. */
|
||||
export interface MountEntry {
|
||||
/** Human-readable identity, e.g. the file name. */
|
||||
readonly label: string
|
||||
/** The open archive. */
|
||||
readonly archive: MpqArchive
|
||||
}
|
||||
|
||||
/** A member resolved to the archive that will serve it. */
|
||||
export interface MountedFile {
|
||||
/** The archive that won the lookup. */
|
||||
readonly entry: MountEntry
|
||||
/** The member within it. */
|
||||
readonly file: MpqFile
|
||||
}
|
||||
|
||||
/** A stack of archives searched last-to-first. */
|
||||
export class MountedArchives {
|
||||
private readonly entries: MountEntry[] = []
|
||||
/** Cached union of every archive's name list, invalidated on `add`. */
|
||||
private names: string[] | null = null
|
||||
|
||||
/**
|
||||
* Mount an archive on top of the stack.
|
||||
*
|
||||
* @param label - identity for diagnostics.
|
||||
* @param archive - the open archive.
|
||||
*/
|
||||
add(label: string, archive: MpqArchive): void {
|
||||
this.entries.push({ label, archive })
|
||||
this.names = null
|
||||
}
|
||||
|
||||
/** The mounted archives, in load order. */
|
||||
get mounted(): readonly MountEntry[] {
|
||||
return this.entries
|
||||
}
|
||||
|
||||
/** How many archives are mounted. */
|
||||
get size(): number {
|
||||
return this.entries.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a member, preferring the most recently mounted archive.
|
||||
*
|
||||
* @param name - member name, either separator.
|
||||
* @returns the winning archive and member, or undefined.
|
||||
*/
|
||||
find(name: string): MountedFile | undefined {
|
||||
for (let index = this.entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = this.entries[index]!
|
||||
const file = entry.archive.find(name)
|
||||
if (file !== undefined) return { entry, file }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any mounted archive has a member.
|
||||
*
|
||||
* @param name - member name.
|
||||
* @returns true when present.
|
||||
*/
|
||||
has(name: string): boolean {
|
||||
return this.find(name) !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and decode a member.
|
||||
*
|
||||
* @param name - member name.
|
||||
* @returns the decoded bytes.
|
||||
*/
|
||||
async read(name: string): Promise<Uint8Array> {
|
||||
const found = this.find(name)
|
||||
if (found === undefined) throw new Error(`mounted archives have no member ${name}`)
|
||||
return found.entry.archive.read(found.file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every member name across the stack, deduplicated.
|
||||
*
|
||||
* A name is only listed once: with overlay semantics the archive that wins the
|
||||
* lookup is the one that matters, and duplicate entries would make pickers and
|
||||
* listings lie about it.
|
||||
*
|
||||
* @returns the name list, sorted.
|
||||
*/
|
||||
async listFiles(): Promise<string[]> {
|
||||
if (this.names !== null) return this.names
|
||||
const seen = new Set<string>()
|
||||
for (const entry of this.entries) {
|
||||
let names: readonly string[]
|
||||
try {
|
||||
names = await entry.archive.listFiles()
|
||||
} catch {
|
||||
// An archive whose (listfile) cannot be decoded contributes no names;
|
||||
// its members are still reachable by explicit name.
|
||||
continue
|
||||
}
|
||||
for (const name of names) seen.add(name)
|
||||
}
|
||||
this.names = [...seen].sort()
|
||||
return this.names
|
||||
}
|
||||
|
||||
/**
|
||||
* A description of the stack, for HUDs and reports.
|
||||
*
|
||||
* @returns one line per archive plus a total.
|
||||
*/
|
||||
describe(): string[] {
|
||||
const lines = this.entries.map(entry => `${entry.label}: ${String(entry.archive.files().length)} block slots`)
|
||||
lines.push(`total: ${String(this.entries.length)} archives`)
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Random-access byte sources for archive reading.
|
||||
*
|
||||
* The engine never loads a whole MPQ into memory: a dragged-in `File` (or an
|
||||
* HTTP range endpoint) is read on demand, so multi-hundred-megabyte archives
|
||||
* stay out of the JS heap and only the sectors actually used are touched.
|
||||
*/
|
||||
|
||||
/** Random-access read port consumed by {@link MpqArchive}. */
|
||||
export interface MpqSource {
|
||||
/** Total size in bytes. */
|
||||
readonly size: number
|
||||
/** Human-readable identity, for diagnostics and error messages. */
|
||||
readonly label: string
|
||||
/**
|
||||
* Read an exact byte range.
|
||||
*
|
||||
* @param offset - absolute byte offset.
|
||||
* @param length - number of bytes; the result is always exactly this long.
|
||||
* @returns the requested bytes.
|
||||
*/
|
||||
read(offset: number, length: number): Promise<Uint8Array>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a source over an in-memory buffer (tests, small archives, Node CLI).
|
||||
*
|
||||
* @param data - the archive bytes.
|
||||
* @param label - identity shown in diagnostics.
|
||||
* @returns the source.
|
||||
*/
|
||||
export function memorySource(data: Uint8Array, label = 'memory'): MpqSource {
|
||||
return {
|
||||
size: data.byteLength,
|
||||
label,
|
||||
read: async (offset, length) => {
|
||||
if (offset < 0 || offset + length > data.byteLength) {
|
||||
throw new RangeError(`${label}: read ${String(offset)}+${String(length)} beyond ${String(data.byteLength)}`)
|
||||
}
|
||||
return data.subarray(offset, offset + length)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a source over a browser `Blob`/`File` (drag & drop, `<input type=file>`,
|
||||
* OPFS handles all expose a Blob).
|
||||
*
|
||||
* @param blob - the blob to read.
|
||||
* @param label - identity shown in diagnostics.
|
||||
* @returns the source.
|
||||
*/
|
||||
export function blobSource(blob: Blob, label = blob instanceof File ? blob.name : 'blob'): MpqSource {
|
||||
return {
|
||||
size: blob.size,
|
||||
label,
|
||||
read: async (offset, length) => {
|
||||
if (offset < 0 || offset + length > blob.size) {
|
||||
throw new RangeError(`${label}: read ${String(offset)}+${String(length)} beyond ${String(blob.size)}`)
|
||||
}
|
||||
return new Uint8Array(await blob.slice(offset, offset + length).arrayBuffer())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bounded view over another source, so per-file reads stay inside one file's
|
||||
* extent (a corrupt offset table then fails loudly instead of reading into a
|
||||
* neighbour).
|
||||
*
|
||||
* @param source - the underlying source.
|
||||
* @param start - window start offset.
|
||||
* @param length - window length.
|
||||
* @returns the windowed source.
|
||||
*/
|
||||
export function windowSource(source: MpqSource, start: number, length: number): MpqSource {
|
||||
return {
|
||||
size: length,
|
||||
label: source.label,
|
||||
read: (offset, count) => source.read(start + offset, count),
|
||||
}
|
||||
}
|
||||
|
||||
/** Bytes to probe with when deciding whether a server honours `Range`. */
|
||||
const RANGE_PROBE_BYTES = 32
|
||||
|
||||
/**
|
||||
* Build a source over HTTP, reading only the byte ranges asked for.
|
||||
*
|
||||
* A Diablo II archive stack is ~1.9 GB, so downloading it into a browser tab is
|
||||
* not an option; the reader's random-access design exists precisely so a page
|
||||
* can pull the hash table, the block table and the sectors it needs. That
|
||||
* depends on the server answering `Range` requests with 206, which is probed
|
||||
* once up front:
|
||||
*
|
||||
* - **206**: every read becomes a ranged `fetch`.
|
||||
* - **200**: the server ignored the range and started sending the whole file.
|
||||
* Rather than silently mis-reading (the response body would be the file's
|
||||
* *start*, not the requested offset), the source refuses to be built and the
|
||||
* caller reports it. Serving the samples directory with `npm run dev` does
|
||||
* support ranges, so this is a real fallback, not the expected path.
|
||||
*
|
||||
* @param url - archive URL.
|
||||
* @param label - identity for diagnostics.
|
||||
* @returns the source.
|
||||
*/
|
||||
export async function httpRangeSource(url: string, label = url): Promise<MpqSource> {
|
||||
const probe = await fetch(url, { headers: { Range: `bytes=0-${String(RANGE_PROBE_BYTES - 1)}` } })
|
||||
if (probe.status !== 206) {
|
||||
const type = probe.headers.get('content-type') ?? 'unknown'
|
||||
throw new Error(
|
||||
`${label}: server answered ${String(probe.status)} (${type}) instead of 206 — `
|
||||
+ 'range requests are required to read an archive this large',
|
||||
)
|
||||
}
|
||||
const contentRange = probe.headers.get('content-range') ?? ''
|
||||
const total = Number(/\/(\d+)$/.exec(contentRange)?.[1] ?? '0')
|
||||
probe.body?.cancel().catch(() => {})
|
||||
if (!Number.isFinite(total) || total <= 0) {
|
||||
throw new Error(`${label}: server sent no usable Content-Range total (${contentRange})`)
|
||||
}
|
||||
return {
|
||||
size: total,
|
||||
label,
|
||||
read: async (offset, length) => {
|
||||
if (offset < 0 || offset + length > total) {
|
||||
throw new RangeError(`${label}: read ${String(offset)}+${String(length)} beyond ${String(total)}`)
|
||||
}
|
||||
const response = await fetch(url, { headers: { Range: `bytes=${String(offset)}-${String(offset + length - 1)}` } })
|
||||
if (response.status !== 206 && response.status !== 200) {
|
||||
throw new Error(`${label}: range read failed with HTTP ${String(response.status)}`)
|
||||
}
|
||||
const buffer = new Uint8Array(await response.arrayBuffer())
|
||||
if (buffer.byteLength !== length) {
|
||||
throw new Error(`${label}: asked for ${String(length)} bytes at ${String(offset)}, got ${String(buffer.byteLength)}`)
|
||||
}
|
||||
return buffer
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* Deterministic lockstep session.
|
||||
*
|
||||
* Multiplayer in Diablo II is not server-authoritative: every peer simulates the
|
||||
* whole world and they only exchange *inputs*. That only works if the simulation
|
||||
* is a pure function of (previous state, this tick's inputs) — which is why the
|
||||
* random number generator has been seeded and explicit since the item system, and
|
||||
* why nothing in the simulation reads a clock.
|
||||
*
|
||||
* This module owns the three things that make lockstep work:
|
||||
*
|
||||
* 1. **A tick runs only when every peer's input for it has arrived.** A peer that
|
||||
* guesses, or runs ahead on partial input, is a peer that diverges. Waiting is
|
||||
* the correct behaviour, so {@link LockstepSession.step} reports `waiting`
|
||||
* rather than advancing.
|
||||
* 2. **Input delay.** Real networks deliver late, so each tick's inputs are due
|
||||
* `inputDelayTicks` ahead of the tick that consumes them — the standard way to
|
||||
* trade a little response latency for never stalling.
|
||||
* 3. **Per-tick state hashes.** Peers exchange hashes and compare them; the first
|
||||
* tick whose hash differs is a desync, and knowing *which* tick diverged is
|
||||
* what makes the bug findable at all.
|
||||
*
|
||||
* The transport is deliberately an interface, not an implementation: the tests
|
||||
* drive it in memory, and a browser build can back it with a WebSocket or an
|
||||
* RTCDataChannel without this file changing.
|
||||
*/
|
||||
|
||||
/** One tick's input from one peer. */
|
||||
export interface InputFrame {
|
||||
/** Tick the frame is for. */
|
||||
readonly tick: number
|
||||
/** Movement request, screen axes. */
|
||||
readonly movement: { readonly x: number; readonly y: number }
|
||||
/** Attack control. */
|
||||
readonly attack: boolean
|
||||
/** Pickup control. */
|
||||
readonly pickup: boolean
|
||||
/** Talk control. */
|
||||
readonly talk: boolean
|
||||
/** Selected skill slot. */
|
||||
readonly skill: number
|
||||
}
|
||||
|
||||
/** A peer's claim about a tick's resulting state. */
|
||||
export interface StateHash {
|
||||
/** The tick the hash is for. */
|
||||
readonly tick: number
|
||||
/** The peer's state digest for that tick. */
|
||||
readonly hash: number
|
||||
}
|
||||
|
||||
/** Session configuration. */
|
||||
export interface LockstepOptions {
|
||||
/** How many peers must supply input. */
|
||||
readonly peers: number
|
||||
/** Ticks of input delay, i.e. the latency budget. */
|
||||
readonly inputDelayTicks: number
|
||||
/** How many past hashes to keep for comparison. */
|
||||
readonly historyTicks?: number
|
||||
}
|
||||
|
||||
/** What one call to {@link LockstepSession.step} did. */
|
||||
export type StepOutcome =
|
||||
| { readonly kind: 'stepped'; readonly tick: number; readonly hash: number }
|
||||
| { readonly kind: 'waiting'; readonly tick: number; readonly missing: readonly number[] }
|
||||
| { readonly kind: 'desync'; readonly tick: number; readonly local: number; readonly remote: number }
|
||||
|
||||
/** A desync report. */
|
||||
export interface DesyncReport {
|
||||
/** The first tick whose hashes disagree. */
|
||||
readonly tick: number
|
||||
/** The local hash. */
|
||||
readonly local: number
|
||||
/** The remote hash. */
|
||||
readonly remote: number
|
||||
}
|
||||
|
||||
/** The simulation a session drives. */
|
||||
export interface LockstepSimulation {
|
||||
/**
|
||||
* Advance the world by exactly one tick.
|
||||
*
|
||||
* Must be a pure function of the previous state and these inputs: no clocks, no
|
||||
* unseeded randomness, no host queries.
|
||||
*
|
||||
* @param inputs - one frame per peer, in peer order.
|
||||
*/
|
||||
advance: (inputs: readonly InputFrame[]) => void
|
||||
/**
|
||||
* Digest the world's state.
|
||||
*
|
||||
* Must cover everything peers could disagree about; a field left out is a
|
||||
* divergence that goes unnoticed until it affects something visible.
|
||||
*
|
||||
* @returns a 32-bit digest.
|
||||
*/
|
||||
hash: () => number
|
||||
}
|
||||
|
||||
/** Number of past hashes kept for late comparisons. */
|
||||
const DEFAULT_HISTORY = 64
|
||||
|
||||
/**
|
||||
* One lockstep session: input inbox, tick clock and hash history.
|
||||
*/
|
||||
export class LockstepSession {
|
||||
private readonly options: LockstepOptions
|
||||
private readonly simulation: LockstepSimulation
|
||||
private readonly inputs = new Map<number, Map<number, InputFrame>>()
|
||||
private readonly hashes: StateHash[] = []
|
||||
private currentTick = 0
|
||||
private stallTicks = 0
|
||||
private desync: DesyncReport | null = null
|
||||
|
||||
/**
|
||||
* @param options - session configuration.
|
||||
* @param simulation - the simulation to drive.
|
||||
*/
|
||||
constructor(options: LockstepOptions, simulation: LockstepSimulation) {
|
||||
this.options = options
|
||||
this.simulation = simulation
|
||||
for (let peer = 0; peer < options.peers; peer += 1) this.inputs.set(peer, new Map())
|
||||
}
|
||||
|
||||
/** The next tick that has not run yet. */
|
||||
get tick(): number {
|
||||
return this.currentTick
|
||||
}
|
||||
|
||||
/** How many consecutive calls could not advance for want of input. */
|
||||
get stalls(): number {
|
||||
return this.stallTicks
|
||||
}
|
||||
|
||||
/** The first desync seen, if any. */
|
||||
get desyncReport(): DesyncReport | null {
|
||||
return this.desync
|
||||
}
|
||||
|
||||
/** Recent per-tick hashes, oldest first. */
|
||||
get history(): readonly StateHash[] {
|
||||
return this.hashes
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an input frame from a peer.
|
||||
*
|
||||
* Frames for ticks that already ran are dropped: replaying them would change
|
||||
* history, and a peer that sends stale input is late, not authoritative.
|
||||
*
|
||||
* @param peer - the peer's index.
|
||||
* @param frame - the frame.
|
||||
* @returns true when the frame was accepted.
|
||||
*/
|
||||
submit(peer: number, frame: InputFrame): boolean {
|
||||
if (!this.inputs.has(peer)) throw new Error(`unknown peer ${String(peer)}`)
|
||||
if (frame.tick < this.currentTick) return false
|
||||
this.inputs.get(peer)!.set(frame.tick, frame)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one tick if every peer's input for it is present.
|
||||
*
|
||||
* @returns what happened.
|
||||
*/
|
||||
step(): StepOutcome {
|
||||
const tick = this.currentTick
|
||||
const missing: number[] = []
|
||||
const frames: InputFrame[] = []
|
||||
for (let peer = 0; peer < this.options.peers; peer += 1) {
|
||||
const frame = this.inputs.get(peer)?.get(tick)
|
||||
if (frame === undefined) { missing.push(peer); continue }
|
||||
frames.push(frame)
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
this.stallTicks += 1
|
||||
return { kind: 'waiting', tick, missing }
|
||||
}
|
||||
|
||||
this.simulation.advance(frames)
|
||||
const hash = this.simulation.hash()
|
||||
this.hashes.push({ tick, hash })
|
||||
if (this.hashes.length > (this.options.historyTicks ?? DEFAULT_HISTORY)) this.hashes.shift()
|
||||
for (const peer of this.inputs.keys()) this.inputs.get(peer)!.delete(tick)
|
||||
this.currentTick += 1
|
||||
this.stallTicks = 0
|
||||
return { kind: 'stepped', tick, hash }
|
||||
}
|
||||
|
||||
/**
|
||||
* The input delay a peer should target when submitting.
|
||||
*
|
||||
* @returns the tick a frame submitted now should be for.
|
||||
*/
|
||||
get inputDueTick(): number {
|
||||
return this.currentTick + this.options.inputDelayTicks
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a peer's hash for a tick against the local one.
|
||||
*
|
||||
* @param remote - the peer's claim.
|
||||
* @returns the desync report when they disagree, null when they agree or the
|
||||
* tick is not in local history.
|
||||
*/
|
||||
compare(remote: StateHash): DesyncReport | null {
|
||||
const local = this.hashes.find(entry => entry.tick === remote.tick)
|
||||
if (local === undefined) return null
|
||||
if (local.hash === remote.hash) return null
|
||||
this.desync ??= { tick: remote.tick, local: local.hash, remote: remote.hash }
|
||||
return this.desync
|
||||
}
|
||||
|
||||
/**
|
||||
* Digest a byte string into the same 32-bit space as the state hashes, so a
|
||||
* caller can hash whatever it likes consistently.
|
||||
*
|
||||
* @param text - the input.
|
||||
* @returns the digest.
|
||||
*/
|
||||
static digest(text: string): number {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
hash ^= text.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619) >>> 0
|
||||
}
|
||||
return hash >>> 0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,522 @@
|
|||
/**
|
||||
* Networked lockstep session: a {@link LockstepSession} wired to a {@link Transport}.
|
||||
*
|
||||
* The lockstep core knows nothing about sockets, and the transport knows nothing
|
||||
* about ticks; this file is the seam. Each call to {@link NetplaySession.pump}
|
||||
* does exactly four things, in order:
|
||||
*
|
||||
* 1. **Drain** everything the peer sent into the lockstep inbox.
|
||||
* 2. **Send** our input for the tick that is due now (`tick + inputDelay`), and a
|
||||
* state hash for every tick we have finished since the last hash we sent.
|
||||
* 3. **Run** one tick, if all inputs are present.
|
||||
* 4. **Announce** our own hash when a tick completes, and compare any peer hash
|
||||
* that arrived for that tick.
|
||||
*
|
||||
* A peer that goes quiet is not a divergence: the session reports `waiting` and
|
||||
* holds the world still. Only a hash mismatch is a divergence, and the first
|
||||
* mismatching tick is recorded with both digests. A peer that never arrives at
|
||||
* all is a *timeout*, kept separate from a desync — different problem, different
|
||||
* response (drop the peer versus stop and resync).
|
||||
*/
|
||||
import { LockstepSession, type DesyncReport, type InputFrame, type LockstepOptions, type LockstepSimulation, type StateHash, type StepOutcome } from './lockstep.ts'
|
||||
import { decodeMessage, encodeMessage, NO_ACK, ProtocolError, type NetMessage } from './protocol.ts'
|
||||
import type { Transport } from './transport.ts'
|
||||
|
||||
/** Pumps between repeated handshake attempts, until the peer answers. */
|
||||
const HANDSHAKE_RETRY_PUMPS = 25
|
||||
|
||||
/** Session configuration beyond the lockstep core's own options. */
|
||||
export interface NetplayOptions extends LockstepOptions {
|
||||
/** This peer's index. */
|
||||
readonly peer: number
|
||||
/** World seed, exchanged in the handshake. */
|
||||
readonly seed: number
|
||||
/** Send a state hash every this many ticks. Defaults to 5. */
|
||||
readonly hashInterval?: number
|
||||
/**
|
||||
* Consecutive ticks that may pass without *any* message from the peer before it
|
||||
* is declared lost. Defaults to 250, i.e. ten seconds at 25 Hz.
|
||||
*
|
||||
* Counted in attempts to run a tick rather than in ticks that ran: a stalled
|
||||
* world does not advance its own clock, so a tick-based deadline would never
|
||||
* expire in exactly the case it exists for.
|
||||
*/
|
||||
readonly timeoutTicks?: number
|
||||
}
|
||||
|
||||
/** Running counters, for the HUD and for tests. */
|
||||
export interface NetplayStats {
|
||||
/** This peer's index. */
|
||||
readonly peer: number
|
||||
/** Messages sent. */
|
||||
readonly sent: number
|
||||
/** Messages received and decoded. */
|
||||
readonly received: number
|
||||
/** Messages that arrived but failed to decode. */
|
||||
readonly malformed: number
|
||||
/** Ticks that could not run because the peer's input had not arrived. */
|
||||
readonly waiting: number
|
||||
/** Ticks that ran. */
|
||||
readonly stepped: number
|
||||
/** Peer hashes received and compared. */
|
||||
readonly hashesCompared: number
|
||||
/** Peer hashes that agreed. */
|
||||
readonly hashesAgreed: number
|
||||
/**
|
||||
* Peer hashes for ticks no longer in local history.
|
||||
*
|
||||
* Counted separately because such a hash *cannot* be checked: reporting it as an
|
||||
* agreement would be claiming verification that did not happen.
|
||||
*/
|
||||
readonly hashesIgnored: number
|
||||
/** Whether the handshake completed. */
|
||||
readonly handshaked: boolean
|
||||
/** Handshake messages sent, counting retries and acknowledgements. */
|
||||
readonly helloSent: number
|
||||
/** Whether every peer has heard this peer's hello. */
|
||||
readonly acknowledged: boolean
|
||||
/** How many of the other peers this peer has heard from. */
|
||||
readonly peersHeard: number
|
||||
/** How many peers are expected, this one excluded. */
|
||||
readonly peersExpected: number
|
||||
/** Whether enough is known about every peer to run the game. */
|
||||
readonly ready: boolean
|
||||
/** Whether the peer has timed out. */
|
||||
readonly timedOut: boolean
|
||||
/** Whether the pipe is open. */
|
||||
readonly connected: boolean
|
||||
}
|
||||
|
||||
/** What a session knows about one other peer. */
|
||||
interface PeerLink {
|
||||
/** Whether a hello has arrived from it. */
|
||||
heard: boolean
|
||||
/** The world seed it announced, or -1. */
|
||||
seed: number
|
||||
/** Whether it has acknowledged this peer's hello. */
|
||||
ackedBy: boolean
|
||||
/** Whether this peer has acknowledged its hello. */
|
||||
ackSent: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* One peer's end of a networked game.
|
||||
*/
|
||||
export class NetplaySession {
|
||||
/** The lockstep core, exposed so callers can read tick, history and desyncs. */
|
||||
readonly lockstep: LockstepSession
|
||||
private readonly options: NetplayOptions
|
||||
private readonly transport: Transport
|
||||
private localIntent: Omit<InputFrame, 'tick'> = { movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }
|
||||
private sent = 0
|
||||
private received = 0
|
||||
private malformed = 0
|
||||
private waiting = 0
|
||||
private stepped = 0
|
||||
private hashesCompared = 0
|
||||
private hashesAgreed = 0
|
||||
private hashesIgnored = 0
|
||||
private lastHashSent = -1
|
||||
/** Highest tick this peer has queued input for; the pipe's send cursor. */
|
||||
private nextSendTick = 0
|
||||
/**
|
||||
* Peer hashes waiting for the local world to reach their tick.
|
||||
*
|
||||
* A hash arrives the moment the peer computes it, which is usually a tick or two
|
||||
* *before* this peer has run that tick — and a hash for a tick that has not run
|
||||
* cannot be checked. Dropping those would leave the lagging peer verifying
|
||||
* almost nothing, which is the opposite of what the check is for. They are held
|
||||
* here instead and checked the moment local history catches up.
|
||||
*/
|
||||
private readonly pendingRemoteHashes = new Map<number, number>()
|
||||
/** Pumps since the peer last said anything. */
|
||||
private silentTicks = 0
|
||||
private handshaked = false
|
||||
private handshakeOwed = false
|
||||
private helloSent = 0
|
||||
private pumpsSinceHello = 0
|
||||
private timedOut = false
|
||||
private mismatchedSeed: number | null = null
|
||||
private closed = false
|
||||
/**
|
||||
* What is known about each other peer.
|
||||
*
|
||||
* Per peer, because a session of four cannot keep one flag for "the peer": it
|
||||
* has to remember which of them has been heard from, whose world seed has been
|
||||
* checked, and whose hello has been acknowledged — otherwise one late joiner
|
||||
* either blocks the game silently or is never told the seed.
|
||||
*/
|
||||
private readonly links = new Map<number, PeerLink>()
|
||||
|
||||
/**
|
||||
* @param options - session configuration.
|
||||
* @param simulation - the simulation to drive.
|
||||
* @param transport - the byte pipe to the peer.
|
||||
*/
|
||||
constructor(options: NetplayOptions, simulation: LockstepSimulation, transport: Transport) {
|
||||
this.options = options
|
||||
this.transport = transport
|
||||
this.lockstep = new LockstepSession(options, simulation)
|
||||
transport.onMessage(data => { this.receive(data) })
|
||||
transport.onClose(() => { this.closed = true })
|
||||
}
|
||||
|
||||
/** This peer's index. */
|
||||
get peer(): number {
|
||||
return this.options.peer
|
||||
}
|
||||
|
||||
/** The lowest-numbered other peer's announced seed, or -1 before the handshake. */
|
||||
get remoteSeed(): number {
|
||||
return this.links.get(this.otherPeers()[0] ?? -1)?.seed ?? -1
|
||||
}
|
||||
|
||||
/** Every other peer's announced seed, by peer index. */
|
||||
get peerSeeds(): readonly { readonly peer: number; readonly seed: number }[] {
|
||||
return this.otherPeers().map(peer => ({ peer, seed: this.links.get(peer)?.seed ?? -1 }))
|
||||
}
|
||||
|
||||
/** The peer indices this session expects to hear from, this one excluded. */
|
||||
private otherPeers(): number[] {
|
||||
const peers: number[] = []
|
||||
for (let peer = 0; peer < this.options.peers; peer += 1) {
|
||||
if (peer !== this.options.peer) peers.push(peer)
|
||||
}
|
||||
return peers
|
||||
}
|
||||
|
||||
/**
|
||||
* The link record for a peer, created on first use.
|
||||
*
|
||||
* @param peer - the peer index.
|
||||
* @returns its record.
|
||||
*/
|
||||
private linkOf(peer: number): PeerLink {
|
||||
const existing = this.links.get(peer)
|
||||
if (existing !== undefined) return existing
|
||||
const created: PeerLink = { heard: false, seed: -1, ackedBy: false, ackSent: false }
|
||||
this.links.set(peer, created)
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* The peer's world seed when it differs from this one, otherwise null.
|
||||
*
|
||||
* Two peers that disagree about the seed build different worlds; saying so at
|
||||
* the handshake is far better than letting it surface later as a desync.
|
||||
*/
|
||||
get seedMismatch(): number | null {
|
||||
return this.mismatchedSeed
|
||||
}
|
||||
|
||||
/** The first desync seen, if any. */
|
||||
get desyncReport(): DesyncReport | null {
|
||||
return this.lockstep.desyncReport
|
||||
}
|
||||
|
||||
/** Counters for the HUD and for tests. */
|
||||
get stats(): NetplayStats {
|
||||
return {
|
||||
peer: this.options.peer,
|
||||
sent: this.sent,
|
||||
received: this.received,
|
||||
malformed: this.malformed,
|
||||
waiting: this.waiting,
|
||||
stepped: this.stepped,
|
||||
hashesCompared: this.hashesCompared,
|
||||
hashesAgreed: this.hashesAgreed,
|
||||
hashesIgnored: this.hashesIgnored,
|
||||
handshaked: this.handshaked,
|
||||
helloSent: this.helloSent,
|
||||
acknowledged: this.acknowledged,
|
||||
peersHeard: this.peersHeard,
|
||||
peersExpected: this.otherPeers().length,
|
||||
ready: this.ready,
|
||||
timedOut: this.timedOut,
|
||||
connected: this.transport.open && !this.closed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Introduce this peer to the other end.
|
||||
*
|
||||
* The message may not go out immediately: a session is usually created in the
|
||||
* same breath as its socket, which is still connecting. The hello is therefore
|
||||
* owed rather than sent, and {@link pump} delivers it as soon as the pipe opens
|
||||
* — repeatedly, until the peer answers.
|
||||
*/
|
||||
start(): void {
|
||||
this.handshakeOwed = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the game may run yet.
|
||||
*
|
||||
* A session that was told to network waits for the handshake. Without this gate
|
||||
* a peer happily sends its first inputs into a relay that has nobody to forward
|
||||
* them to — the sender cannot tell — and then waits forever for inputs it will
|
||||
* never receive, because its own first frames were dropped in the void. The
|
||||
* handshake is the only way to know the other end is listening.
|
||||
*/
|
||||
private get ready(): boolean {
|
||||
if (!this.handshakeOwed) return true
|
||||
return this.otherPeers().every(peer => this.links.get(peer)?.heard === true)
|
||||
}
|
||||
|
||||
/** How many other peers this session has heard from. */
|
||||
private get peersHeard(): number {
|
||||
return this.otherPeers().filter(peer => this.links.get(peer)?.heard === true).length
|
||||
}
|
||||
|
||||
/** Whether every other peer has heard this peer's hello. */
|
||||
private get acknowledged(): boolean {
|
||||
return this.otherPeers().every(peer => this.links.get(peer)?.ackedBy === true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the hello when it is owed and the pipe is open, retrying until the peer
|
||||
* answers so that a handshake lost to a moment of disconnection still lands.
|
||||
*/
|
||||
private ensureHandshake(): void {
|
||||
if (!this.handshakeOwed || !this.transport.open || this.acknowledged) return
|
||||
// The hello is repeated until every peer *acknowledges* it, not merely until
|
||||
// peers are heard from. "I heard you" and "you heard me" are different facts,
|
||||
// and a session that only tracks the first goes on waiting for input from
|
||||
// someone who is waiting for its hello — which is exactly the deadlock the
|
||||
// acknowledgement exists to prevent.
|
||||
if (this.helloSent > 0 && this.pumpsSinceHello < HANDSHAKE_RETRY_PUMPS) {
|
||||
this.pumpsSinceHello += 1
|
||||
return
|
||||
}
|
||||
this.pumpsSinceHello = 0
|
||||
this.sendHello(NO_ACK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a hello.
|
||||
*
|
||||
* @param ackTo - the peer being acknowledged, or {@link NO_ACK} for a plain
|
||||
* introduction. With one broadcast pipe for every peer, an unaddressed
|
||||
* acknowledgement would be read as one by peers it was never meant for.
|
||||
*/
|
||||
private sendHello(ackTo: number): void {
|
||||
this.helloSent += 1
|
||||
this.send({ kind: 'hello', peer: this.options.peer, peers: this.options.peers, seed: this.options.seed, ackTo })
|
||||
}
|
||||
|
||||
/** Say goodbye before closing the pipe. */
|
||||
leave(): void {
|
||||
this.send({ kind: 'bye', peer: this.options.peer })
|
||||
this.closed = true
|
||||
this.transport.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage this tick's local input. Applied on the next {@link pump}.
|
||||
*
|
||||
* @param intent - the local controls.
|
||||
*/
|
||||
setIntent(intent: Partial<Omit<InputFrame, 'tick'>>): void {
|
||||
this.localIntent = { ...this.localIntent, ...intent }
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the session by one tick.
|
||||
*
|
||||
* @returns what happened: `stepped` with the local hash, `waiting` for the peer,
|
||||
* or `desync`, in which case {@link desyncReport} says which tick diverged.
|
||||
*/
|
||||
pump(): StepOutcome {
|
||||
this.ensureHandshake()
|
||||
if (this.ready) this.sendLocalInput()
|
||||
const outcome = this.lockstep.step()
|
||||
if (outcome.kind === 'waiting') this.waiting += 1
|
||||
if (outcome.kind === 'stepped') {
|
||||
this.stepped += 1
|
||||
this.sendHash(outcome.tick, outcome.hash)
|
||||
}
|
||||
// Counted here and cleared in `receive`, not cleared here: a transport
|
||||
// delivers from its own task, so a message almost never arrives *during* the
|
||||
// pump that follows it. Only "how many pumps since the last message" is a
|
||||
// question with an answer.
|
||||
this.drainRemoteHashes()
|
||||
this.silentTicks += 1
|
||||
this.checkTimeout()
|
||||
return outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a peer hash against local history.
|
||||
*
|
||||
* @param tick - the tick.
|
||||
* @param hash - the peer's digest.
|
||||
* @returns the desync report when they disagree.
|
||||
*/
|
||||
compareRemoteHash(tick: number, hash: number): DesyncReport | null {
|
||||
const remote: StateHash = { tick, hash }
|
||||
if (!this.lockstep.history.some(entry => entry.tick === tick)) {
|
||||
// The tick has aged out of history: this is not agreement, it is an
|
||||
// unanswerable question, and it is counted as such.
|
||||
this.hashesIgnored += 1
|
||||
return null
|
||||
}
|
||||
this.hashesCompared += 1
|
||||
const report = this.lockstep.compare(remote)
|
||||
if (report === null) this.hashesAgreed += 1
|
||||
return report
|
||||
}
|
||||
|
||||
/**
|
||||
* Check every buffered peer hash whose tick this peer has now run.
|
||||
*
|
||||
* A hash whose tick has already fallen out of local history can never be
|
||||
* checked, and is counted as ignored rather than as agreement: claiming to have
|
||||
* verified something that was never compared would make the desync detector
|
||||
* look healthier than it is.
|
||||
*/
|
||||
private drainRemoteHashes(): void {
|
||||
if (this.pendingRemoteHashes.size === 0) return
|
||||
const window = this.options.historyTicks ?? 64
|
||||
const oldest = this.lockstep.tick - window
|
||||
for (const [tick, hash] of [...this.pendingRemoteHashes]) {
|
||||
if (tick < oldest) {
|
||||
this.pendingRemoteHashes.delete(tick)
|
||||
this.hashesIgnored += 1
|
||||
continue
|
||||
}
|
||||
if (tick >= this.lockstep.tick) continue
|
||||
this.pendingRemoteHashes.delete(tick)
|
||||
this.hashesCompared += 1
|
||||
if (this.lockstep.compare({ tick, hash }) === null) this.hashesAgreed += 1
|
||||
}
|
||||
}
|
||||
|
||||
/** Local tick, for outbound timestamps. */
|
||||
private get now(): number {
|
||||
return this.lockstep.tick
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode and send a message.
|
||||
*
|
||||
* @param message - the message.
|
||||
*/
|
||||
private send(message: NetMessage): void {
|
||||
if (!this.transport.open) return
|
||||
this.transport.send(encodeMessage(message))
|
||||
this.sent += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue this peer's input for every tick from the send cursor up to the tick
|
||||
* that is due now.
|
||||
*
|
||||
* Normally that is exactly one frame per tick. The range matters at the start
|
||||
* and after a reconnect: with `inputDelayTicks = D` the first due tick is `D`,
|
||||
* so ticks `0..D-1` would never be supplied by anyone and the game would stall
|
||||
* before its first tick — a deadlock that looks like a network problem and is
|
||||
* not. Catching up from the cursor fills that warm-up window, and it also
|
||||
* repairs a pipe that was not open when those ticks came due.
|
||||
*
|
||||
* Frames for ticks that already ran are dropped by the core, and a frame
|
||||
* re-sent for a tick that has not run yet is idempotent, so over-sending is
|
||||
* safe.
|
||||
*/
|
||||
private sendLocalInput(): void {
|
||||
if (!this.transport.open || !this.ready) return
|
||||
const due = this.lockstep.inputDueTick
|
||||
for (let tick = this.nextSendTick; tick <= due; tick += 1) {
|
||||
const frame: InputFrame = { tick, ...this.localIntent }
|
||||
this.send({ kind: 'input', peer: this.options.peer, frame })
|
||||
// Our own input is authoritative locally and takes the same path as the
|
||||
// peer's: no shortcut, so a bug in submission affects both ends equally.
|
||||
this.lockstep.submit(this.options.peer, frame)
|
||||
}
|
||||
this.nextSendTick = Math.max(due + 1, this.lockstep.tick + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a state hash, if this tick is a hash checkpoint.
|
||||
*
|
||||
* @param tick - the tick that just ran.
|
||||
* @param hash - its digest.
|
||||
*/
|
||||
private sendHash(tick: number, hash: number): void {
|
||||
const interval = this.options.hashInterval ?? 5
|
||||
if (tick % interval !== 0 || tick === this.lastHashSent) return
|
||||
this.lastHashSent = tick
|
||||
this.send({ kind: 'hash', peer: this.options.peer, tick, hash })
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle one received message.
|
||||
*
|
||||
* A malformed message is counted and dropped rather than thrown: one bad packet
|
||||
* from a confused peer should not take the world down, and the counter makes it
|
||||
* visible.
|
||||
*
|
||||
* @param data - the raw bytes.
|
||||
*/
|
||||
private receive(data: Uint8Array): void {
|
||||
let message: NetMessage
|
||||
try {
|
||||
message = decodeMessage(data)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProtocolError)) throw error
|
||||
this.malformed += 1
|
||||
return
|
||||
}
|
||||
this.received += 1
|
||||
this.silentTicks = 0
|
||||
switch (message.kind) {
|
||||
case 'hello': {
|
||||
if (message.peer === this.options.peer || message.peer >= this.options.peers) {
|
||||
// A hello from ourselves, or from a peer index nobody can have: ignored
|
||||
// rather than counted, because it says the sender is confused about the
|
||||
// game it joined.
|
||||
this.malformed += 1
|
||||
break
|
||||
}
|
||||
const link = this.linkOf(message.peer)
|
||||
link.heard = true
|
||||
link.seed = message.seed
|
||||
// The seed is checked, not trusted: peers that disagree about it build
|
||||
// different worlds, and the honest response is to say so at once rather
|
||||
// than to discover it as a mysterious desync a second later.
|
||||
if (message.seed !== this.options.seed) this.mismatchedSeed = message.seed
|
||||
// An acknowledgement addressed to this peer means its own hello arrived.
|
||||
if (message.ackTo === this.options.peer) link.ackedBy = true
|
||||
// Every hello is answered until that peer's own acknowledgement arrives —
|
||||
// including an acknowledgement itself, because a peer that answered us
|
||||
// still needs to know we heard it.
|
||||
if (!link.ackSent) {
|
||||
link.ackSent = true
|
||||
this.sendHello(message.peer)
|
||||
}
|
||||
this.handshaked = this.ready
|
||||
break
|
||||
}
|
||||
case 'input':
|
||||
this.lockstep.submit(message.peer, message.frame)
|
||||
break
|
||||
case 'hash':
|
||||
this.pendingRemoteHashes.set(message.tick, message.hash)
|
||||
this.drainRemoteHashes()
|
||||
break
|
||||
case 'bye':
|
||||
this.closed = true
|
||||
break
|
||||
/* v8 ignore next -- the message union is closed above. */
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** Flag the peer as lost once it has been silent for too long. */
|
||||
private checkTimeout(): void {
|
||||
if (this.timedOut) return
|
||||
const limit = this.options.timeoutTicks ?? 250
|
||||
if (this.silentTicks < limit) return
|
||||
this.timedOut = true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Wire protocol for lockstep play.
|
||||
*
|
||||
* Peers exchange three things and nothing else: who they are, what they pressed,
|
||||
* and what state they ended up with. Everything the world contains is derived
|
||||
* locally by every peer, so the messages stay tiny — an input frame is a handful
|
||||
* of bytes, which is what makes 25 ticks per second over a normal connection
|
||||
* unremarkable.
|
||||
*
|
||||
* Encoding is explicit little-endian bytes rather than JSON: at 25 Hz per peer a
|
||||
* text format would spend most of its bandwidth on field names, and a
|
||||
* hand-written codec is also the one place where malformed input can be rejected
|
||||
* before it reaches the simulation. Movement is fixed-point (`×1000`) so a float
|
||||
* can never round differently on two machines — the same reason the simulation
|
||||
* clamps positions rather than trusting a peer's arithmetic.
|
||||
*/
|
||||
import type { InputFrame } from './lockstep.ts'
|
||||
|
||||
/** Message type tags, as they appear on the wire. */
|
||||
export const MESSAGE_TYPE = {
|
||||
/** Peer introduction: identity, peer count, world seed, acknowledgement flag. */
|
||||
hello: 1,
|
||||
/** One tick's input from one peer. */
|
||||
input: 2,
|
||||
/** One peer's state digest for a tick. */
|
||||
hash: 3,
|
||||
/** Peer leaving. */
|
||||
bye: 4,
|
||||
} as const
|
||||
|
||||
/** A decoded message. */
|
||||
export type NetMessage =
|
||||
| {
|
||||
readonly kind: 'hello'
|
||||
readonly peer: number
|
||||
readonly peers: number
|
||||
readonly seed: number
|
||||
/** The peer this hello acknowledges, or {@link NO_ACK} for a plain introduction. */
|
||||
readonly ackTo: number
|
||||
}
|
||||
| { readonly kind: 'input'; readonly peer: number; readonly frame: InputFrame }
|
||||
| { readonly kind: 'hash'; readonly peer: number; readonly tick: number; readonly hash: number }
|
||||
| { readonly kind: 'bye'; readonly peer: number }
|
||||
|
||||
/** Scaling used for movement components. */
|
||||
const MOVEMENT_SCALE = 1000
|
||||
/** Bytes of an `input` message: type, peer, tick, x, y, flags, skill. */
|
||||
const INPUT_BYTES = 1 + 1 + 4 + 2 + 2 + 1 + 1
|
||||
/** Bytes of a `hash` message. */
|
||||
const HASH_BYTES = 1 + 1 + 4 + 4
|
||||
/** Bytes of a `hello` message: type, peer, peers, seed, flags. */
|
||||
const HELLO_BYTES = 1 + 1 + 1 + 4 + 1
|
||||
/** Bytes of a `bye` message. */
|
||||
const BYE_BYTES = 1 + 1
|
||||
|
||||
/** Input frame flags byte. */
|
||||
const FLAG_ATTACK = 1
|
||||
const FLAG_PICKUP = 2
|
||||
const FLAG_TALK = 4
|
||||
|
||||
/**
|
||||
* `ackTo` value meaning "this hello is not an acknowledgement".
|
||||
*
|
||||
* A field rather than a flag byte: with more than two peers, "I heard you" has to
|
||||
* name *who* was heard, or a session of four peers cannot tell an acknowledgement
|
||||
* meant for it from one that merely passed by.
|
||||
*/
|
||||
export const NO_ACK = 0xff
|
||||
|
||||
/** Raised when a message cannot be decoded. */
|
||||
export class ProtocolError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ProtocolError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp and scale a movement component to fixed point.
|
||||
*
|
||||
* @param value - the component.
|
||||
* @returns the scaled integer.
|
||||
*/
|
||||
function toFixed(value: number): number {
|
||||
return Math.max(-32768, Math.min(32767, Math.round(value * MOVEMENT_SCALE)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode one message.
|
||||
*
|
||||
* @param message - the message.
|
||||
* @returns the bytes to send.
|
||||
*/
|
||||
export function encodeMessage(message: NetMessage): Uint8Array {
|
||||
switch (message.kind) {
|
||||
case 'hello': {
|
||||
const out = new Uint8Array(HELLO_BYTES)
|
||||
const view = new DataView(out.buffer)
|
||||
out[0] = MESSAGE_TYPE.hello
|
||||
out[1] = message.peer & 0xff
|
||||
out[2] = message.peers & 0xff
|
||||
view.setUint32(3, message.seed >>> 0, true)
|
||||
out[7] = message.ackTo & 0xff
|
||||
return out
|
||||
}
|
||||
case 'input': {
|
||||
const out = new Uint8Array(INPUT_BYTES)
|
||||
const view = new DataView(out.buffer)
|
||||
out[0] = MESSAGE_TYPE.input
|
||||
out[1] = message.peer & 0xff
|
||||
view.setUint32(2, message.frame.tick >>> 0, true)
|
||||
view.setInt16(6, toFixed(message.frame.movement.x), true)
|
||||
view.setInt16(8, toFixed(message.frame.movement.y), true)
|
||||
out[10] = (message.frame.attack ? FLAG_ATTACK : 0)
|
||||
| (message.frame.pickup ? FLAG_PICKUP : 0)
|
||||
| (message.frame.talk ? FLAG_TALK : 0)
|
||||
out[11] = message.frame.skill & 0xff
|
||||
return out
|
||||
}
|
||||
case 'hash': {
|
||||
const out = new Uint8Array(HASH_BYTES)
|
||||
const view = new DataView(out.buffer)
|
||||
out[0] = MESSAGE_TYPE.hash
|
||||
out[1] = message.peer & 0xff
|
||||
view.setUint32(2, message.tick >>> 0, true)
|
||||
view.setUint32(6, message.hash >>> 0, true)
|
||||
return out
|
||||
}
|
||||
case 'bye': {
|
||||
const out = new Uint8Array(BYE_BYTES)
|
||||
out[0] = MESSAGE_TYPE.bye
|
||||
out[1] = message.peer & 0xff
|
||||
return out
|
||||
}
|
||||
/* v8 ignore next -- the message union is closed above. */
|
||||
default: throw new ProtocolError('unknown message')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one message.
|
||||
*
|
||||
* Every field is range-checked: a peer sending nonsense should be rejected by the
|
||||
* protocol layer, not by the simulation several ticks later.
|
||||
*
|
||||
* @param data - the received bytes.
|
||||
* @returns the message.
|
||||
*/
|
||||
export function decodeMessage(data: Uint8Array): NetMessage {
|
||||
if (data.byteLength === 0) throw new ProtocolError('empty message')
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
const peer = data[1] ?? 0
|
||||
switch (data[0]) {
|
||||
case MESSAGE_TYPE.hello: {
|
||||
if (data.byteLength !== HELLO_BYTES) throw new ProtocolError(`hello has ${String(data.byteLength)} bytes, expected ${String(HELLO_BYTES)}`)
|
||||
const peers = data[2] ?? 0
|
||||
if (peers < 1 || peers > 8) throw new ProtocolError(`hello declares ${String(peers)} peers`)
|
||||
return { kind: 'hello', peer, peers, seed: view.getUint32(3, true), ackTo: data[7] ?? NO_ACK }
|
||||
}
|
||||
case MESSAGE_TYPE.input: {
|
||||
if (data.byteLength !== INPUT_BYTES) throw new ProtocolError(`input has ${String(data.byteLength)} bytes, expected ${String(INPUT_BYTES)}`)
|
||||
const flags = data[10] ?? 0
|
||||
return {
|
||||
kind: 'input',
|
||||
peer,
|
||||
frame: {
|
||||
tick: view.getUint32(2, true),
|
||||
movement: { x: view.getInt16(6, true) / MOVEMENT_SCALE, y: view.getInt16(8, true) / MOVEMENT_SCALE },
|
||||
attack: (flags & FLAG_ATTACK) !== 0,
|
||||
pickup: (flags & FLAG_PICKUP) !== 0,
|
||||
talk: (flags & FLAG_TALK) !== 0,
|
||||
skill: data[11] ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
case MESSAGE_TYPE.hash: {
|
||||
if (data.byteLength !== HASH_BYTES) throw new ProtocolError(`hash has ${String(data.byteLength)} bytes, expected ${String(HASH_BYTES)}`)
|
||||
return { kind: 'hash', peer, tick: view.getUint32(2, true), hash: view.getUint32(6, true) }
|
||||
}
|
||||
case MESSAGE_TYPE.bye: {
|
||||
if (data.byteLength !== BYE_BYTES) throw new ProtocolError(`bye has ${String(data.byteLength)} bytes, expected ${String(BYE_BYTES)}`)
|
||||
return { kind: 'bye', peer }
|
||||
}
|
||||
/* v8 ignore next -- unreachable for the checked tags. */
|
||||
default: throw new ProtocolError(`unknown message type ${String(data[0])}`)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue