koishi-plugin-simc
v0.9.1
Published
本地 SimulationCraft 模拟插件(简化版 Raidbots):QuickSim / GearCompare / TopGear / Advanced,调用本机 SimC 二进制,不依赖任何网页 API。
Maintainers
Readme
koishi-plugin-simc(本地 SimC 模拟插件 / 简化版 Raidbots)
调用本机自行编译的 SimulationCraft(SimC)二进制完成魔兽世界角色模拟。不调用 Raidbots 网页 API、不联网。
⚠️ 仅为 SimC 理论模拟,不等于实战结果。治疗专精模拟结果不可靠,建议改用 QE-Live。
命令
| 命令 | 说明 |
| --- | --- |
| simc.quicksim [参数] | 先下模拟参数(可留空),再把字符串给它(网页粘贴 / 发 txt)。别名 simc.quick |
| simc.compare [参数] | 同上,配置多组用单独一行 --- 分隔 |
| simc.topgear [参数] | 同上,内容为「基础角色 + --- + 备选装备」 |
| simc.advanced <script> | 高级执行,直接运行原始 SimC 脚本并返回原始输出 |
| simc.status | 查看模拟队列状态(运行中 / 排队中 / 最大并发) |
| simc.cores [参数] | 线程数自检:带上自己的角色字符串,串行实测几档线程数的墙钟,给出 simThreadBudget 该填多少的建议(见下文「线程数怎么定」) |
| simc.diag | 全量自检:配置 / 二进制 / 【算力】/ 专精图标 / 卡片发送逐项验证 |
通用选项(QuickSim / Compare / TopGear):
-f, --fight-style <style>:战斗类型,见下-l, --length <秒>:战斗时长-e, --enemies <数量>:敌人数量(AoE 场景)-i, --iterations <次数>:固定迭代次数-s, --smart:SmartSim 模式(用 target_error 代替固定迭代)-n, --no-buffs:关闭团队 buff-b, --binary <路径>:临时指定本次使用的 SimC 二进制(即可选不同版本)
战斗类型枚举:Patchwerk / CastingPatchwerk / HecticAddCleave / CleaveAdd / LightMovement / HeavyMovement / DungeonSlice
(以 src/types.ts 的 FIGHT_STYLES 为准,它必须与 SimC 源码 engine/util/util.cpp 的 fight_style_string() 对齐——ExecutePatchwerk 在 1210 已被 SimC 删除)
两步走:先下参数,再给字符串(推荐)
SimC 角色字符串长到只能走网页或 txt 文件,没法和参数挤在同一条消息里。所以顺序是「先参数、后字符串」:
① simc.quicksim 无团队buff 无合剂 5目标 木桩战 ← 不写参数 = 用插件默认配置
② 机器人:🎛️ 已记住本次模拟参数(…)/请打开网页粘贴字符串,或把字符串存成 txt 发到本频道
③ 打开它给的网页粘贴字符串(表单会按 ① 的参数预填),或把导出的 txt 发进聊天
④ 收到字符串 → 按 ① 的参数开跑,结果卡片发回本频道- 参数会被记 15 分钟,一次有效(用完即清),所以「发一次命令、之后每个 txt 都被套用旧参数」不会发生。
- 参数按 平台 + 频道 + 用户 隔离,别人发的 txt 不会吃掉你的参数。
- 没先发命令就发 txt:会提示你补发命令并开跑(不会静默按默认参数跑掉)。想退回「丢文件直接跑」的老用法,把
autoFileRequirePending设成false。 - 网页那条路(5151)也和聊天共用同一套参数:命令里下的参数会在网页表单上预填,页面表达不了的三项(单项团队增益 / 消耗品整类开关 / 点名某一款消耗品)由服务端合并。
也可以在命令里直接贴字符串一步到位(老用法,仍然支持):字符串 + 换行 + 中文选项,或只贴字符串。
- 写法:带汉字的行一定是中文参数;纯 ASCII 但没有
=的行(smart/no buff)也算中文参数;剩下的才当角色字符串。#注释行和---分隔始终算角色字符串。 - 支持:
战斗类型
木桩战/大秘境/频繁移动/顺劈/混乱/施法木桩(长词优先,不会被截成半截)目标数
5目标/五个目标/目标5(1–50)、战斗时长3分钟/180秒、迭代次数20000次/1万次团队增益总开关:
无团队buff/要团队buff;否定写法一并支持别开/不要开/不开/屏蔽/no职业自带增益:直接写职业名。
法师团队buff= 只开奥术智慧、战士团队buff= 只开战斗怒吼 + 致死之伤。单写法师则只叠一层奥术智慧、不动总开关。(圣骑士 / 死亡骑士 / 术士 在 SimC 那 12 项里确实没有专属项,会明确告诉你,不会瞎猜)单关某一职业:
不要法师团队buff= 只关奥术智慧,其余团队增益照样开(不动总开关——这句的言下之意就是「其他的还要」)其余全开 / 全关:
其他buff都要/其他buff都不要(常和上一行连用:不要法师团队buff其他buff都要)单项增益:
嗜血/英勇/有嗜血/奥术智慧/战斗怒吼/野性印记/天怒图腾/青铜龙的祝福…;爆发映射到嗜血类团队增益(SimC 没有「禁用爆发技能」这个开关,回显里会说明)消耗品整类:
无合剂/无食物/无药水/无符文(只能关,因为默认就是按最优配置带满——写肯定式会告诉你这句)点名喝哪一款合剂:
喝暴击合剂/喝急速合剂/喝精通合剂/喝全能合剂。写「属性 + 合剂」就行,不用记物品名。当前资料片(12.1)的对应关系与依据:| 你写的 | 实际下发的合剂 | 属性 | | --- | --- | --- | |
喝暴击合剂| 破碎残阳合剂flask_of_the_shattered_sun_2| 暴击 | |喝急速合剂| 血骑士合剂flask_of_the_blood_knights_2| 急速 | |喝精通合剂| 魔导师合剂flask_of_the_magisters_2| 精通 | |喝全能合剂| 萨拉斯抗性合剂flask_of_thalassian_resistance_2| 全能 |点名消耗品(直接写名字,中文官方名或英文名都行):
喝圣光潜力/吃鲁莽药水/喝液态光泽/虚触强化符文/银月城浮华大餐智能/高精度(SmartSim)
- 未识别的片段一定会回显出来(不会默默忽略),所以写完看一眼回显就知道有没有生效。收文件那条路会把生效参数写在进度卡片的副标题上。
- 一句话里可以连写多个概念(
爆发合剂会拆成「爆发」+「合剂」分别处理),空格有没有都行。 - 换装(把 A 换成 B):直接写
原装备名换成新装备名,例唤波者的海石换成盖博的无底袋。名字中文官方名 / 英文名都行,走离线物品库(零请求);
换成/换为/替换成/换掉都认,带不带空格、带不带「把」都行。装等从哪来,按这个优先级:① 显式写数字(
无底袋(321)/无底袋 321/无底袋装等321)→ 用你写的;② 写「难度 + 升级次数」(无底袋英雄6/6)→ 查下面的升级轨道表换算;③ 都不写 → 继承被换掉那件的装等。数字和难度同时写时以数字为准。模式语义不同:
quicksim/compare= 直接把那一行换掉(compare的每一组都换);topgear= 新装备作为同槽位的候选,跟原装备做对比(这才是 TopGear 的用法)。换装要等字符串到了才应用 —— 要先按物品 id 找到字符串里那一行,所以它天然配合上面「先下参数、后给字符串」那套流程。
升级轨道表(至暗之夜 S2 / 12.1):难度 × 升级次数 → 装等。
老兵=随机、勇士=普通、神话=史诗(即团本那四档「随机 / 普通 / 英雄 / 史诗」);神话的 7~9 档是「晋升」,分母照游戏里写/6。| 难度 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 老兵 / 随机 | 279 | 282 | 285 | 289 | 292 | 295 | | | | | 勇士 / 普通 | 292 | 295 | 298 | 302 | 305 | 308 | | | | | 英雄 | 305 | 308 | 311 | 315 | 318 | 321 | | | | | 神话 / 史诗 | 318 | 321 | 324 | 328 | 331 | 334 | 337 | 340 | 344 |
例:
海石换成无底袋英雄6/6→ 写成ilevel=321;…神话9/6→ilevel=344。这个说法写不全时会退回「继承」并说清原因,不瞎猜:只写
英雄没写6/6→ 定位不了(各难度装等会重叠,英雄 6/6 与神话 2/6 都是 321);写英雄9/6→ 英雄只有 6 档。表在
src/itemLevel.ts的UPGRADE_TRACKS(原来在gearSwap.ts,为了让纯文本解析器nlpArgs.ts也能共用而拆出来 —— 它不许 import 到物品索引)。⚠️ 相邻档不是等差(285→289 是唯一的 +4),所以整表写死,别改成公式算。_gearswap_test.js的[G]段逐格对着整理图断言,改表必红。表里没有的轨道(
冒险者/探险者)不装懂:照旧剥掉并说明。换不成的情况一律回显:名字搜不到、同名的有多件、部位装不上、你给的字符串里没有那一件。
- 饰品 / 戒指对比(一次试一对):
饰品对比 A、B、C、D(也认饰物/指环,以及反语序对比饰品 …)。- 为什么单列:饰品和戒指各是两个槽(
trinket1+trinket2、finger1+finger2),两个槽可以各放一件不同的装备。所以这两个槽的候选合并成一个池、按「两件一起换」组合,不能当两个独立槽各搜各的 —— 那样会把其中一个槽永远钉在身上那件(这是老版本的 bug)。 - 说了「X 不变」→ 钉住 X 所在的那个槽,候选逐件换到另一个槽(N 组)。例:
饰品对比 破损的阿曼尼战旗、盖博的无底袋、米利克萨之牙、乌拉特克贪婪之心都神话6/6 虫群之瘤不变→ 4 组,每组都是「候选 + 虫群之瘤」。 - 没说「不变」→ 两两组合都试(C(N,2) 组),把身上那两件也当候选一起给上时也一样参与组合。
- 候选写法和「换装」同源:中文 / 英文名、
名字+id、纯数字 id 都行;装等同样按「显式数字 > 难度+升级次数 > 继承」来(继承取两个槽里较高的那个装等,避免看着偏低)。 - 列表末尾那一条升级描述 = 整组默认值(
… 、乌拉特克贪婪之心都神话6/6或… A、B 都神话6/6)——「加不加『都』都是这几件全都要」是用户的明确要求,所以尾巴那条一律按整组算;单件自己写了的以单件为准(A英雄3/6、B、C神话6/6→ A 用 3/6,B/C 用 6/6)。回显里会写「整组升级描述:神话6/6」,不会让你猜。 - 「都」是语法标记,会被摘掉(不摘就会去搜「乌拉特克贪婪之心都」这种不存在的名字)。认不出的描述(
英雄9/6越界、只写英雄)也照样把「都」摘掉,但不当整组默认值,并明说一句原因 —— 绝不静默。 - 组合数上限由
topGearMaxCombinations管(默认 200);超了直接报错而不是偷偷截断 —— 对比表少一件会让人看错结论。 - 一件都对不上就不展开、不跑,逐条把原因列出来(
❌开头)。 - 旧资料片 / 时光漫游物品按中文名直接写就行:本地索引只收 id≥225000 的当前资料片装备,但命令入口会拿「本地搜不到」的名字去 wago 的
ItemSparse按中文名反查(filter[Display_lang],单值可用、整条响应约 2KB),查到后落进别名表<assetsDir>/item-alias.json—— 同一个名字一辈子只联网查一次,之后纯离线。写米利克萨之牙158367或直接158367也照样认(多条路都通)。 - ⚠️ 你说了「X 不变」但那件不在身上这两个槽里时,不会退回「两两组合」(那样会把你不想换的那件也换掉),而是报错让你核对名字。
- 为什么单列:饰品和戒指各是两个槽(
网页表单(推荐:粘贴超长 SimC 字符串)
聊天里发不下完整 SimC 字符串——完整导出含背包装备可达 6000+ 字符,远超 QQ 单条文本上限(约 2000)、KOOK 文本上限(约 5000~8000)。本插件内置一个网页表单,浏览器打开即可粘贴,绕开聊天字数限制,且 QQ / KOOK 通用。
前提:启用 @cordisjs/plugin-server(你的 koishi.yml 已启用,监听端口如 5140)。插件内部通过 inject: ['server'] 声明对该服务的依赖,server 就绪后才会挂载 /simc 路由;若未启用 server,网页功能自动禁用、聊天命令不受影响。
- 打开
http://<host>:<port>/simc(路由前缀由webBasePath配置,默认/simc)。 - 选择模式(快速模拟 / 装备对比 / 最优配装 / 高级执行),粘贴字符串,点“开始模拟”。
- 对比用单独一行
---分隔多组;最优配装用---分隔基础角色与备选装备(同聊天命令格式)。 - 结果直接在页面显示,与聊天命令共用同一套队列 / 子进程 / 解析逻辑(零重复代码)。
换装备 / 加装备(试你身上没有的装备)
右栏「换装备 / 加装备」面板用来把手上没有的装备换上去试(例:把身上的饰品换成另一件):
- 选部位(角色字符串里已有的会标注「当前已有」,没有的部位也能选 = 加一件);
- 搜名字——中文、英文都行,优先全部离线:英文名来自 SimC 自带的物品库,中文名来自客户端字符串表导出的索引(当前 6129 件全部覆盖,例:搜「无底袋」→ 盖博的无底袋 / 搜「海石」→ 唤波者的海石)。本地没有再问暴雪官方接口,官方接口按中文名实测 0 命中,所以最后还会补一次 wago 的中文名反查(命中后记进别名表,下次纯离线);也可以直接填数字 id;
- 点结果 → 填装等 → 点「写入角色字符串」。
写入 = 直接改写左边角色字符串里那一行(没有该部位就插到装备段末尾),所以快速模拟 / 装备对比 / 最优配装三条链路全都吃得下,改完还能「撤销」。
⚠️ 装等一定要填对:SimC 物品库里的
level是基础装等,不等于实际装等(例:无底袋在 DBC 里是 36,实际 277)。换已有部位时默认沿用当前装等;新增部位必须手填。 需要精确控制bonus_id/ 附魔 / 宝石时,用面板里的「手动贴装备行」,整行原样覆盖。
最优配装(TopGear)勾选网格:饰品 / 戒指是「一对」
粘贴「基础角色 + --- + 备选装备」后,右栏会把每个部位列成一个分组供勾选。饰品和戒指会渲染成一个分组(trinket1+trinket2、finger1+finger2 合并),按两两组合跑,而不是两个槽各搜各的:
- 分组头写着「两个槽 · 两两组合」,张数就是
C(勾选件数, 2); - 组里那两行「当前装备」默认不勾、但可以勾(勾上就变成「候选 + 身上那件」一起比)—— 普通部位那行「当前」是锁死的基准,饰品组不锁,正是为了不再出现「钉死一个槽」;
- 预计模拟次数按「基准 1 + 普通部位(候选 + 基准回跑)+ Σ 组合数 + 最佳组合确认 1」算,勾选数量实时显示;
- 只勾 1 件会明确提示「凑不成一对(要两件不同的)」,且该件不参与任何组合。
对应聊天侧就是 饰品对比 A、B、C…(语义完全一致:说了「X 不变」→ N 组,没说 → C(N,2) 组)。
配置见上表 web* 项:webEnabled(开关,默认开)、webBasePath(路由前缀,默认 /simc)、webToken(访问令牌,留空不鉴权;配置后表单需填对应令牌,错误返回 403)。
⚠️ 安全提示:simc 是 CPU 密集型,网页端点默认对局域网开放。公网暴露请用反向代理 + 鉴权,或设
webEnabled=false。
配置项(必须填 simcBinary)
| 配置 | 默认 | 说明 |
| --- | --- | --- |
| simcBinary | (必填) | SimC 可执行文件绝对路径 |
| maxConcurrent | 2 | 最大同时运行的模拟任务数(防 CPU 打满)。每个任务拿几个 SimC 线程由插件按真实并发自动分配(见下),所以调大它不会让单任务变慢 |
| simThreads | 0 | 每个模拟固定使用的线程数。0(推荐)= 交给插件按真实并发自动分配;填正数则自动分配关闭。⚠️ 它是「每任务固定 N」,并发时总共占 N × maxConcurrent 个线程(填 32 且并发 2 = 64 线程抢 32 核)。想控制总量请用 simThreadBudget |
| simThreadBudget | 0 | 本机总共允许 SimC 使用的线程数。0 = 自动取逻辑核数。填了之后按真实并发均分:单任务吃满这个数,两个任务同时跑时各一半 —— 并发时不会超订。典型用途:① 开了超线程的服务器(如 16 物理核 / 32 逻辑核的 Xeon Gold 6226R)想让 SimC 只用物理核(填 16,把 SMT 让给别的服务);② 给别的服务留余量(32 线程的机器只给 24)。该填多少跑 simc.cores 实测(见下) |
| defaultIterations | 10000 | 默认迭代次数 |
| defaultFightStyle | Patchwerk | 默认战斗类型 |
| defaultFightLength | 300 | 默认战斗时长(秒) |
| defaultEnemyCount | 1 | 默认敌人数量 |
| defaultTeamBuffs | true | 默认开启团队 buff |
| smartSimTargetError | 0.1 | SmartSim 目标误差 |
| taskTimeout | 300000 | 单模拟超时(毫秒),超时杀进程 |
| topGearMaxCombinations | 200 | TopGear 最大组合数,超过报错 |
| tempDir | 系统临时目录 | 临时文件目录 |
| webEnabled | true | 是否启用网页表单(需启用 server 插件)。用于粘贴超长 SimC 字符串,绕开聊天字数限制 |
| webBasePath | /simc | 网页表单路由前缀,例如 /simc,访问地址即 http(s)://host:port/simc |
| webToken | (空) | 网页 API 访问令牌;留空则不鉴权(仅建议本机/内网使用)。配置后表单需填写对应令牌 |
| remoteEnabled | true | 是否启用专属提交网站(5151)。命令回的「打开模拟页面」链接走这条;关掉后只能靠发 txt |
| remotePort | 5151 | 专属提交网站监听端口 |
| remoteBaseUrl | (空) | 生成提交链接用的对外地址(同局域网访问时填主机名/IP) |
| kookCard | true | KOOK 原生卡片开关;卡片显示异常时的逃生开关(关掉一律走纯文本) |
| autoFileSim | true | 收到配置文件(.txt/.simc)时自动跑一次模拟 |
| autoFileExt | txt,simc,simcprofile,prof | 触发自动模拟的文件后缀(逗号分隔) |
| autoFileMaxSize | 256 | 自动模拟的文件体积上限(KB) |
| autoFileCooldown | 20 | 同一用户两次自动模拟的最小间隔(秒);0 = 不限制 |
| autoFileRequirePending | true | 要求先发一条命令(simc.quicksim 等)才能触发收文件模拟 —— 也就是「先下参数、后给字符串」。设 false 退回老行为:丢文件就按默认参数直接跑 |
架构与防护(重点)
- 不在主线程同步执行:所有 SimC 调用进入内存任务队列(
queue.ts),异步执行,绝不阻塞 Koishi。 - 并发限制 + 线程按真实并发现算:
maxConcurrent(默认 2)限制同时运行的 SimC 进程数,防止 CPU 耗尽。每个任务拿几个 SimC 线程由队列的threadBudget()按此刻真实并发决定(分母是running,不含排队中的任务 —— 否则 TopGear 一次排 50 个会把每个压到 1 线程):- 空闲时单任务 = 线程总量(
simThreadBudget,未填时 = 逻辑核数);2 个任务同时在跑 = 先到的拿满、后到的拿一半。总墙钟与「平均分」基本相当,但先到的那个能更早出结果;代价是重叠期间约 1.5× 超订,实测约 7% 的开销(2 任务各吃满平均 23.1s vs 各分一半 21.5s)。 simThreads > 0时强制固定,动态分配关闭;simThreadBudget > 0时分子换成运维声明的总量(总量,不是每任务量 —— 并发时自动均分,不超订)。- 为什么这么改:SimC 线程数近乎线性加速(6 核实测 1 线程 46.8s → 3 线程 16.5s → 6 线程 9.1s)。旧算法是
cores / maxConcurrent(按最坏情况预留),maxConcurrent=2时单任务只用一半的核 —— 白白慢一倍。真机端到端(_thread_e2e_test.js):20.2s → 9.0s(2.25×),DPS 差 0.12%(只是快了,结果没变)。 - 也试过「一个进程 +
profileset跑多候选」,实测更慢(3 候选:profileset 41.9s vs 3 个独立进程串行 26.8s),所以维持「多进程 + 队列」。 - ⚠️ 为什么线程总量必须能手填:Node 在 Windows 上读不到物理核数 ——
os.cpus()每一项只有model / speed / times,没有physicalId/coreId(那是 Linux 的字段)。所以 16 物理核 / 32 逻辑核的 Xeon Gold 6226R 只会被数成 32,插件自己分辨不出来。想让 SimC 只用物理核(把 SMT 让给别的服务)就得显式填simThreadBudget: 16。该填多少别猜 —— 跑simc.cores(见下文「线程数怎么定」)。 - 另注:单任务给到 2 倍逻辑核都不变慢(6 核实测 6 线程 10.5s、12 线程 10.1s),所以线程总量偏大不会让单个模拟变慢,只会让并发时互相抢核。
- 空闲时单任务 = 线程总量(
- 超时即杀:每个任务有独立超时;超时通过
AbortController触发child.kill('SIGKILL'),杜绝僵尸进程。 - TopGear 组合爆炸防护:算法是逐分组线性的 —— 普通部位每组「候选数 + 基准 1 次」,饰品/戒指这种成对槽位(两个槽一起换)每组
C(N,2)次,总量是 Σ 而不是 ∏。组数(含基准组)超过topGearMaxCombinations直接报错,提示减少候选或改用「XX不变」把其中一个槽钉住(那就只要 N 组)。 - 套装约束:
--set-pieces 2|4强制达到指定数量套装件;候选行行尾加#tier标记为套装件(写入实际 profile 前会被剥离,不影响 SimC 解析)。 - 治疗专精提示:
role === "heal"或已知治疗专精会被识别并提示结果不可靠、建议 QE-Live。 - JSON 输出:通过
json2=<path>让 SimC 输出结构化 JSON(Report 2.0 格式),只解析 JSON,不解析纯文本日志。 - 错误处理:二进制缺失 / 参数非法 / 超时 / 非 0 退出 / JSON 解析失败,均有友好中文提示,不暴露内部路径与堆栈。
若需要跨进程 / 多实例统一排队,可把
queue.ts的SimQueue替换为 BullMQ,对外接口保持一致即可(本插件默认零依赖内存队列,部署最简单)。
部署注意事项
1. 自行编译 SimC 二进制(必须)
本插件不内置 SimC。你需要自己编译:
# 以 Midnight 源码为例(路径见 external/simc/packages/simc-midnight)
git clone <simc 仓库> simc-src
cd simc-src
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
make -j"$(nproc)" # 产物:build/simc (Windows 下为 simc.exe)把生成的 simc(或 simc.exe)绝对路径填到配置 simcBinary。
首次编译要多久:本机实测 约 10 分钟(175 个编译单元,MSBuild 串行,未开多核)。 之后不要重头来过 —— 用 2.1 的一键更新脚本,只同步真正变化的文件,避免全量重编。
Windows 编译(本机 win32 / MSVC):先装 Visual Studio 2022 生成工具(勾选“使用 C++ 的桌面开发”工作负载,含 MSVC + Windows SDK)+ CMake(两者均可用
winget install安装)。PowerShell 5.1 不支持&&,用;或每条单独回车:mkdir build; cd build cmake .. -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DBUILD_TESTING=OFF cmake --build . --config Release产物在
build\Release\simc.exe。务必加-DBUILD_GUI=OFF,否则会因缺少 Qt 而 configure 失败;本插件只用 CLI 二进制,不需要 GUI。把该 exe 绝对路径(如C:\...\simc-midnight\build\Release\simc.exe)填到simcBinary。
2. 更新 SimC(游戏 hotfix / 版本更新后必做)
SimC 依赖游戏 DBC / 物品数据库。每次魔兽大版本(如 11.x → 12.x Midnight)或在线修正(hotfix)后, 必须更新 SimC 源码并重新编译,否则模拟的装备/天赋/数值会过时甚至报错。 这也是为什么不调用网页 API——本地二进制必须跟版本走。
2.1 一键更新脚本
插件根目录带一个 _simc_update.js:
node _simc_update.js --check # 只看有没有新版(约 2 秒,不下载不编译)
node _simc_update.js --diff # 看改了哪些文件、预计重编几个 cpp(约 2 秒,不下载)
node _simc_update.js --dry-run # 真的解压包比对源码树,看会改动哪些文件(不落盘)
node _simc_update.js # 同步源码 + 增量编译(推荐)
node _simc_update.js --sync-only # 只同步源码,编译留到后面(会复用已缓存的包)
node _simc_update.js --keep-extra # 不删「本地有、上游没有」的文件
node _simc_update.js --zip <路径> # 复用已下好的源码包
node _simc_update.js --rollback # 新版有问题时,回滚到更新前的 exe 备份它做的事:拉取 GitHub midnight 分支最新源码 → 只覆盖内容真正有变化的文件
(未变文件的 mtime 保持不动,从而命中 CMake 增量编译)→ 编译 → 记录版本号。
simc.exe 会在编译前先备份成 simc.exe.bak,所以链接失败也还能 --rollback。
本地多出来的文件会被删掉:SimC 用
source_files/*.props维护显式源文件清单(不是 GLOB), 所以上游删文件时必须跟着删。但脚本会把删除清单全部打印出来, 不想动就加--keep-extra(2026-09-15 那次要删 2 个:上游真删的MID2_Paladin_Protection_Lightsmith.simc,以及本地自己复制出来的profiles/__simc_test_ci.simc)。
为什么这么设计(下面每个数字都是实测的,改之前请先复测):
| 环节 | 实测 | 结论 |
| --- | --- | --- |
| 官方 nightly 站 downloads.simulationcraft.org | ~17 KB/s(win64 包 115 MB → 近 2 小时) | ❌ 国内不可行 |
| git clone --depth 1 | 9 分钟只下 22 MB | ❌ |
| GitHub codeload 源码包(49.4 MB) | ~4 MB/s(十几秒) | ✅ 走这条 |
| 7z 解压 49.4 MB 包 | 3.1 秒 | ✅ |
| 智能同步 1160 个文件(逐字节比对 + 只覆盖变化项) | 2.9 秒(只有 100 个真被改写) | ✅ 同步不是瓶颈 |
| 编译耗时 | 不是常数,看改了哪些头文件 —— 见 2.2 | ⚠️ |
| 全量编译 175 个 TU | 10.5 分钟(MSBuild 串行,未开 /MP) | 最坏情况也就这个量级 |
为什么不用
git clone:本机实测git clone --depth 1跑 9 分钟才下 22 MB (pack 解包 + SHA-1 校验 + 落盘 5000+ 文件),而 zip 走 HTTPS 只要十几秒。 更关键的是:zip 方案能自己控制哪些文件的 mtime 被更新,这正是增量编译生效的前提;git reset --hard无法保证跳过未变文件。不要走官方 nightly:本机 exe 是 9-11 自己编译的,而官方 1210 nightly 构建于 9-08 —— 走 nightly 反而可能降级。
更新后不需要重启 koishi:插件每次模拟都是现 spawn 一个
simc.exe进程 (见src/simc.ts),所以覆盖 exe 后下一次模拟就直接生效。 脚本在编译前会检查有没有 simc.exe 还在跑(Windows 下运行中的 exe 无法被覆盖)。
2.2 「这次要编译多久」取决于头文件,不取决于改了哪几个文件
这是最容易误判的一点。按后缀数「改了几个 .cpp / 几个 .hpp」会严重低估:
C++ 里改一个 .cpp 只重编它自己,但改一个被广泛 include 的 .h,会让所有间接依赖它的 .cpp 全部重编。
真实例子(2026-09-15 那次 69587 → 69814):
- 总共只动了 102 个文件
- 其中
engine/dbc/data_enums.hh只改了 43 行、只有 4 KB - 但它的依赖链短得刺眼:
data_enums.hh→simulationcraft.hpp(只有 2 跳), 而后者是每个 cpp 的公共头 - 结果:145 个 cpp 被这一个小文件拖下水,全次共 161 / 175 个 cpp —— 等于全量编译
所以脚本不按后缀猜,而是真的把 #include 依赖图建出来做反向闭包
(558 个节点 / 0.3 秒),并给出「元凶排行」:
→ 预计需要重新编译:161 个 cpp(会被编译的一共 175 个,占 92%)
这几个改动决定了重编量(越靠前影响越大):
145 个 engine/dbc/data_enums.hh
10 个 engine/sim/profileset.hpp
4 个 engine/class_modules/paladin/sc_paladin.hpp由此把更新分成两类,用 --diff 两秒就能分辨是哪一类:
| 类型 | 长什么样 | 耗时 |
| --- | --- | --- |
| 轻量(APL / 配装改动) | 只改 ActionPriorityLists/、profiles/、少量 class_modules/*.cpp | 秒级 ~ 分钟级 |
| 重量(游戏数据更新) | 提交信息带 [live] Game data update (Build xxxxx),必动 data_enums.hh 与 dbc/generated/*.inc | 接近全量编译(~10 分钟) |
实践建议:暴雪 hotfix 很频繁,但一周里大多数提交只是某个职业的 APL 微调
(2026-09-15 那批 29 个提交里,职业相关全是 Paladin / Warlock / Hunter / Evoker / Druid / Shaman,
一个 DK 的都没有)。所以先 --diff 看一眼——只有出现 [live] Game data update,
或者改动命中你玩的专精时才值得跑;否则攒着一起更新。
qt/(GUI,25 个 cpp)在BUILD_GUI=OFF时不计入分母,脚本会读build/CMakeCache.txt自动判断。 顺带一提:simc.vcxproj里没有MultiProcessorCompilation也没有/MP, 所以 MSBuild 是逐个 cpp 串行编译的 —— 这才是「全量 10.5 分钟」的原因。 真嫌慢可以考虑给 CMake 加/MP,但未验证:/MP与/showIncludes式的依赖追踪有冲突风险, 一旦弄坏会退化成「改了头文件却不重编」这种静默错误。
2.3 更新后建议顺带自检的三件事
SimC 的数据变了,插件的静态表可能跟着过期:
战斗类型白名单
src/types.ts的FIGHT_STYLES—— 必须与 SimC 源码engine/util/util.cpp的fight_style_string()对齐。 SimC 删过选项(例如ExecutePatchwerk在 1210 已移除),照抄旧列表会让用户选了就报错。附魔 / 宝石 / 消耗品目录
src/loadoutData.ts—— 由_gen_loadout_table.js自动生成(扫描 SimC 官方 profile + DBC 英文名 + 暴雪 API 中文名 + 本机 simc 逐条真机验证)。但不是每次更新都要重跑(它要联网查暴雪 API、还要逐条真跑 simc,比较慢)。 判据是:生成器的输入有没有引入新的 id / token,即
profiles/generators/{MID1,MID2,PreRaids}/**/*.simc里新出现的enchant_id=/gem_id=, 以及flask=/food=/potion=/augmentation=/temporary_enchant=这些消耗品 token
只有出现了、而目录里查不到的,才需要重跑:
node _gen_loadout_table.js实测参考:69587 → 69814 那次,附魔/宝石/物品数据全是「只改文件头 build 号」的 churn (26 MB 的
item_data.inc只差一行),profiles/generators/里 Paladin / Warlock 虽有英雄天赋更名,但涉及的 9 个enchant_id+ 5 个gem_id全都已在目录里、 消耗品 token 净增 0 个 → 不必重跑。合剂 → 属性别名表
src/nlpArgs.ts的FLASK_STAT_ALIAS—— 「喝暴击合剂」能落到那一款合剂上,靠的就是这张表。跨资料片换合剂时它会静默失效 (新合剂不在表里 → 「暴击合剂」进未识别;旧 token 也早已不存在)。 不用靠人记得检查:_nlp_test.js的[K]段会回头拿 SimC 自带的engine/dbc/generated/item_effect.inc(物品→法术)与spelltext_data.inc(法术→ "Increases your Critical Strike by…")重新推一遍,新合剂没进表就会红。 所以「更新后跑一次_nlp_test.js」就能知道要不要补表。
三处都检查完后,按项目惯例走完整流程:tsc --noEmit → esbuild 打包 lib/index.js → 重启 koishi
(改插件代码需要重启,改 simc 二进制不需要)。
3. 不实现 Droptimizer
完整 Droptimizer 需要完整的装备掉落数据库与权重迭代,复杂度极高,本插件暂不做。
4. 线程数怎么定(simThreadBudget)—— 跑 simc.cores,别猜
插件默认把逻辑核数(os.cpus().length)当线程总量。这在两种机器上都不准:
- 开了超线程的服务器(如 16 物理核 / 32 逻辑核的 Xeon Gold 6226R)—— 逻辑核数会是 32,但 SimC 是浮点密集负载,超线程的收益很小。想让 SimC 只用物理核、把 SMT 让给别的服务,就得手填
simThreadBudget: 16。 - 共享服务器 —— 想给别的服务留余量(32 线程的机器只给 24)。
为什么插件不能自己算:Node 在 Windows 上读不到物理核数 —— os.cpus() 每一项只有 model / speed / times,没有 physicalId / coreId(那是 Linux 的字段)。所以插件无法区分「16 物理核」和「32 逻辑核」。
该填多少用 simc.cores 实测(它也是唯一可靠的办法 —— 墙钟-线程曲线在哪一档转平取决于物理核数、SMT、内存带宽、有没有别的服务在抢核):
simc.cores 5目标 木桩 <粘贴你的角色字符串>
simc.cores -n 8000 <角色字符串> # 迭代数调大更准(默认 4000)它会串行跑 总量/2、总量、总量×2 三档并报墙钟,然后给建议值。几个设计要点:
- 串行跑:并发会让不同档互相干扰,测出来就不是单任务性能了;跑之前还会检查队列是否空闲。
- 先预热一次(结果丢弃):正式机
simc.exe有 120MB,不预热的话第一个被测的档位会白背一次冷启动开销,曲线是假的。 - 报出固定开销:每档墙钟里含多少是「进程启动 + SimC 初始化」,并据此判断这次曲线可不可信 —— 「各档差异」小于「一次空跑的抖动」时会明确说不可信、让你调大
-n,而不是给一个假精确的数字。 - 建议值不会超过逻辑核数:超订只在单任务独占时没坏处,一旦两个任务同时跑就会互相抢核(实测超订 2× 比均分慢 7%)。所以推荐规则是「在不超过逻辑核数的档位里,取落在最快档 3% 以内、线程数最小的那个」。
5. 资源与超时调优建议
- 单机 / 独占(推荐):什么都不用改 ——
simThreads=0/simThreadBudget=0时单任务自动吃满线程总量,多任务自动均分。核多就是快。 - 共享服务器:把
simThreadBudget设成你愿意让出的线程数(如 24),或maxConcurrent调小(如 1)。 - ⛔ 别用
simThreads当「限流开关」:它是「每任务固定 N」,并发时总共占N × maxConcurrent(填 32 且并发 2 = 64 线程抢 32 核)。要限总量用simThreadBudget。simc.diag的【算力】段会在检测到超订时直接给警告和改法。 - 一条命令看全部数字:
simc.diag的【算力】段会把「逻辑核 / 线程总量(及来源)/ 最大并发 / 每任务实际线程数 / 是否关掉了动态分配 / 是否超订」打出来。 - TopGear 组合很多时:调大
taskTimeout,并合理减少每个槽位的候选数量。 - 避免长时间迭代:固定迭代建议 ≤ 25000;追求精度用
--smart而非盲目拉高迭代。
TopGear 输入示例
<基础角色 SimC 字符串,从游戏内导出>
---
head=,id=190001,bonus_id=...
head=,id=190002,bonus_id=...
chest=,id=190003,bonus_id=... #tier
chest=,id=190004,bonus_id=... #tier--set-pieces 4 时,只有包含 ≥4 件 #tier 的组合会被模拟。
⚠️
---之后的候选段不认#开头(#是角色字符串里背包段/注释的写法,候选段是普通行)。网页勾选后拼回来的正是去掉#的装备行,两边形态一致。
饰品 / 戒指:直接写中文一句就行(比手写两段装备行省事,也让「一个槽不能被钉死」这条语义由代码保证):
simc.topgear <基础角色字符串>
不要团队buff 暴击合剂 圣光潜力 无嗜血 5目标 木桩 饰品对比破损的阿曼尼战旗、盖博的无底袋、米利克萨之牙、乌拉特克贪婪之心都神话6/6 虫群之瘤不变- 末尾那条
都神话6/6是整组默认值(这几件全按 334 算),不写「都」也一样;单件自己写了的以单件为准。 米利克萨之牙(id 158367,旧资料片)不用写 id:入口会按中文名联网反查一次并记住。想写成米利克萨之牙158367也行。
真机实测(血 DK 血坦 / 5 目标 / 团队增益全关 / 120 秒,基准身上 trinket2 = 虫群之瘤):
| 组 | DPS | 相对基准 | | --- | --- | --- | | 乌拉特克贪婪之心 + 虫群之瘤 | 231,119 | 最高 | | 米利克萨之牙 + 虫群之瘤 | 228,958 | -0.94% | | 破损的阿曼尼战旗 + 虫群之瘤 | 222,137 | -3.89% | | 盖博的无底袋 + 虫群之瘤 | 221,146 | -4.32% | | 当前装备(基准) | 217,694 | -5.81% |
已知坑点(开发者向)
- 成对槽位(
trinket1/2、finger1/2)不能用slot === cand.slot去反查候选行。成对组的候选要同时换两个槽,按槽位反查只能找到其中一个 → 最佳组合确认时会漏掉一个槽,结果里留下身上那件、拼出重复装备。正解是候选自带slots[]/lines[],buildProfile(base, slots, lines)一次换多个槽(_pair_test.js的[A][C]钉住这条)。 - 组合的装等取两件里较小的那个(
Math.min)。取第一件会在两件装等不同时给出偏高的假象。 - 网页模板字符串里的内联 JS,反斜杠必须双写(
split('\\n')、/^\\s*/)。漏一个轻则SyntaxError→ 整段脚本全哑、页面按钮全没反应,重则静默语义错(/^\s*/变成/^s*/)。只断言「HTML 里有某个函数名」抓不到这类错,必须new Function(内联脚本)真解析一遍 ——_web_form_test.js/_swap_test.js/_loadout_test.js都这么干。 FORM_HTML的属性引号漏一个收尾引号,后面整段会被吞进属性里。这类问题只有真解析 DOM 才发现:用 jsdom +runScripts: 'dangerously'把整页跑起来(_web_form_test.js的[B]E2E 段就是这么抓到一次真 bug 的)。- SimC 的 DPS 误差字段在 JSON 里叫
dpse(不是dps_error)。 - 多人模拟时
players数组长度 > 1,每个玩家各自有stats技能数组;JSON 里没有单一 Sample Ability Log 时间线(那是 HTML 报告专属)。 - 治疗专精
dps可能为 0 / 缺省,解析时按 0 处理并打治疗警告。 - SimC 偶发非 0 退出但仍产出可用 JSON;本插件先尝试读 JSON,失败才按错误上报。
- 跨大版本时
json2字段名可能微调,解析器对新字段缺失做了兜底。 - 装备行的
level不是装等:engine/dbc/generated/item_data.inc里的level是基础装等(现代 WoW 实际装等由升级曲线决定,例:Relentless Rider's CrownDBC 里 197、官方 profile 写 289)。所以本地物品索引不存装等,换装备时要么用户给数字、要么写「难度 + 升级次数」由UPGRADE_TRACKS换算(见上面「换装」一节)。 slot=,id=N,ilevel=M不需要bonus_id:实测 4 例全部 exit 0 且 DPS 各异,官方 profile 自己也这么写。bonus_id只影响升级等级 / 词缀 / 插槽,手动换件对比给装等就够。- 本地物品索引裁剪规则(
_gen_item_index.js):id>=225000+ 品质 ≥ 绿 + 基础装等 ≥ 21,滤掉 6400 件幻化 / 玩具 / 新手杂物,11.5 万条 → 6129 件(253 KB)。同名物品留 id 最大的。 - SimC 不回显命令行,所以真机断言「
desired_targets生效」要数*** Targets ***段里的Target: xxx行数(1 目标 = 1 行、5 目标 = 5 行)。 - 中文物品名的唯一可行来源是 wago 整表导出:暴雪官方搜索接口(
/data/wow/search/item)需要 OAuth 凭据,没配bnetId/bnetSecret时静默返回空——所以「搜中文」绝不能建成走官方接口。wago 的ItemSparse客户端字符串表不需要鉴权、可整表导出(48MB / 约 30 秒)。 - wago 的两个限制(都实测过):
filter[ID]=a,b,c不支持批量(返回空表),columns=不裁剪列。所以要么整表下载自己挑列,要么逐条查(6129 件 ≈ 8 分钟,别这么干)。 - 但
filter[Display_lang]=<中文名>单值反查可用,而且很便宜(整条响应约 2KB、几百毫秒,还带InventoryType→ 槽位)。这是「旧资料片物品按中文名搜到」的唯一可行路:暴雪官方搜索按中文名实测 0 命中(?name.zhCN=返回空,static-cn还要额外权限 403),wago 按 id 批量也不行,只有这条通。查到的结果落进别名表<assetsDir>/item-alias.json,所以每个名字一辈子只查一次。列号一律按表头名查(ID/Display_lang/InventoryType,共 103 列,加字段是常事)。 - 中文名索引需要重新生成的时机:游戏更新后本地物品库出现索引里没有的新物品时。步骤:
node _gen_item_index.js→node _gen_item_zh.js --refresh。 - 专精图标是「内联 + 外部」双层(
specIcons.ts,40 张 PNG / 305KB → base64 后 408KB)。原因:图标缺失是完全静默的降级(卡片照发、只是没图),而正式机的部署方式不确定(该仓库没有 git remote,clone / rsync / npm 都可能)。所以 40 张全部编进产物,specIconBuffer()的顺序是「外部文件(插件自带assets/specs→config.assetsDir)→ 内联表」,任何部署方式都不会丢。⚠️ 外部查找必须遍历两个目录 —— 早期版本把「插件自带只读素材」和「可写缓存」绑成同一个变量,运维一改config.assetsDir(只为把翻译缓存放共享盘)图标就全没了,而且不报错。_icon_test.js里有对这条的回归。 assets/要进版本库:package.json的files含assets/specs,包的.gitignore只忽略运行时缓存(assets/*.json、assets/*.log)并显式!assets/specs/**。别用宽泛的assets/规则把它一起忽略掉。- 把组级默认值拼到候选上时,不能直接
名字 + 描述 + id:候选可能是米利克萨之牙158367这种「名字+id」写法,直接拼会变成米利克萨之牙158367神话6/6—— 尾部 5~6 位 id 不在末尾了,splitTrailingId切不出来,整串被当成物品名去搜,报「搜不到「米利克萨之牙158367」」。正解是先把 id 切出来、把描述插在名字与 id 之间(gearPool.ts的withGroupLevel)。这个是真机端到端(_pool_e2e_test.js)抓到的,单元测试当时是绿的。 - 索引体积不要紧:
lib/index.js现在 1.3MB(含 408KB 内联图标),V8 编译耗时一次性、实测几十毫秒,不用为体积做压缩。
