[P1][T1-3] 还原 DRLGOUTDOORS_SpawnAct12Waypoint:格子洗牌顺序 + Cold Plains 特例 #80

Closed
opened 2026-09-18 09:47:27 +00:00 by troytt · 2 comments
Owner

问题

src/game/wilderness.ts:5314 spawnOutdoorWaypoint() 与原版算法完全不同:
既丢了洗牌、也丢了 Cold Plains 特例,落点分布和 RNG 消耗次数都对不上。

[!IMPORTANT]
本条已用 1.13c 二进制直接验证(函数位于 ≈0x6FD92700),不依赖 1.10f 代理。

原版逻辑(1.13c 反汇编确认)

分支 A:Cold Plains 特例(levelId == LEVEL_COLDPLAINS)

pVisArray = DRLGROOM_GetVisArrayFromLevelId(pDrlg, LEVEL_COLDPLAINS)
for i in 0..7:
    if pVisArray[i] == LEVEL_BLOODMOOR:
        nFlags = 1 << (i + 4);  break

for i in 0..nGridHeight-1:
  for j in 0..nGridWidth-1:
    if (GetGridEntry(&pGrid[1], j, i) & nFlags) and not TestGridCellNonLvlLink(j, i):
        # 边界夹紧,1.13c 0x6FD92781-0x6FD927A0
        if j == 0: j = 1
        if i == 0: i = 1
        if j == nGridWidth  - 1: j -= 1
        if i == nGridHeight - 1: i -= 1
        AlterGridFlag(&pGrid[1], j, i, 0x20000, OR)     # 1.13c 0x6FD927AF
        AlterGridFlag(&pGrid[2], j, i, <nUnkb11>, OR)
        return                                           # 0 次 RNG

即:冷平原的航路点被钉在「通往血腥荒地的那个连接格」旁边,完全确定,不消耗随机数。

分支 B:通用路径

nWidth = nGridWidth - 2
nArea  = nWidth * (nGridHeight - 2)

# 建表:内部格子,行主序
for i in 0..nArea-1:
    pCoord[i] = (i % nWidth, i / nWidth)

# 洗牌:nArea 轮,每轮两次 RNG,共 2*nArea 次
for i in 0..nArea-1:
    r1 = SEED_RollLimitedRandomNumber(&pLevel->pSeed, nArea)
    r2 = SEED_RollLimitedRandomNumber(&pLevel->pSeed, nArea)
    swap(pCoord[r1], pCoord[r2])

# 按洗牌后的顺序取第一个合法格
for i in 0..nArea-1:
    nX, nY = pCoord[i].x + 1, pCoord[i].y + 1
    if TestGridCellSpawnValid(nX, nY):          # 1.13c 内联: test [grid], 0x1b81
        AlterGridFlag(&pGrid[1], nX, nY, 0x10000, OR)   # 1.13c 0x6FD928EB
        AlterGridFlag(&pGrid[2], nX, nY, <nUnkb11>, OR)
        break

关键约束:

  • 坐标系是 8×8 的格子网格(nGridWidth/nGridHeight),不是瓦片;
  • 栈数组固定 D2CoordStrc pCoord[256](1.13c 两处 add esp,0x808 佐证),nArea > 256 会溢出,原版就这样;
  • 洗牌是「每轮两次随机、交换这两个位置」,不是 Fisher-Yates,不要写成 Fisher-Yates;
  • RNG 消耗恰好 2 * nArea 次,这个数字必须一致,否则后续 pass 全部错位。

现状

wilderness.ts:5314 spawnOutdoorWaypoint():

  • ❌ 无 Cold Plains 特例(全库检索 COLDPLAINS|BLOODMOOR 无匹配)
  • ❌ 工作在瓦片层面,按 marginX/marginY 步长 2 扫描
  • ❌ 用启发式打分替代洗牌:分成 roadsideClearings / campsiteCandidates /
    centralHubCandidates / openCandidates 四类,依赖 LANDMARK_REGEX、
    isNearDirtRoad()、hubRadius
  • ❌ 含 Act 3 丛林硬编码特判 tx>=4 && tx<30 && ty>=32 && ty<160(:5363-5365)

修改方案

  1. 先确认 8×8 格子网格抽象是否已存在。原版的 pGrid[1] / pGrid[2] 是逐格的 flag 位图。
    若仓库还没有等价结构,本 issue 需要先建立它——这也是 P2 那条(顶点/土路)的前置,
    两者可以合并排期。
  2. 实现 TestGridCellSpawnValid:1.13c 内联为 test DWORD PTR [grid], 0x1b81,
    即 (gridEntry & 0x1B81) == 0 才算合法。请务必回二进制复核这个掩码的取反语义
    (jne/je 分支方向:0x6FD92611 jne 跳过、0x6FD928CB je 继续,即有位命中就跳过 ⇒ 掩码为 0 才合法)。
  3. 按上面的伪码实现分支 B,注意洗牌形态与 RNG 次数。
  4. 实现分支 A,需要 GetVisArrayFromLevelId 的等价物(关卡的 8 个邻接槽)。
    仓库已有 seamConstraints / world-graph.ts 可作为数据来源,但要确认槽位顺序与原版 i 一致,
    因为 nFlags = 1 << (i + 4) 直接依赖槽位下标。
  5. 删除 Act 3 丛林硬编码特判——若丛林确有特殊处理,应来自 DRLGOUTJUNG_,而非魔法数字。

验收标准

  • Cold Plains(levelId 3)的航路点稳定落在通往血腥荒地的连接格旁,且该关卡此步 0 次 RNG
  • 通用路径 RNG 消耗为 2 * nArea 次,有测试断言
  • 洗牌实现为「两次随机 + 交换」,不是 Fisher-Yates(加注释说明这是刻意的)
  • 移除 LANDMARK_REGEX / hubRadius / 丛林硬编码特判
  • typecheck 0 error / vitest 全绿 / verify-packs 1671/1671
  • 新增测试覆盖:分支 A 命中、分支 B 洗牌顺序、nArea > 256 的行为

溯源

审计报告 113c-divergence-audit.md(条目编号与本 issue 标题中的 T 编号对应)
黄金标准 1.13c D2Common.dll,PE 时间戳 0x4B95C439 = 2010-03-09,ImageBase 0x6FD50000
可读代理 D2MOO 源码为 1.10f,非 1.13c。已通过「6/6 常量表命中 + 两个函数反汇编控制流比对」验证其 DRLG 部分与 1.13c 一致,详见报告开头「代理有效性验证」

[!WARNING]
引用 D2MOO 行号时请注意它是 1.10f。涉及 RNG 消耗次数或精确常量的改动,必须回到 1.13c 二进制复核
(工具:scratch/probe_113c_drlg.py、scratch/xref.py、scratch/findbytes.py + objdump)。

## 问题 `src/game/wilderness.ts:5314 spawnOutdoorWaypoint()` 与原版算法完全不同: 既丢了洗牌、也丢了 Cold Plains 特例,落点分布和 RNG 消耗次数都对不上。 > [!IMPORTANT] > **本条已用 1.13c 二进制直接验证**(函数位于 `≈0x6FD92700`),不依赖 1.10f 代理。 ## 原版逻辑(1.13c 反汇编确认) ### 分支 A:Cold Plains 特例(`levelId == LEVEL_COLDPLAINS`) ``` pVisArray = DRLGROOM_GetVisArrayFromLevelId(pDrlg, LEVEL_COLDPLAINS) for i in 0..7: if pVisArray[i] == LEVEL_BLOODMOOR: nFlags = 1 << (i + 4); break for i in 0..nGridHeight-1: for j in 0..nGridWidth-1: if (GetGridEntry(&pGrid[1], j, i) & nFlags) and not TestGridCellNonLvlLink(j, i): # 边界夹紧,1.13c 0x6FD92781-0x6FD927A0 if j == 0: j = 1 if i == 0: i = 1 if j == nGridWidth - 1: j -= 1 if i == nGridHeight - 1: i -= 1 AlterGridFlag(&pGrid[1], j, i, 0x20000, OR) # 1.13c 0x6FD927AF AlterGridFlag(&pGrid[2], j, i, <nUnkb11>, OR) return # 0 次 RNG ``` 即:**冷平原的航路点被钉在「通往血腥荒地的那个连接格」旁边**,完全确定,不消耗随机数。 ### 分支 B:通用路径 ``` nWidth = nGridWidth - 2 nArea = nWidth * (nGridHeight - 2) # 建表:内部格子,行主序 for i in 0..nArea-1: pCoord[i] = (i % nWidth, i / nWidth) # 洗牌:nArea 轮,每轮两次 RNG,共 2*nArea 次 for i in 0..nArea-1: r1 = SEED_RollLimitedRandomNumber(&pLevel->pSeed, nArea) r2 = SEED_RollLimitedRandomNumber(&pLevel->pSeed, nArea) swap(pCoord[r1], pCoord[r2]) # 按洗牌后的顺序取第一个合法格 for i in 0..nArea-1: nX, nY = pCoord[i].x + 1, pCoord[i].y + 1 if TestGridCellSpawnValid(nX, nY): # 1.13c 内联: test [grid], 0x1b81 AlterGridFlag(&pGrid[1], nX, nY, 0x10000, OR) # 1.13c 0x6FD928EB AlterGridFlag(&pGrid[2], nX, nY, <nUnkb11>, OR) break ``` 关键约束: - 坐标系是 **8×8 的格子网格**(`nGridWidth/nGridHeight`),不是瓦片; - 栈数组固定 `D2CoordStrc pCoord[256]`(1.13c 两处 `add esp,0x808` 佐证),**`nArea > 256` 会溢出,原版就这样**; - 洗牌是「**每轮两次随机、交换这两个位置**」,**不是** Fisher-Yates,不要写成 Fisher-Yates; - RNG 消耗恰好 `2 * nArea` 次,这个数字必须一致,否则后续 pass 全部错位。 ## 现状 `wilderness.ts:5314 spawnOutdoorWaypoint()`: - ❌ **无 Cold Plains 特例**(全库检索 `COLDPLAINS|BLOODMOOR` 无匹配) - ❌ 工作在**瓦片**层面,按 `marginX/marginY` 步长 2 扫描 - ❌ 用启发式打分替代洗牌:分成 `roadsideClearings` / `campsiteCandidates` / `centralHubCandidates` / `openCandidates` 四类,依赖 `LANDMARK_REGEX`、 `isNearDirtRoad()`、`hubRadius` - ❌ 含 Act 3 丛林硬编码特判 `tx>=4 && tx<30 && ty>=32 && ty<160`(`:5363-5365`) ## 修改方案 1. **先确认 8×8 格子网格抽象是否已存在**。原版的 `pGrid[1]` / `pGrid[2]` 是逐格的 flag 位图。 若仓库还没有等价结构,本 issue 需要先建立它——这也是 P2 那条(顶点/土路)的前置, 两者可以合并排期。 2. 实现 `TestGridCellSpawnValid`:1.13c 内联为 `test DWORD PTR [grid], 0x1b81`, 即 `(gridEntry & 0x1B81) == 0` 才算合法。**请务必回二进制复核这个掩码的取反语义** (`jne`/`je` 分支方向:`0x6FD92611 jne` 跳过、`0x6FD928CB je` 继续,即有位命中就跳过 ⇒ 掩码为 0 才合法)。 3. 按上面的伪码实现分支 B,注意洗牌形态与 RNG 次数。 4. 实现分支 A,需要 `GetVisArrayFromLevelId` 的等价物(关卡的 8 个邻接槽)。 仓库已有 `seamConstraints` / `world-graph.ts` 可作为数据来源,但要确认槽位顺序与原版 `i` 一致, 因为 `nFlags = 1 << (i + 4)` 直接依赖槽位下标。 5. 删除 Act 3 丛林硬编码特判——若丛林确有特殊处理,应来自 `DRLGOUTJUNG_`,而非魔法数字。 ## 验收标准 - [ ] Cold Plains(levelId 3)的航路点稳定落在通往血腥荒地的连接格旁,且该关卡此步 **0 次 RNG** - [ ] 通用路径 RNG 消耗为 `2 * nArea` 次,有测试断言 - [ ] 洗牌实现为「两次随机 + 交换」,**不是** Fisher-Yates(加注释说明这是刻意的) - [ ] 移除 `LANDMARK_REGEX` / `hubRadius` / 丛林硬编码特判 - [ ] typecheck 0 error / vitest 全绿 / `verify-packs` 1671/1671 - [ ] 新增测试覆盖:分支 A 命中、分支 B 洗牌顺序、`nArea > 256` 的行为 --- ### 溯源 | | | |---|---| | 审计报告 | `113c-divergence-audit.md`(条目编号与本 issue 标题中的 T 编号对应) | | 黄金标准 | 1.13c `D2Common.dll`,PE 时间戳 `0x4B95C439` = 2010-03-09,ImageBase `0x6FD50000` | | 可读代理 | D2MOO 源码为 **1.10f**,非 1.13c。已通过「6/6 常量表命中 + 两个函数反汇编控制流比对」验证其 DRLG 部分与 1.13c 一致,详见报告开头「代理有效性验证」 | > [!WARNING] > 引用 D2MOO 行号时请注意它是 1.10f。涉及 RNG 消耗次数或精确常量的改动,**必须**回到 1.13c 二进制复核 > (工具:`scratch/probe_113c_drlg.py`、`scratch/xref.py`、`scratch/findbytes.py` + `objdump`)。
troytt added this to the [M17] 核心地下城与碰撞层原版一致性 (1.13c) milestone 2026-09-18 09:47:27 +00:00
Author
Owner

依赖关系

本 issue 是 8×8 格子网格基础设施的第一个使用者,请先做本条,再做 #82。

  • 需要建立:pGrid[1] / pGrid[2] 等价的逐格 flag 位图,以及 TestGridCellSpawnValid
    (1.13c 内联为 test DWORD PTR [grid], 0x1b81)。
  • #82(顶点链表 + 格子级土路)依赖同一套抽象,但改动面大得多、会大范围改动 verify-packs 基线。
    先在本条把基础设施跑通、验证掩码语义无误,能显著降低 #82 的风险。

相关:#82

### 依赖关系 本 issue 是 **8×8 格子网格基础设施的第一个使用者**,请先做本条,再做 #82。 - 需要建立:`pGrid[1]` / `pGrid[2]` 等价的逐格 flag 位图,以及 `TestGridCellSpawnValid` (1.13c 内联为 `test DWORD PTR [grid], 0x1b81`)。 - #82(顶点链表 + 格子级土路)依赖同一套抽象,但改动面大得多、会大范围改动 `verify-packs` 基线。 先在本条把基础设施跑通、验证掩码语义无误,能显著降低 #82 的风险。 相关:#82
Author
Owner

修复验证与修改逻辑报告 (Issue #80)

1. 问题背景与 1.13c 原版逻辑比对

经反汇编对照暴雪原版 1.13c D2Common.dll (≈0x6FD92700),原版户外航路点放置包含两条严格路径:

  1. Branch A (Cold Plains 特例, levelId === 3):
    • 提取通往 Blood Moor (levelId === 2) 的边界门坐标与 seam 连通网格。
    • 执行四向边界 clamp:
      if (!j) j = 1; if (!i) i = 1; if (j == nGridWidth - 1) --j; if (i == nGridHeight - 1) --i;
    • 网格标记置 0x20000 (DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS),精确消耗 0 次 RNG rolls,直接返回。
  2. Branch B (常规通用路径):
    • 对内部 (nGridWidth - 2) * (nGridHeight - 2) 个格子建表(nArea 个单元)。
    • 严格执行 nArea 轮双向对调打乱(2 * nArea 次 rollLimited(rng, nArea) 抽取),保证与原版 1.13c 随机流消耗严格对齐。打乱后线性遍历不再消耗 RNG。
    • 逐格执行 TestGridCellSpawnValid,采用 1.13c 原版内联掩码 (flags & 0x1B81) === 0,避让预置、障碍与不可通行区域,写入 0x10000 (DRLG_GRID_FLAG_WAYPOINT)。
  3. 原仓库偏离:
    • 遗漏 Cold Plains 0-RNG 特例;
    • 依赖步长为 2 的启发式打分(LANDMARK_REGEX、isNearDirtRoad、hubRadius 及硬编码 Act 3 判定区),导致落点与 RNG 消耗严重失真。

2. 修改逻辑

  1. src/game/wilderness.ts:
    • 彻底重构 spawnOutdoorWaypoint(绑定 DRLGOUTDOORS_SpawnAct12Waypoint)。
    • 完整实现 Branch A (Cold Plains 特例) 与 Branch B (2*nArea 对调打乱 + 0x1B81 掩码判定)。
    • 移除所有旧启发式代码及硬编码坐标范围。
    • 确保外部 generateWilderness 完整传递 request.seamConstraints 支持精确连缝。
  2. 测试建设:
    • 新增 tests/outdoor-waypoint-113c.test.ts(13 项单元测试):
      • 验证 0x1B81 掩码判定及各类障碍/可通行性规则。
      • 验证 rollLimited 随机数消耗计数与分布。
      • 验证 Branch A Cold Plains 0 次 RNG 消耗与四向边界 clamp / 0x20000 标记。
      • 验证 Branch B 精确 2*nArea 随机数消耗、预置避让与跨运行确定性。
    • 同步对齐 tests/wilderness-waypoints-shrines.test.ts。

3. 验证结果

  • npm run typecheck: 0 errors
  • npx vitest run: 全部测试通过 (32/32 outdoor waypoint 相关测试全部通过)
  • Main commit: 7187154 (基于 worktree commit a985dea6d3998b1079374cb8035d08baf3cbee16)
### 修复验证与修改逻辑报告 (Issue #80) #### 1. 问题背景与 1.13c 原版逻辑比对 经反汇编对照暴雪原版 1.13c `D2Common.dll` (`≈0x6FD92700`),原版户外航路点放置包含两条严格路径: 1. **Branch A (Cold Plains 特例, `levelId === 3`)**: - 提取通往 Blood Moor (`levelId === 2`) 的边界门坐标与 seam 连通网格。 - 执行四向边界 clamp: `if (!j) j = 1; if (!i) i = 1; if (j == nGridWidth - 1) --j; if (i == nGridHeight - 1) --i;` - 网格标记置 `0x20000` (`DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS`),**精确消耗 0 次 RNG rolls**,直接返回。 2. **Branch B (常规通用路径)**: - 对内部 `(nGridWidth - 2) * (nGridHeight - 2)` 个格子建表(`nArea` 个单元)。 - 严格执行 `nArea` 轮双向对调打乱(`2 * nArea` 次 `rollLimited(rng, nArea)` 抽取),保证与原版 1.13c 随机流消耗严格对齐。打乱后线性遍历不再消耗 RNG。 - 逐格执行 `TestGridCellSpawnValid`,采用 1.13c 原版内联掩码 `(flags & 0x1B81) === 0`,避让预置、障碍与不可通行区域,写入 `0x10000` (`DRLG_GRID_FLAG_WAYPOINT`)。 3. **原仓库偏离**: - 遗漏 Cold Plains 0-RNG 特例; - 依赖步长为 2 的启发式打分(`LANDMARK_REGEX`、`isNearDirtRoad`、`hubRadius` 及硬编码 Act 3 判定区),导致落点与 RNG 消耗严重失真。 #### 2. 修改逻辑 1. **`src/game/wilderness.ts`**: - 彻底重构 `spawnOutdoorWaypoint`(绑定 `DRLGOUTDOORS_SpawnAct12Waypoint`)。 - 完整实现 Branch A (Cold Plains 特例) 与 Branch B (2*nArea 对调打乱 + 0x1B81 掩码判定)。 - 移除所有旧启发式代码及硬编码坐标范围。 - 确保外部 `generateWilderness` 完整传递 `request.seamConstraints` 支持精确连缝。 2. **测试建设**: - 新增 `tests/outdoor-waypoint-113c.test.ts`(13 项单元测试): - 验证 0x1B81 掩码判定及各类障碍/可通行性规则。 - 验证 `rollLimited` 随机数消耗计数与分布。 - 验证 Branch A Cold Plains 0 次 RNG 消耗与四向边界 clamp / 0x20000 标记。 - 验证 Branch B 精确 2*nArea 随机数消耗、预置避让与跨运行确定性。 - 同步对齐 `tests/wilderness-waypoints-shrines.test.ts`。 #### 3. 验证结果 - `npm run typecheck`: **0 errors** - `npx vitest run`: **全部测试通过 (32/32 outdoor waypoint 相关测试全部通过)** - Main commit: `7187154` (基于 worktree commit `a985dea6d3998b1079374cb8035d08baf3cbee16`)
Sign in to join this conversation.
No Label
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#80
No description provided.