Path of Building 修改符计算引擎拆解:Mod 模型、ModDB 求值与缓存如何协作
【免费下载链接】PathOfBuildingOffline build planner for Path of Exile.项目地址: https://gitcode.com/GitHub_Trending/pa/PathOfBuilding
Path of Building 是《流放之路》的离线构建规划器,它的核心不是一张树图,而是一台计算引擎:被动树节点、装备词条、技能宝石、光环与诅咒全部被归一成同一种 Mod 结构,由 ModDB 按类型求值,最终产出 DPS 与防御面板。生成文件 src/Data/ModCache.lua 有 23215 行,正是这台引擎把"解析"从运行时热路径上挪走的证据。本文带你沿一条词条走完从游戏文本到面板数字的链路,并逐组件拆解 src/Modules/ModParser.lua、src/Classes/ModDB.lua、src/Modules/CalcPerform.lua 各自的红线与协作方式。
为什么不能为每条词条写一个 if?
朴素做法有三条,都走不通。第一条,给每个技能写 if/else 伤害分支——但 PoE 每赛季都引入新机制,3.29 "Allflame" 一个赛季就带来了 Pacts、Vestigial、Intangibility 解析、Abyss 永恒宝石等一整批新词条,分支写法每赛季重写一遍。第二条,每条新词条写一个解析函数——同一措辞会出现在装备、宝石、天赋三处,三份实现必然漂移。第三条,每次启动都重新解析全库——PoB 每次打开都要算满屏技能,逐条跑正则会成为冷启动瓶颈。
PoB 的答案是把问题拆成三段:
统一数据模型——无论词条来自哪里,都归一成mod(ModName, ModType, Value, source, modFlags, keywordFlags, extraTags)这一个结构,ModType 只有 BASE / INC / MORE / OVERRIDE / FLAG(外加 MAX / MIN)几种,求值逻辑只认类型,不认来源。
解析与求值分离——文本→Mod 的解析是纯函数,产物可以缓存;求值只消费 Mod,永远不回头看文本。
解析产物持久化——解析结果被预生成进 src/Data/ModCache.lua,运行时优先查表,正则解析只作未命中的兜底。
| 组件 | 位置(文件/模块) | 一句话职责 | 红线职责(绝不做的事) |
|---|---|---|---|
| ModParser | src/Modules/ModParser.lua | 把词条文本扫成 Mod 结构 | 绝不参与运行时求值,绝不决定数值 |
| ModCache | src/Data/ModCache.lua | 存放"文本→Mod"的预解析结果 | 绝不允许手写,只能由 ModParser 生成 |
| ModDB | src/Classes/ModDB.lua | 按名分桶、按 ModType 聚合求值 | 绝不做文本解析,绝不算总 DPS |
| CalcPerform / CalcOffence | src/Modules/CalcPerform.lua | 驱动 hit speed / damage / ailments 各 pass | 绝不解析词条,绝不在 pass 内偷改全局输出 |
一条词条如何变成 DPS 面板上的数字?
拿 Solidity 天赋的 "Attack skills deal 20% increased damage while holding a Shield" 走一遍,共七步:
- 加载构建:查 ModCache,词条文本命中缓存表;
- 未命中才落到 ModParser 的五级扫描,产出
mod("Damage","INC",20,…); - 带上 ModFlag 与
{ type="Condition", var="UsingShield" }标签; - 挂进该构建的 ModDB,按名字入
mods["Damage"]桶; - CalcPerform 先
mergeKeystones、初始化 minion 的 modDB、对三套 DB 做applyEnemyModifiers; - CalcOffence 在 damage pass 里以
1 + Sum("INC")/100 × More(...)聚合求值,条件为真才计入; - 输出写入
output,FullDPS 再按技能逐个重算、汇总各源 DPS。
容易忽略的两个协调细节:
- ModCache 不是手写的,而是开发模式下
Ctrl+F5时由 ModParser 全量重跑生成的产物,提交前必须确认它已同步; - 同一套 ModDB 类同时实例化给 player、minion、enemy 三方,但桶与桶之间绝不串用——诅咒打在敌人身上,用的是 enemyDB 的求值结果。
ModParser:扫描、标记、分桶——但绝不算数
职责红线:
- 绝不参与运行时求值,它的唯一产出是 Mod 结构;
- 绝不在每次启动时对全库重新解析,热路径永远先查缓存;
- 绝不手写 ModCache 里的任何一行,那是生成文件。
关键流程:
preFlagList先扫attack skills deal,存下KeywordFlag.Attack。先摘标志而不是最后补,因为标志决定后续所有匹配的 ModFlag 语境。preSkillNameList看行首是否为技能名,主要用于武器附魔;不是就原样继续。formList命中^(%d+)%% increased,ModType 定成 INC、数值 20 先存进modValue。先定形式再谈内容,形式错了后面全是错的。modTagList走两遍,摘出while holding a shield转成 Condition 标签。走两遍是为了让一条词条最多挂两个限制条件。- 剩下的
damage由modNameList映射为 Mod 名 "Damage",全行消费完毕。
设计细节:文档原话是——"Since mods in most builds contain just the raw mod text, and are not tied to the underlying mod from GGG's data, we need to keep old mod wordings intact so those keep working on older builds." 白话说:构建文件里存的是词条原文而不是数据 ID,老措辞一旦删掉,老构建就废了,所以解析必须向后兼容,而不是跟着游戏文案"清理"旧词条。兜底的specialModList只留给既不标准又不够多的少数词条,文档明确要求"加新词条先想 flag 和 tag,再考虑进 specialModList"。
ModDB:分桶、聚合、覆盖——但绝不碰文本
职责红线:
- 绝不做词条文本解析,进来就只能是 Mod 结构;
- 绝不算总 DPS,它是 actor 级账本,汇总是 CalcOffence 的事;
- 绝不让子库的桶泄漏到父库,parent 只用于"向上查不到时继续找"。
关键流程:
AddMod把 Mod 塞进self.mods[name]列表。按名分桶后,求值只扫一个桶,而不是全表。EvalMod/Sum("INC")/More按 ModType 聚合:BASE 直接求和,INC 求和后 +100 变倍率,MORE 连乘——这正是 PoE 游戏内"加法堆、乘法乘"的语义。ReplaceModInternal与ConvertModInternal在同桶找不到匹配项时沿 parent 链上溯。宝石的"替换某节点词条""把 Str 转成 All"这类语义,本质就是跨桶的移动,向上找让子 DB 能改写父 DB 里的条目。- player、minion、enemy 各持一套实例;minion 初始化时直接按怪物常数表填基础属性,例如基础暴伤倍率来自
base_critical_strike_multiplier。
设计细节:minion 的 modDB 不是复制玩家的再打补丁,而是独立建库、按怪物等级查monsterConstants填底。这是刻意的——仆从的生命成长曲线、抗性、暴伤与玩家完全是两套数据,共享一个库会让"玩家的 Onslaught 是否传给骷髅"这类条件判定全部失真。
CalcPerform:合并、初始化、跑 pass——但绝不在 pass 里偷改全局输出
职责红线:
- 绝不做任何文本解析,它拿到的全部是 ModDB 与技能数据;
- 绝不在 pass 循环内隐式修改全局
output,双手的值必须显式合并; - 绝不跨技能复用未标记的缓存。
关键流程:
mergeKeystones合并基石词条,并复制出每个主动技能的skillModList。基石会改写其他节点的词条,必须在任何求值前落库。- 为主技能若有 minion,初始化
minion.modDB并createMinionSkills。 - 对 player / minion / enemy 三套 DB 依次
applyEnemyModifiers。 - 进入 src/Modules/CalcOffence.lua 的三组 pass:hit speed 与命中、damage、ailments 与 debuff;攻击技能对主手、副手各跑一遍,非攻击只跑一遍。
- 每轮 pass 结束后用
combineStat把MainHand/OffHand的结果合回全局output,FullDPS 再按技能 UUID 取缓存或重算。
设计细节:docs/calcOffence.md 原句是——"any value added to output inside of a damage pass may not be added onto the main or global output",白话说:pass 内你写的output.X实际落在actor.output.MainHand.X或OffHand.X,不显式合并就会静默丢失。这个坑被写成文档而不是留在代码注释里,因为它的症状是"数值对不上但没有任何报错",只有文档能拦住下一个踩坑的人。
值得借鉴的细节
- 📦先定数据模型,再加解析器:所有来源归一成同一种 mod 结构后,求值只认 ModType 不认来源,新词条进来几乎不动计算核心。
- ⚡把最贵的一步从热路径搬走:23215 行的 ModCache 是预解析产物,运行时只剩"查表未命中才跑正则",冷启动与重算都受益。
- 🔁缓存键必须包含全部影响因子:
calcFullDPS按技能 UUID 取缓存并用limitedSkills防无限递归,注释里写明了原因——缓存键漏掉一个因子,比较结果就是"A 和 A 比",差异全为零。
快速上手
git clone -b dev https://gitcode.com/GitHub_Trending/pa/PathOfBuilding.git # 取开发分支 cd PathOfBuilding && ./runtime/Path{space}of{space}Building.exe # 从仓库运行即自动进入 Dev Mode # 修改 ModParser 后:按 Ctrl + F5 重新生成 ModCache,用 Ctrl + ` 打开控制台核对改了 src/Modules/ModParser.lua 之后必须重新生成 src/Data/ModCache.lua 并随 PR 一起提交——运行时靠它做缓存命中,漏交意味着你的修复在真实环境里根本不会生效。同理,src/Data下所有带 "This file is automatically generated, do not edit!" 头部的文件都不许手改,要动就去改 src/Export 里的导出脚本再重跑。
延伸阅读:Mod 语法总表见 docs/modSyntax.md,解析管线与兼容性注意事项见 docs/addingMods.md,伤害 pass 的 output 陷阱见 docs/calcOffence.md,开发环境搭建见 CONTRIBUTING.md——四份文档读完,这台计算引擎的每一条红线你都能自己指出来。
【免费下载链接】PathOfBuildingOffline build planner for Path of Exile.项目地址: https://gitcode.com/GitHub_Trending/pa/PathOfBuilding
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考