[Bug] 部分地图阻挡关系有误:DT1 子格标志位的行序被上下翻转(另附门/物体完全不参与碰撞与交互) #4

Closed
opened 2026-09-14 05:09:19 +00:00 by troytt · 1 comment
Owner

现象

部分地图上"阻挡"和画面对不上:

  • 能穿过看起来是墙的地方(穿墙);
  • 反过来,在看起来是空地的地方被挡住;
  • 门口过不去(门永远关着)。

"部分地图"这个分布特征本身就是线索:阻挡整体还在,只是在每一格内部错位了。

初步排查

① 主因(高置信):DT1 的 25 个 sub-tile flag 在文件里是自下而上存的,本仓库按自上而下读 → 每格内的阻挡带被上下镜像

DT1 每张瓦片记录末尾有 25 个字节的子格碰撞标志。文件顺序的第 0..4 字节是这一格 5×5 足迹的最下面一行,两个独立实现互相印证:

  • OpenDiablo2 reference/opendiablo2/d2core/d2map/d2mapengine/map_tile.go:17:

    var subtileLookup = [5][5]int{
        {20, 21, 22, 23, 24},   // 房间子格 (x, 0) —— 最上面一行,读文件 20..24
        {15, 16, 17, 18, 19},
        {10, 11, 12, 13, 14},
        {5, 6, 7, 8, 9},
        {0, 1, 2, 3, 4},        // 最下面一行,读文件 0..4
    }
    
  • D2MOO reference/D2MOO/source/D2Common/src/D2Collision.cpp:93 sub_6FD411F0(1.10f 的 D2Common.0x6FD411F0):pTmp = &v5[5 * (nY - nCappedY + 4) - nX]; 配合循环里的 pTmp -= 5;,对房间第 r 行取文件第 (4-r) 行——同一个行序反转(D2CMP_10085_GetTileFlagArray 返回的就是文件里的 25 字节)。

本仓库这边:

  • src/formats/dt1.ts:288-290 按文件顺序解出 subTileFlags[0..24];
  • src/game/d2map.ts:432-443(地面/墙)与 src/game/d2map.ts:486-494(影子层)、src/game/map.ts:227-237(另一处实现)都用 tile.subTileFlags[subY * 5 + subX] 写入 grid[(cellY*5 + subY) * gridWidth + cellX*5 + subX]。

即"文件第 0 行"被当成了"格子最上面一行",每个格子内部的阻挡带上下翻转,最多错 4 个子格(32px 屏幕纵向,约一格 160×80 的 1/4)。

量化(拿仓库现成的 samples/d2-packs/*/*/scene.json 的碰撞 RLE,对每格做一次行翻转后逐子格对比):

地图 阻挡子格 取值变化的子格 换位的阻挡子格
Act 1 - Cathedral 1559 1512(占全图 6.0%) 756 ≈ 48%
Act 1 - Town(罗格营地 4 个 DS1) 42039 2472(4.2%) 1236
Act 1 - Catacombs 4 1147 1194(6.5%) 597
Act 1 - Courtyard 1 W 4988 4644(7.9%) 2322
Act 3 - Town 68084 1252(1.6%) 626

也就是说大教堂里近一半的阻挡子格落在了格内错误的行上:墙的阻挡带整体上/下挪了一格以内,于是"墙前面被挡、墙里能走穿"同时出现;门口那格被镜像后也可能正好把通道堵死。这与报障里"部分地图、像穿墙、门过不去"完全吻合。

顺带排除两个不是问题的点(避免误修):bit 语义是对的(src/formats/dt1.ts:248 subTileFlagsOf:bit0=blockWalk、bit3=blockPlayerWalk,与 OD2 d2dt1/subtile.go:68 一致);层间 OR 合并的语义也和引擎一致(OD2 SubTileFlags.Combine)。问题只在行序。

② 次因:门/物体完全没有参与碰撞与交互

  • src/scene/act-scene.ts 只用了 scene.objects.length 做 HUD 计数(第 401/540/772 行),不渲染、不碰撞、不交互;资源包里 objects 的 frame 也全是 null(例:samples/d2-packs/act1/33-act-1-cathedral-cathy3/scene.json),HUD 文案自己写的是"可破坏物品的位置与美术成员已进包,静态帧待接"。
  • src/formats/ds1.ts:95 的 Ds1Object.flags 解码出来了,但全仓库没有任何消费者——而原版里这一位正是"门/物体状态"的载体;grep -ri door src/ 也找不到任何门逻辑。
  • 原版里门是物体:关着时那格有碰撞、被交互/破坏后清掉那格的碰撞。这里既没有状态机也没有交互路径,所以只要 DS1 把门口那格标成阻挡,它永远过不去;反之物体那格也不会挡路(穿门而过)。

抽样:Act 2 城镇的 TrappDoor(token TD)所在格 25/25 子格全阻挡、3×3 邻域 225/225 全阻挡;corpse(DF/DG)所在格 0/25(不挡)。

③ 顺手记两个不该留着的隐患(不是本次主因)

  • src/scene/act-scene.ts:823-828 collides() 只采样 20×10 脚部方框的4 个角点;子格在屏幕空间是 32×16 的菱形,两者的基不同,方框内部完全不采样。我用它的原样判定在 62 张烘焙图上做了"可站子格集合"与"占 1 子格"参考模型的对比:目前两者完全一致(0 差异),因为 D2 的墙通常是整格厚(5 子格),所以它不是本次主因。但它是"点采样代替足迹"的脆弱实现:改 FEET_WIDTH/HEIGHT 或改投影,脚印会静默变化(当前实际脚印 ≈ 每轴 1.25 子格)。建议改成"取方框覆盖到的全部子格"或直接按引擎的 1 子格足迹判定。
  • 碰撞被压成"每子格 1 位布尔",且不记录来源(哪一层、哪张 DT1 瓦片贡献的)。这直接导致"阻挡错了"这类报障无法从资源包里定位——本次排查只能靠反解引擎公式。建议至少在 debug 构建/校验脚本里记录来源(layer + style:sequence:type)。

影响

  • 每格内阻挡错位最多 4 子格 → 视感上的"穿墙 / 空地被挡 / 门口堵死",且因图而异(取决于该图瓦片的阻挡带形状是否上下对称),与报障描述一致;
  • 生成类地图(见 issue #1)在修好之前也会继承这个错位。

修复建议

  1. 按引擎的行序解/用 flag:
    • 方案一(推荐,改动最小):src/formats/dt1.ts 解码时就按 flags[(4-subY)*5 + subX] = raw[subY*5 + subX] 归一化,下游三处(d2map.ts 地面/墙、d2map.ts 影子、map.ts)不用动;
    • 方案二:三处消费者改索引。两处必须同时改,不能只改一处。
  2. 加一条 verify:collision-orientation 断言:
    • 用 OD2 的 subtileLookup 常量做"文件索引 ↔ 房间子格"的永久断言(这是本次唯一漏检的东西:verify:tiles 只校验绘制位置、verify:packs 只比对"包 vs 现读现解",两边共用同一段代码所以都发现不了);
    • 再把"翻/不翻"两种网格叠在 tiles-*.png 上出覆盖图做一次人工/像素比对,确认哪一种与墙面吻合(这条建议留作一次性的确认,之后只留断言)。
  3. 门/物体:给 DS1 objects 建最小实体(位置 + token + flags + 关/开状态 + 碰撞占格),至少做到"门挡住 → 可交互开门 → 清掉碰撞占格";在那之前,HUD/文档不要暗示有可交互物体。
  4. 顺手把 collides() 改成足迹判定,并给碰撞子格加来源信息(debug/校验用)。
## 现象 部分地图上"阻挡"和画面对不上: - 能穿过看起来是墙的地方(穿墙); - 反过来,在看起来是空地的地方被挡住; - 门口过不去(门永远关着)。 "部分地图"这个分布特征本身就是线索:阻挡**整体还在**,只是在每一格内部错位了。 ## 初步排查 ### ① 主因(高置信):DT1 的 25 个 sub-tile flag 在文件里是**自下而上**存的,本仓库按自上而下读 → 每格内的阻挡带被上下镜像 DT1 每张瓦片记录末尾有 25 个字节的子格碰撞标志。**文件顺序的第 0..4 字节是这一格 5×5 足迹的最下面一行**,两个独立实现互相印证: - OpenDiablo2 `reference/opendiablo2/d2core/d2map/d2mapengine/map_tile.go:17`: ```go var subtileLookup = [5][5]int{ {20, 21, 22, 23, 24}, // 房间子格 (x, 0) —— 最上面一行,读文件 20..24 {15, 16, 17, 18, 19}, {10, 11, 12, 13, 14}, {5, 6, 7, 8, 9}, {0, 1, 2, 3, 4}, // 最下面一行,读文件 0..4 } ``` - D2MOO `reference/D2MOO/source/D2Common/src/D2Collision.cpp:93 sub_6FD411F0`(1.10f 的 `D2Common.0x6FD411F0`):`pTmp = &v5[5 * (nY - nCappedY + 4) - nX];` 配合循环里的 `pTmp -= 5;`,对房间第 r 行取文件第 `(4-r)` 行——同一个行序反转(`D2CMP_10085_GetTileFlagArray` 返回的就是文件里的 25 字节)。 本仓库这边: - `src/formats/dt1.ts:288-290` 按**文件顺序**解出 `subTileFlags[0..24]`; - `src/game/d2map.ts:432-443`(地面/墙)与 `src/game/d2map.ts:486-494`(影子层)、`src/game/map.ts:227-237`(另一处实现)都用 `tile.subTileFlags[subY * 5 + subX]` 写入 `grid[(cellY*5 + subY) * gridWidth + cellX*5 + subX]`。 即"文件第 0 行"被当成了"格子最上面一行",**每个格子内部的阻挡带上下翻转**,最多错 4 个子格(32px 屏幕纵向,约一格 160×80 的 1/4)。 量化(拿仓库现成的 `samples/d2-packs/*/*/scene.json` 的碰撞 RLE,对每格做一次行翻转后逐子格对比): | 地图 | 阻挡子格 | 取值变化的子格 | 换位的阻挡子格 | | --- | --- | --- | --- | | Act 1 - Cathedral | 1559 | 1512(占全图 6.0%) | **756 ≈ 48%** | | Act 1 - Town(罗格营地 4 个 DS1) | 42039 | 2472(4.2%) | 1236 | | Act 1 - Catacombs 4 | 1147 | 1194(6.5%) | 597 | | Act 1 - Courtyard 1 W | 4988 | 4644(7.9%) | 2322 | | Act 3 - Town | 68084 | 1252(1.6%) | 626 | 也就是说**大教堂里近一半的阻挡子格落在了格内错误的行上**:墙的阻挡带整体上/下挪了一格以内,于是"墙前面被挡、墙里能走穿"同时出现;门口那格被镜像后也可能正好把通道堵死。这与报障里"**部分**地图、像穿墙、门过不去"完全吻合。 顺带排除两个**不是**问题的点(避免误修):bit 语义是对的(`src/formats/dt1.ts:248 subTileFlagsOf`:bit0=blockWalk、bit3=blockPlayerWalk,与 OD2 `d2dt1/subtile.go:68` 一致);层间 OR 合并的语义也和引擎一致(OD2 `SubTileFlags.Combine`)。问题只在**行序**。 ### ② 次因:门/物体完全没有参与碰撞与交互 - `src/scene/act-scene.ts` 只用了 `scene.objects.length` 做 HUD 计数(第 401/540/772 行),**不渲染、不碰撞、不交互**;资源包里 objects 的 `frame` 也全是 `null`(例:`samples/d2-packs/act1/33-act-1-cathedral-cathy3/scene.json`),HUD 文案自己写的是"可破坏物品的位置与美术成员已进包,静态帧待接"。 - `src/formats/ds1.ts:95` 的 `Ds1Object.flags` 解码出来了,但全仓库没有任何消费者——而原版里这一位正是"门/物体状态"的载体;`grep -ri door src/` 也找不到任何门逻辑。 - 原版里门是**物体**:关着时那格有碰撞、被交互/破坏后清掉那格的碰撞。这里既没有状态机也没有交互路径,所以**只要 DS1 把门口那格标成阻挡,它永远过不去**;反之物体那格也不会挡路(穿门而过)。 抽样:Act 2 城镇的 `TrappDoor`(token `TD`)所在格 25/25 子格全阻挡、3×3 邻域 225/225 全阻挡;`corpse`(`DF`/`DG`)所在格 0/25(不挡)。 ### ③ 顺手记两个不该留着的隐患(不是本次主因) - `src/scene/act-scene.ts:823-828 collides()` 只采样 20×10 脚部方框的**4 个角点**;子格在屏幕空间是 32×16 的菱形,两者的基不同,**方框内部完全不采样**。我用它的原样判定在 62 张烘焙图上做了"可站子格集合"与"占 1 子格"参考模型的对比:目前两者**完全一致(0 差异)**,因为 D2 的墙通常是整格厚(5 子格),所以它不是本次主因。但它是"点采样代替足迹"的脆弱实现:改 `FEET_WIDTH/HEIGHT` 或改投影,脚印会静默变化(当前实际脚印 ≈ 每轴 1.25 子格)。建议改成"取方框覆盖到的全部子格"或直接按引擎的 1 子格足迹判定。 - 碰撞被压成"每子格 1 位布尔",且**不记录来源**(哪一层、哪张 DT1 瓦片贡献的)。这直接导致"阻挡错了"这类报障无法从资源包里定位——本次排查只能靠反解引擎公式。建议至少在 debug 构建/校验脚本里记录来源(layer + style:sequence:type)。 ## 影响 - 每格内阻挡错位最多 4 子格 → 视感上的"穿墙 / 空地被挡 / 门口堵死",且**因图而异**(取决于该图瓦片的阻挡带形状是否上下对称),与报障描述一致; - 生成类地图(见 issue #1)在修好之前也会继承这个错位。 ## 修复建议 1. 按引擎的行序解/用 flag: - 方案一(推荐,改动最小):`src/formats/dt1.ts` 解码时就按 `flags[(4-subY)*5 + subX] = raw[subY*5 + subX]` 归一化,下游三处(`d2map.ts` 地面/墙、`d2map.ts` 影子、`map.ts`)不用动; - 方案二:三处消费者改索引。两处必须同时改,不能只改一处。 2. 加一条 `verify:collision-orientation` 断言: - 用 OD2 的 `subtileLookup` 常量做"文件索引 ↔ 房间子格"的**永久断言**(这是本次唯一漏检的东西:`verify:tiles` 只校验绘制位置、`verify:packs` 只比对"包 vs 现读现解",两边共用同一段代码所以都发现不了); - 再把"翻/不翻"两种网格叠在 `tiles-*.png` 上出覆盖图做一次人工/像素比对,确认哪一种与墙面吻合(这条建议留作一次性的确认,之后只留断言)。 3. 门/物体:给 DS1 objects 建最小实体(位置 + token + `flags` + 关/开状态 + 碰撞占格),至少做到"门挡住 → 可交互开门 → 清掉碰撞占格";在那之前,HUD/文档不要暗示有可交互物体。 4. 顺手把 `collides()` 改成足迹判定,并给碰撞子格加来源信息(debug/校验用)。
Author
Owner

问题确认与修复结论

感谢如此详尽的排查!根据所附的 OpenDiablo2 与 D2MOO 逆向代码,已确证该问题并完成代码修复与回归断言套件,相关结论如下:

1. 根因印证(DT1 子格碰撞标志位行序)

  • 存储布局:DT1 瓦片末尾的 25 字节子格碰撞标志位在二进制文件中为自底向上(bottom-to-top)存储:
    • 文件字节 0..4 对应 5×5 房间网格的最底行(subY = 4);
    • 文件字节 20..24 对应 5×5 房间网格的最顶行(subY = 0)。
  • 代码缺陷:原先 src/formats/dt1.ts 线性按 0..24 读入 subTileFlags,而下游消费者(src/game/d2map.ts、src/game/map.ts)均以自顶向下的 subY * 5 + subX 写入碰撞图,导致每个格子内部碰撞标志位上下镜像翻转,上下错位达 4 个子格(约 32px 纵向),产生严重的穿墙与空地受阻。

2. 修复实施(采用建议方案一)

  • 解码层归一化:
    在 src/formats/dt1.ts 解码瓦片时,直接按 fileIndex = (SUB_TILE_GRID - 1 - subY) * SUB_TILE_GRID + subX 进行行序归一化:
    const subTileFlags: SubTileFlags[] = []
    for (let subY = 0; subY < SUB_TILE_GRID; subY += 1) {
      for (let subX = 0; subX < SUB_TILE_GRID; subX += 1) {
        const fileIndex = (SUB_TILE_GRID - 1 - subY) * SUB_TILE_GRID + subX
        subTileFlags.push(subTileFlagsOf(data[at + 40 + fileIndex]!))
      }
    }
    
    下游消费者(d2map.ts 地表/墙面 stamp、影子层解析、map.ts 的 stampTile)无需变更索引逻辑,天然获得自顶向下的标准子格行序。
  • 参考工具对齐:
    同步更新 tools/go-oracle/main.go 中的 DT1 dump 逻辑,使用相同行列映射,保证 TypeScript 解码器与 Go 导出工具在格式比对时保持 100% 格式对齐。
  • 碰撞采样强化:
    在 src/scene/act-scene.ts 中将 collides() 从仅测脚部 4 个角点扩展为采样中心点及四边中点,杜绝菱形投影基不同导致的脚框内部漏检。

3. 新增永久自动化断言(verify:collision-orientation)

新增了测试脚本 scripts/verify-collision-orientation.ts 并集成至 package.json 的 npm run verify:all 中,包含 7 项不可篡改的恒真断言:

  1. 参考引擎公式等价性:OpenDiablo2 subtileLookup 查找表与 D2MOO (4 - subY) * 5 + subX 公式在全 25 子格完全等价;
  2. 合成瓦片行序比对:合成顺序编码 0..24 的 DT1 二进制瓦片,验证解码后 25 个子格的 raw 标志位与 OpenDiablo2 subtileLookup 逐点一致;
  3. 顶行精准断言:subY = 0 必须准确提取文件字节 [20, 21, 22, 23, 24];
  4. 底行精准断言:subY = 4 必须准确提取文件字节 [0, 1, 2, 3, 4];
  5. 语义方向测试(底行):当文件仅第 0..4 字节置位时,解码后仅 subY = 4 为阻挡,subY = 0 保证畅通;
  6. 语义方向测试(顶行):当文件仅第 20..24 字节置位时,解码后仅 subY = 0 为阻挡,subY = 4 保证畅通;
  7. 全套流水线验收:全量测试套件(combat, items, m4, m5, net, collision-orientation 共 335+ 验证项)与生产构建(npm run build)全部通过。

4. 关于门与物体交互的后续跟进

正如初步排查所指出的,门在原版中作为物体实体(Object)存在动态碰撞修改机制(开门操作清除门口格阻挡)。当前版本 Objects 处于静态阶段尚未接入碰撞与状态机逻辑,后续将配合 Issue #5(对象层绘制)建立最小实体状态机,使门能够开闭并动态更新碰撞网格。

## 问题确认与修复结论 感谢如此详尽的排查!根据所附的 OpenDiablo2 与 D2MOO 逆向代码,已确证该问题并完成代码修复与回归断言套件,相关结论如下: ### 1. 根因印证(DT1 子格碰撞标志位行序) * **存储布局**:DT1 瓦片末尾的 25 字节子格碰撞标志位在二进制文件中为**自底向上**(bottom-to-top)存储: * 文件字节 `0..4` 对应 5×5 房间网格的最底行(`subY = 4`); * 文件字节 `20..24` 对应 5×5 房间网格的最顶行(`subY = 0`)。 * **代码缺陷**:原先 `src/formats/dt1.ts` 线性按 `0..24` 读入 `subTileFlags`,而下游消费者(`src/game/d2map.ts`、`src/game/map.ts`)均以自顶向下的 `subY * 5 + subX` 写入碰撞图,导致每个格子内部碰撞标志位**上下镜像翻转**,上下错位达 4 个子格(约 32px 纵向),产生严重的穿墙与空地受阻。 --- ### 2. 修复实施(采用建议方案一) * **解码层归一化**: 在 `src/formats/dt1.ts` 解码瓦片时,直接按 `fileIndex = (SUB_TILE_GRID - 1 - subY) * SUB_TILE_GRID + subX` 进行行序归一化: ```typescript const subTileFlags: SubTileFlags[] = [] for (let subY = 0; subY < SUB_TILE_GRID; subY += 1) { for (let subX = 0; subX < SUB_TILE_GRID; subX += 1) { const fileIndex = (SUB_TILE_GRID - 1 - subY) * SUB_TILE_GRID + subX subTileFlags.push(subTileFlagsOf(data[at + 40 + fileIndex]!)) } } ``` 下游消费者(`d2map.ts` 地表/墙面 `stamp`、影子层解析、`map.ts` 的 `stampTile`)无需变更索引逻辑,天然获得自顶向下的标准子格行序。 * **参考工具对齐**: 同步更新 `tools/go-oracle/main.go` 中的 DT1 dump 逻辑,使用相同行列映射,保证 TypeScript 解码器与 Go 导出工具在格式比对时保持 100% 格式对齐。 * **碰撞采样强化**: 在 `src/scene/act-scene.ts` 中将 `collides()` 从仅测脚部 4 个角点扩展为采样中心点及四边中点,杜绝菱形投影基不同导致的脚框内部漏检。 --- ### 3. 新增永久自动化断言(`verify:collision-orientation`) 新增了测试脚本 `scripts/verify-collision-orientation.ts` 并集成至 `package.json` 的 `npm run verify:all` 中,包含 7 项不可篡改的恒真断言: 1. **参考引擎公式等价性**:OpenDiablo2 `subtileLookup` 查找表与 D2MOO `(4 - subY) * 5 + subX` 公式在全 25 子格完全等价; 2. **合成瓦片行序比对**:合成顺序编码 `0..24` 的 DT1 二进制瓦片,验证解码后 25 个子格的 raw 标志位与 OpenDiablo2 `subtileLookup` 逐点一致; 3. **顶行精准断言**:`subY = 0` 必须准确提取文件字节 `[20, 21, 22, 23, 24]`; 4. **底行精准断言**:`subY = 4` 必须准确提取文件字节 `[0, 1, 2, 3, 4]`; 5. **语义方向测试(底行)**:当文件仅第 0..4 字节置位时,解码后仅 `subY = 4` 为阻挡,`subY = 0` 保证畅通; 6. **语义方向测试(顶行)**:当文件仅第 20..24 字节置位时,解码后仅 `subY = 0` 为阻挡,`subY = 4` 保证畅通; 7. **全套流水线验收**:全量测试套件(combat, items, m4, m5, net, collision-orientation 共 335+ 验证项)与生产构建(`npm run build`)全部通过。 --- ### 4. 关于门与物体交互的后续跟进 正如初步排查所指出的,门在原版中作为物体实体(Object)存在动态碰撞修改机制(开门操作清除门口格阻挡)。当前版本 Objects 处于静态阶段尚未接入碰撞与状态机逻辑,后续将配合 Issue #5(对象层绘制)建立最小实体状态机,使门能够开闭并动态更新碰撞网格。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#4
No description provided.