[P0][T23] 实现 1.13c PRNG:64 位 LCG(0x6AC690C5)+ per-unit seed #87

Closed
opened 2026-09-18 10:50:43 +00:00 by troytt · 1 comment
Owner

问题

现在 src/game/rng.ts 用的是 mulberry32(32 位状态、state += 0x6D2B79F5 + 两轮 xorshift/imul、返回 [0,1) 浮点、全局流 + fork(label))。

D2 1.13c 用的是完全不同的东西。三个维度都不兼容:

维度 1.13c 现状 mulberry32 后果
状态 64 bit(lo/hi) 32 bit 周期与序列完全不同
取整 lo % max(2 的幂走 AND) floor(next()*span) 偏差方向不同,同 seed 不可能对齐
归属 每个 unit 一条流 全局 + fork 无法复现「同一只怪的掉落序列」

净效果:永久失去与 1.13c 对拍的能力。对 M10 这种「以原版为金标准」性质的里程碑,这是不可接受的。

金标准数据

D2Common!6FD510B0 的反汇编(另有 6FD51100、6FD51180 变体;D2Game!6FC211D0/6FC21180 是完全相同的副本,此外还有大量内联展开点):

mov    (%ecx),%eax          ; lo = seed->lo
mov    $0x6ac690c5,%edx
mul    %edx                 ; edx:eax = lo * 0x6AC690C5   (无符号 32x32->64)
mov    0x4(%ecx),%edi       ; hi = seed->hi
add    %edi,%eax
adc    %ebx,%edx            ; 64 位加 hi
mov    %eax,(%ecx)          ; seed->lo = low32
mov    %edx,0x4(%ecx)       ; seed->hi = high32

即:

state64 = (u64)lo * 0x6AC690C5 + hi
lo = state64 & 0xFFFFFFFF
hi = state64 >>> 32

三个 API:

API 语义
Rand(seed) 只推进,返回 lo(内联点常直接 lo & 0x3FF 做 1024 判定)
Rand(seed, max) max <= 0 → 0;max 是 2 的幂 → lo & (max-1);否则 → lo % max
Rand(seed, min, max) min >= max → min;否则 span = max-min+1,返回 min + lo % span(或 min + (lo & (span-1)))

seed 的归属(确证):

  • 每个 unit 自带 seed:UnitAny + 0x20(dwSeed[2] = lo/hi);+0x28 是初始 seed。
  • 整条掉落链(NoDrop 判定、TC 条目抽取、uber/ultra 判定、RollQuality)全部用死掉的那只怪的 seed —— 代码反复 lea 0x20(%unit) 后调 RNG。
  • 物品创建后的词缀与属性 roll 用物品自己的 seed。这正是 .d2s 只存 32 位 id 就能完整复原词缀数值的原因。

实现要求

  1. 新增 D2Rng 类,内部持 { lo: number, hi: number }(两个 u32)。
  2. 32×32→64 乘法用 Math.imul 拆 16 位手写,不要用 BigInt —— 掉落是热点路径,BigInt 会慢一个数量级。参考写法:把 lo 拆成 lo & 0xFFFF 与 lo >>> 16,与常量的高低 16 位做四次 Math.imul,再拼回 64 位。
  3. 实现上表三个 Rand 重载,严格区分 2 的幂与非 2 的幂两条分支。
  4. 掉落 / 物品 / 怪物三处全部改走 D2Rng,每个 unit 持有自己的 seed 实例。
  5. mulberry32 保留,但只服务表现层抖动(动画、粒子等与游戏逻辑无关的地方)。

陷阱

[!CAUTION]
不能只换底层不换取整方式。 现在 Rng.int() 是 floor(next() * span),与 lo % span 不等价 —— 前者是从浮点均匀分布截断,后者带模偏差。只替换 PRNG 内核而保留 floor(next()*span) 的调用方式,仍然对不上原版。

[!CAUTION]
lo % max 的模偏差是原版行为的一部分,不要「改良」成拒绝采样。

[!WARNING]
这个 issue 会改掉所有掷骰点的调用签名,必须在 T-13 / T-14 / T-24 之前完成,否则要返工两遍。

验收标准

  • 固定 seed 下,state64 = lo * 0x6AC690C5 + hi 的前 N 个输出与手算序列逐位相等(写成单测的已知向量)
  • Rand(seed, 1024) 走 AND 分支、Rand(seed, 1000) 走取模分支,两条分支都有覆盖
  • Rand(seed, min, max) 在 min >= max 时返回 min
  • 每个 unit 的 seed 独立推进,互不干扰
  • npm run typecheck 0 error
  • npx vitest run 全绿
  • npx tsx scripts/verify-items.ts 新增断言通过

溯源

金标准(算法) 1.13c 反汇编:D2Common!6FD510B0 / D2Common!6FD51180 / D2Game!6FC211D0(objdump -d,D2Common 基址 0x6FD50000、D2Game 0x6FC10000)
本 issue 结论强度 逐指令确证(PRNG 内核与 seed 归属均直接读自反汇编)

[!WARNING]
D2MOO 是 1.10f,不是本项目的金标准。 它只能作线索使用。任何涉及常量、列序、整数截断位置或 RNG 消耗次数的结论,都必须回到 1.13c 的表格或 DLL 复核后再落地。

## 问题 现在 [`src/game/rng.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/branch/main/src/game/rng.ts) 用的是 **mulberry32**(32 位状态、`state += 0x6D2B79F5` + 两轮 xorshift/imul、返回 `[0,1)` 浮点、全局流 + `fork(label)`)。 D2 1.13c 用的是完全不同的东西。三个维度都不兼容: | 维度 | 1.13c | 现状 mulberry32 | 后果 | |---|---|---|---| | 状态 | **64 bit**(lo/hi) | 32 bit | 周期与序列完全不同 | | 取整 | `lo % max`(2 的幂走 AND) | `floor(next()*span)` | 偏差方向不同,同 seed 不可能对齐 | | 归属 | **每个 unit 一条流** | 全局 + fork | 无法复现「同一只怪的掉落序列」 | 净效果:**永久失去与 1.13c 对拍的能力**。对 M10 这种「以原版为金标准」性质的里程碑,这是不可接受的。 ## 金标准数据 `D2Common!6FD510B0` 的反汇编(另有 `6FD51100`、`6FD51180` 变体;`D2Game!6FC211D0`/`6FC21180` 是完全相同的副本,此外还有大量**内联展开**点): ```asm mov (%ecx),%eax ; lo = seed->lo mov $0x6ac690c5,%edx mul %edx ; edx:eax = lo * 0x6AC690C5 (无符号 32x32->64) mov 0x4(%ecx),%edi ; hi = seed->hi add %edi,%eax adc %ebx,%edx ; 64 位加 hi mov %eax,(%ecx) ; seed->lo = low32 mov %edx,0x4(%ecx) ; seed->hi = high32 ``` 即: ``` state64 = (u64)lo * 0x6AC690C5 + hi lo = state64 & 0xFFFFFFFF hi = state64 >>> 32 ``` 三个 API: | API | 语义 | |---|---| | `Rand(seed)` | 只推进,返回 `lo`(内联点常直接 `lo & 0x3FF` 做 1024 判定) | | `Rand(seed, max)` | `max <= 0` → 0;**max 是 2 的幂 → `lo & (max-1)`**;否则 → `lo % max` | | `Rand(seed, min, max)` | `min >= max` → `min`;否则 `span = max-min+1`,返回 `min + lo % span`(或 `min + (lo & (span-1))`) | **seed 的归属**(确证): - 每个 unit 自带 seed:`UnitAny + 0x20`(`dwSeed[2]` = lo/hi);`+0x28` 是初始 seed。 - **整条掉落链**(NoDrop 判定、TC 条目抽取、uber/ultra 判定、RollQuality)全部用**死掉的那只怪**的 seed —— 代码反复 `lea 0x20(%unit)` 后调 RNG。 - 物品创建后的**词缀与属性 roll 用物品自己的 seed**。这正是 `.d2s` 只存 32 位 id 就能完整复原词缀数值的原因。 ## 实现要求 1. 新增 `D2Rng` 类,内部持 `{ lo: number, hi: number }`(两个 u32)。 2. 32×32→64 乘法用 `Math.imul` 拆 16 位手写,**不要用 BigInt** —— 掉落是热点路径,BigInt 会慢一个数量级。参考写法:把 `lo` 拆成 `lo & 0xFFFF` 与 `lo >>> 16`,与常量的高低 16 位做四次 `Math.imul`,再拼回 64 位。 3. 实现上表三个 `Rand` 重载,**严格区分 2 的幂与非 2 的幂两条分支**。 4. 掉落 / 物品 / 怪物三处全部改走 `D2Rng`,每个 unit 持有自己的 seed 实例。 5. mulberry32 **保留**,但只服务表现层抖动(动画、粒子等与游戏逻辑无关的地方)。 ## 陷阱 > [!CAUTION] > **不能只换底层不换取整方式。** 现在 `Rng.int()` 是 `floor(next() * span)`,与 `lo % span` **不等价** —— 前者是从浮点均匀分布截断,后者带模偏差。只替换 PRNG 内核而保留 `floor(next()*span)` 的调用方式,仍然对不上原版。 > [!CAUTION] > `lo % max` 的**模偏差是原版行为的一部分**,不要「改良」成拒绝采样。 > [!WARNING] > 这个 issue 会改掉所有掷骰点的调用签名,**必须在 T-13 / T-14 / T-24 之前完成**,否则要返工两遍。 ## 验收标准 - [ ] 固定 seed 下,`state64 = lo * 0x6AC690C5 + hi` 的前 N 个输出与手算序列**逐位相等**(写成单测的已知向量) - [ ] `Rand(seed, 1024)` 走 AND 分支、`Rand(seed, 1000)` 走取模分支,两条分支都有覆盖 - [ ] `Rand(seed, min, max)` 在 `min >= max` 时返回 `min` - [ ] 每个 unit 的 seed 独立推进,互不干扰 - [ ] `npm run typecheck` 0 error - [ ] `npx vitest run` 全绿 - [ ] `npx tsx scripts/verify-items.ts` 新增断言通过 --- ### 溯源 | | | |---|---| | 金标准(算法) | 1.13c 反汇编:`D2Common!6FD510B0` / `D2Common!6FD51180` / `D2Game!6FC211D0`(`objdump -d`,D2Common 基址 `0x6FD50000`、D2Game `0x6FC10000`) | | 本 issue 结论强度 | **逐指令确证**(PRNG 内核与 seed 归属均直接读自反汇编) | > [!WARNING] > **D2MOO 是 1.10f,不是本项目的金标准。** 它只能作线索使用。任何涉及常量、列序、整数截断位置或 RNG 消耗次数的结论,都必须回到 1.13c 的表格或 DLL 复核后再落地。
troytt added this to the [M10] 真实物品系统:500 基础物品、1000 词缀、TreasureClassEx 掉落树 milestone 2026-09-18 10:50:43 +00:00
Author
Owner

已完成并通过 1.13c 逐位对齐测试,合并入 main (commit 928d422)。

已完成并通过 1.13c 逐位对齐测试,合并入 main (commit 928d422)。
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#87
No description provided.