[P0][T13] 实现 TreasureClassEx 展开:64 帧显式栈 + 预计算前缀和二分 + NoDrop 浮点缩放 #101

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

问题

rollDrop 现在是三行:

if (!rng.chance(options.dropChance)) return { kind: 'nothing' }
if (rng.chance(options.goldChance))  return { kind: 'gold', ... }
const eligible = bases.filter(base => base.level <= options.level)
const base = rng.pick(eligible.length > 0 ? eligible : bases)

与 D2 的模型毫无关系。本 issue 实现真正的 TC 展开。

金标准数据(全部逐指令确证)

主循环在 D2Game!6FC32380,入口包装在 D2Game!6FC32D60。

调用链

怪物死亡
  │ 选 TC 索引:MonStats.txt 的 TreasureClass1..4 / 1..4(N) / 1..4(H)
  │   (4 列按怪物类型选:1=普通 2=Champion 3=Unique 4=任务掉落;(N)/(H) 按难度)
  ▼
D2Game!6FC32D60(pGame, pUnit, nTcIndex, …)
  │ ① 若 expansion 且 pUnit 是怪物:nLevel = GetStat(pUnit, 12 /*level*/)
  │ ② pTC = ResolveTreasureClass(nTcIndex, nLevel)   // TC group 升级
  │      —— 同 group 内取 level <= 怪物等级的最高一档
  ▼
D2Game!6FC32380(pGame, pMonster, pTC, …, ppOut[], nMaxItems)

⭐ NoDrop 与队伍人数缩放(x87 双精度)

n = 有效玩家数
if (NoDrop != 0 && n > 1):
    r       = NoDrop / (NoDrop + TotalProb)          // double
    NoDrop' = trunc( TotalProb * r^n / (1 - r^n) )   // ftol,向零截断
    if (1 - r^n == 0.0) NoDrop' = 0
else:
    NoDrop' = NoDrop

// 有效玩家数(整数运算)
p = 队伍人数(取杀怪归属玩家所在队伍);无玩家 / 非玩家 / p<=1 → p = 1;否则 p = min(p, 8)
g = 本局游戏内玩家数(/players X 的口径)
n = p + (g - p) / 2                       // 整数除,向零截断
if (pMonster 是怪物) n = max(n, GetStat(pMonster, 100 /* monster_playercount */))
if (n <= 1) 不缩放

stat 100 = monster_playercount 已在 1.13c ItemStatCost.txt 核对 ✓。

NoDrop' 在每一次 pick 都重算(纯确定性,只是重复开销)。

⭐ Picks 的两条分支

frame.picks = max(|Picks|, 1)

// ── Picks >= 0:随机抽,抽 Picks 次 ──
roll = Rand(monster.seed, NoDrop' + TotalProb)
if (roll < NoDrop') { frame.picks--; 本次不掉; continue }
roll -= NoDrop'
entry = 累计概率二分(roll)
frame.picks--
(若上层传入「忽略 NoDrop」标志,则 roll = Rand(seed, TotalProb),不减)

// ── Picks < 0:确定性枚举,【完全绕过 NoDrop 与 RNG】──
i = |Picks| - frame.picks        // 第 0, 1, 2, … 次
if (i >= TotalProb) 结束本层
entry = 累计概率二分(i)
frame.picks--

等价于「第 k 个条目掉 Prob_k 次」。1.13c 数据里 Champ / Unique / Super / Countess / Griswold / Smith 这 184 行(21.6%)就是这么配的。

⭐ 64 帧显式栈(不是递归)

  • 64 帧 × 20 字节 { pTC(4), picksRemaining(4), qualityFactors 6×u16(12) },放在 0x52C 字节栈帧里。
  • depth >= 0x40 → 断言失败并 FatalExit(断言行号 0xFCD = 4045)。上限 64,没有环检测。
  • 只有父帧还有剩余 picks 时才压栈;父帧 picks 已归零则子帧复用同槽位(尾调用优化)。
  • 子 TC 解析:ResolveTreasureClass(entry.id, 0) —— 子层不再做 group 升级。

品质因子沿链传播

child.qf[i] = (parent.qf[i] == 0) ? entry.qf[i] : max(parent.qf[i], entry.qf[i])

6 个 u16:[0]=Magic [1]=Rare [2]=Set [3]=Unique(RollQuality 读 +0/+2/+4/+6),[4] [5] 交给 T-24。

其它确证细节

  • 强制品质:entry.flags & 1 → quality = 7(unique);& 2 → quality = 5(set)。id 取 entry.param + 1,跳过 RollQuality。
  • 金币倍率:gold = GetStat(item, 14 /*gold*/) * param >> 8 —— 这就是 gld,mul=N 的实现(N/256,所以 1280/1536/2048 → ×5/×6/×8)。
  • classic 过滤:非扩展游戏命中 version >= 100 的物品时退回重抽,全函数累计重抽上限 10 次,超出后用硬编码回退码(字节为 "lsd ",在 1.13c 三张表里查不到 —— 【存疑】)。

定点验算

Act 1 H2H A:NoDrop=100,ΣProb = 21+16+21+2 = 60,分母 160 → 62.5% 空掉落(单人)。
Cow (H):NoDrop=100,ΣProb = 19+19+19+3 = 60 → 同样 62.5%。

实现要求

  1. 新建 src/game/treasure.ts(母 issue 指定的文件名)。
  2. 用显式栈实现,不要用语言递归 —— 要能精确复现「父帧 picks 归零则复用槽位」的行为。
  3. 深度上限 64,额外加一个总步数预算(见陷阱)。
  4. NoDrop 缩放按上面的 double 公式,r^n 建议按反汇编的 8 连乘展开复现,最后 Math.trunc。
  5. 用 T-11 预计算的累计概率前缀和做二分,不要每次线性累加。
  6. 所有掷骰走 T-23 的 D2Rng,用怪物的 seed。
  7. 品质因子沿链 max 传播,输出给 T-14 / T-24。

陷阱

[!CAUTION]
Picks < 0 时 NoDrop 完全无效,且一次 RNG 都不消耗。 这是简化实现最常见的错误点。如果这里写错,所有精英 / Boss / 女伯爵的掉落都会偏。

[!CAUTION]
「父帧 picks 归零则复用槽位」会导致 Picks=1 的自引用 TC 原地死循环,而不是撑爆 64 层深度。 官方数据没有这种配置,但自研引擎必须加总步数预算,否则一条脏数据能挂掉整个游戏。

[!CAUTION]
NoDrop 是并入分母参赛的权重,不是「先 roll 一次 NoDrop 再 roll 物品」。分母是 NoDrop' + TotalProb。

[!WARNING]
group 升级只在顶层做一次,子 TC 解析时传 level=0 不再升级。

[!WARNING]
Act 1 Champ A 的 Picks=-2 而 ΣProb=3。在「以 TotalProb 为界枚举」的模型下这不是数据错误 —— |Picks| 只是初始计数,实际枚举边界是 TotalProb。不要用 |Picks| 去切分条目。

[!NOTE]
TC 展开函数在 1.13c 里有两个调用者:6FC32DDB(怪物/对象掉落)与 6FC8B6D0(另一子系统,疑似宝箱 / 可破坏对象 / 任务掉落)。本 issue 只做前者。

验收标准

  • Act 1 H2H A 单人空掉落率实测收敛到 62.5%(±统计误差)
  • Cow (H) 同样收敛到 62.5%
  • Act 1 H2H A 的完整展开与手算概率树一致(gld 13.125% / Act 1 Equip A 10% / Act 1 Junk 13.125% / Act 1 Good 1.25%)
  • Countess (H)(Picks=-2)必定各掉 1 组 Item + 1 组 Rune,且不消耗 RNG
  • 多人缩放:2 人 / 4 人 / 8 人下 NoDrop' 与手算 double 公式逐位一致
  • 深度 > 64 时抛出明确错误而非静默截断
  • 人造自引用 TC 能被总步数预算拦住,不死循环
  • gld,mul=2048 产出 ×8 金币
  • 强制 unique / set 的条目跳过 RollQuality
  • npm run typecheck 0 error
  • npx vitest run 全绿
  • npx tsx scripts/verify-items.ts 新增断言通过

溯源

金标准(数据) 1.13c MPQ data\global\excel\ 下的 TreasureClassEx.txt / MonStats.txt / ItemStatCost.txt(本机 /usr/local/google/home/taodao/d2-data,挂载序 d2data → d2exp → Patch_D2,Patch_D2 优先)
金标准(算法) 1.13c 反汇编:D2Game!6FC32380 / D2Game!6FC32D60 / D2Game!6FC32690(objdump -d,D2Common 基址 0x6FD50000、D2Game 0x6FC10000)
本 issue 结论强度 逐指令确证(NoDrop 公式、Picks 双分支、64 帧栈、品质因子传播均直接读自反汇编);"lsd " 回退码【存疑】

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

## 问题 [`rollDrop`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/branch/main/src/game/items.ts) 现在是三行: ```ts if (!rng.chance(options.dropChance)) return { kind: 'nothing' } if (rng.chance(options.goldChance)) return { kind: 'gold', ... } const eligible = bases.filter(base => base.level <= options.level) const base = rng.pick(eligible.length > 0 ? eligible : bases) ``` 与 D2 的模型毫无关系。本 issue 实现真正的 TC 展开。 ## 金标准数据(全部逐指令确证) 主循环在 `D2Game!6FC32380`,入口包装在 `D2Game!6FC32D60`。 ### 调用链 ``` 怪物死亡 │ 选 TC 索引:MonStats.txt 的 TreasureClass1..4 / 1..4(N) / 1..4(H) │ (4 列按怪物类型选:1=普通 2=Champion 3=Unique 4=任务掉落;(N)/(H) 按难度) ▼ D2Game!6FC32D60(pGame, pUnit, nTcIndex, …) │ ① 若 expansion 且 pUnit 是怪物:nLevel = GetStat(pUnit, 12 /*level*/) │ ② pTC = ResolveTreasureClass(nTcIndex, nLevel) // TC group 升级 │ —— 同 group 内取 level <= 怪物等级的最高一档 ▼ D2Game!6FC32380(pGame, pMonster, pTC, …, ppOut[], nMaxItems) ``` ### ⭐ NoDrop 与队伍人数缩放(x87 双精度) ``` n = 有效玩家数 if (NoDrop != 0 && n > 1): r = NoDrop / (NoDrop + TotalProb) // double NoDrop' = trunc( TotalProb * r^n / (1 - r^n) ) // ftol,向零截断 if (1 - r^n == 0.0) NoDrop' = 0 else: NoDrop' = NoDrop // 有效玩家数(整数运算) p = 队伍人数(取杀怪归属玩家所在队伍);无玩家 / 非玩家 / p<=1 → p = 1;否则 p = min(p, 8) g = 本局游戏内玩家数(/players X 的口径) n = p + (g - p) / 2 // 整数除,向零截断 if (pMonster 是怪物) n = max(n, GetStat(pMonster, 100 /* monster_playercount */)) if (n <= 1) 不缩放 ``` `stat 100 = monster_playercount` 已在 1.13c `ItemStatCost.txt` 核对 ✓。 **`NoDrop'` 在每一次 pick 都重算**(纯确定性,只是重复开销)。 ### ⭐ Picks 的两条分支 ``` frame.picks = max(|Picks|, 1) // ── Picks >= 0:随机抽,抽 Picks 次 ── roll = Rand(monster.seed, NoDrop' + TotalProb) if (roll < NoDrop') { frame.picks--; 本次不掉; continue } roll -= NoDrop' entry = 累计概率二分(roll) frame.picks-- (若上层传入「忽略 NoDrop」标志,则 roll = Rand(seed, TotalProb),不减) // ── Picks < 0:确定性枚举,【完全绕过 NoDrop 与 RNG】── i = |Picks| - frame.picks // 第 0, 1, 2, … 次 if (i >= TotalProb) 结束本层 entry = 累计概率二分(i) frame.picks-- ``` 等价于「第 k 个条目掉 `Prob_k` 次」。1.13c 数据里 Champ / Unique / Super / Countess / Griswold / Smith 这 **184 行**(21.6%)就是这么配的。 ### ⭐ 64 帧显式栈(不是递归) - 64 帧 × 20 字节 `{ pTC(4), picksRemaining(4), qualityFactors 6×u16(12) }`,放在 0x52C 字节栈帧里。 - `depth >= 0x40` → 断言失败并 `FatalExit`(断言行号 0xFCD = 4045)。**上限 64,没有环检测。** - **只有父帧还有剩余 picks 时才压栈**;父帧 picks 已归零则子帧**复用同槽位**(尾调用优化)。 - 子 TC 解析:`ResolveTreasureClass(entry.id, 0)` —— **子层不再做 group 升级**。 ### 品质因子沿链传播 ``` child.qf[i] = (parent.qf[i] == 0) ? entry.qf[i] : max(parent.qf[i], entry.qf[i]) ``` 6 个 u16:`[0]=Magic [1]=Rare [2]=Set [3]=Unique`(RollQuality 读 +0/+2/+4/+6),`[4]` `[5]` 交给 T-24。 ### 其它确证细节 - **强制品质**:`entry.flags & 1` → quality = 7(unique);`& 2` → quality = 5(set)。id 取 `entry.param + 1`,**跳过 RollQuality**。 - **金币倍率**:`gold = GetStat(item, 14 /*gold*/) * param >> 8` —— 这就是 `gld,mul=N` 的实现(N/256,所以 1280/1536/2048 → ×5/×6/×8)。 - **classic 过滤**:非扩展游戏命中 `version >= 100` 的物品时**退回重抽**,全函数累计重抽上限 10 次,超出后用硬编码回退码(字节为 `"lsd "`,在 1.13c 三张表里查不到 —— 【存疑】)。 ### 定点验算 `Act 1 H2H A`:`NoDrop=100`,`ΣProb = 21+16+21+2 = 60`,分母 160 → **62.5% 空掉落**(单人)。 `Cow (H)`:`NoDrop=100`,`ΣProb = 19+19+19+3 = 60` → 同样 **62.5%**。 ## 实现要求 1. 新建 `src/game/treasure.ts`(母 issue 指定的文件名)。 2. 用**显式栈**实现,不要用语言递归 —— 要能精确复现「父帧 picks 归零则复用槽位」的行为。 3. 深度上限 64,**额外加一个总步数预算**(见陷阱)。 4. NoDrop 缩放按上面的 double 公式,`r^n` 建议按反汇编的 8 连乘展开复现,最后 `Math.trunc`。 5. 用 T-11 预计算的累计概率前缀和做**二分**,不要每次线性累加。 6. 所有掷骰走 T-23 的 `D2Rng`,**用怪物的 seed**。 7. 品质因子沿链 `max` 传播,输出给 T-14 / T-24。 ## 陷阱 > [!CAUTION] > **`Picks < 0` 时 NoDrop 完全无效,且一次 RNG 都不消耗。** 这是简化实现最常见的错误点。如果这里写错,所有精英 / Boss / 女伯爵的掉落都会偏。 > [!CAUTION] > **「父帧 picks 归零则复用槽位」会导致 `Picks=1` 的自引用 TC 原地死循环,而不是撑爆 64 层深度。** 官方数据没有这种配置,但自研引擎**必须加总步数预算**,否则一条脏数据能挂掉整个游戏。 > [!CAUTION] > **NoDrop 是并入分母参赛的权重**,不是「先 roll 一次 NoDrop 再 roll 物品」。分母是 `NoDrop' + TotalProb`。 > [!WARNING] > **group 升级只在顶层做一次**,子 TC 解析时传 level=0 不再升级。 > [!WARNING] > `Act 1 Champ A` 的 `Picks=-2` 而 `ΣProb=3`。在「以 `TotalProb` 为界枚举」的模型下这不是数据错误 —— `|Picks|` 只是初始计数,实际枚举边界是 `TotalProb`。不要用 `|Picks|` 去切分条目。 > [!NOTE] > TC 展开函数在 1.13c 里有两个调用者:`6FC32DDB`(怪物/对象掉落)与 `6FC8B6D0`(另一子系统,疑似宝箱 / 可破坏对象 / 任务掉落)。本 issue 只做前者。 ## 验收标准 - [ ] `Act 1 H2H A` 单人空掉落率实测收敛到 **62.5%**(±统计误差) - [ ] `Cow (H)` 同样收敛到 **62.5%** - [ ] `Act 1 H2H A` 的完整展开与手算概率树一致(`gld` 13.125% / `Act 1 Equip A` 10% / `Act 1 Junk` 13.125% / `Act 1 Good` 1.25%) - [ ] `Countess (H)`(`Picks=-2`)**必定**各掉 1 组 Item + 1 组 Rune,且**不消耗 RNG** - [ ] 多人缩放:2 人 / 4 人 / 8 人下 NoDrop' 与手算 double 公式逐位一致 - [ ] 深度 > 64 时抛出明确错误而非静默截断 - [ ] 人造自引用 TC 能被总步数预算拦住,不死循环 - [ ] `gld,mul=2048` 产出 ×8 金币 - [ ] 强制 unique / set 的条目跳过 RollQuality - [ ] `npm run typecheck` 0 error - [ ] `npx vitest run` 全绿 - [ ] `npx tsx scripts/verify-items.ts` 新增断言通过 --- ### 溯源 | | | |---|---| | 金标准(数据) | 1.13c MPQ `data\global\excel\` 下的 `TreasureClassEx.txt` / `MonStats.txt` / `ItemStatCost.txt`(本机 `/usr/local/google/home/taodao/d2-data`,挂载序 d2data → d2exp → Patch_D2,**Patch_D2 优先**) | | 金标准(算法) | 1.13c 反汇编:`D2Game!6FC32380` / `D2Game!6FC32D60` / `D2Game!6FC32690`(`objdump -d`,D2Common 基址 `0x6FD50000`、D2Game `0x6FC10000`) | | 本 issue 结论强度 | **逐指令确证**(NoDrop 公式、Picks 双分支、64 帧栈、品质因子传播均直接读自反汇编);`"lsd "` 回退码【存疑】 | > [!WARNING] > **D2MOO 是 1.10f,不是本项目的金标准。** 它只能作线索使用。任何涉及常量、列序、整数截断位置或 RNG 消耗次数的结论,都必须回到 1.13c 的表格或 DLL 复核后再落地。
troytt added this to the [M10] 真实物品系统:500 基础物品、1000 词缀、TreasureClassEx 掉落树 milestone 2026-09-18 10:50:50 +00:00
Author
Owner

已完成 TreasureClassEx 64层调用栈、Picks<0 确定性展开与 Picks>=0 玩家数 NoDrop 缩放、品质因子继承与金币倍率解析,合并入 main (commit bc05af8)。

已完成 TreasureClassEx 64层调用栈、Picks<0 确定性展开与 Picks>=0 玩家数 NoDrop 缩放、品质因子继承与金币倍率解析,合并入 main (commit bc05af8)。
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#101
No description provided.