叽里咕噜说什么呢
2025 年,英雄联盟的大乱斗做出了一次巨大更新:将原来的骰子删除,替换为全新的翻牌机制
不过,在此之前,腾讯出了一个活动,用来提前帮拳头给这个改动收集数据验证可行性
有的人可能已经猜到了,就是补骰子活动。国服在 Lobby 界面的开始匹配按钮上方,添加了一个按钮,小手点一下,就可以补充

问题是,就算 K6 合作部不舍得大刀阔斧动客户端原来的插件逻辑,你都把按钮做出来了,你自己 click 一下然后校验结果不行吗?非得玩家自己补?
这个活动直到结束,还是经常能遇到不补骰子的,也是神了…
言归正传
上面简单吐槽一下,顺便回忆一下细节,接下来我们看一下怎么实现自动补骰子
总体思路
CDP is for Chrome Devtools Protocol
Chrome 的 DevTools 其实是典型的 C/S 架构,浏览器内部跑着一个 Server,然后浏览器会访问devtools://devtools/bundled/inspector.html来从 Server 获取信息,而他们的沟通就是通过 CDP 实现的
那既然 DevTools 可以在 Console Tab 任意 Evaluate 一段 JavaScript 代码,那我们岂不是也可以?
是的,CDP 通过Runtime.evaluate来进行任意代码的求值,参考Chrome DevTools Protocol - Runtime domain
所以首先打开 F12 简单扒一下补骰子的逻辑在哪,随后使用 JS 操作即可
打开远程调试
但是在这之前,我们需要打开远程调试(Remote Debugging),不然不能通过外部连接 DevTools Server
英雄联盟于 2016 更新海克斯客户端架构,这是一个典型的 CEF 应用,所以我们可以通过 Hook 来强行给 Browser process 加一个--remote-debugging-port={PORT}来开启远程调试
具体的操作这里就不提了,直接使用 https://github.com/PenguLoader/PenguLoader (我也曾深度参与项目社区和贡献,项目至今可用,拳头大概是默许了,有兴趣的可以看看)
如果有兴趣,可以参考我另外一个对 WeGame 的 Hook 项目的说明文档,里面详细介绍了这块的知识
BetterWG/HOW_IT_WORKS.md at main · BakaFT/BetterWG
控制流构思
1> 通过游戏的 WebSocket 事件驱动模型,一旦玩家进入房间,那么执行回调函数
2> 回调函数中,首先建立 CDP 连接,找到网页对应的 Frame
这里涉及到 V8 的一些概念,如果看不懂建议代码拷贝过去问 AI
3> 判断当前模式是不是可以补骰子(无限乱斗和大乱斗)
4> 连接到 Frame,使用Runtime.evaluate执行业务代码
编码
首先,怎么检测玩家到房间呢?
1 2 3 4 5 6 7 8 9 10 11 12 13
| createWebSocketConnection().then((ws=>{ console.log('连接客户端成功') ws.subscribe('/lol-gameflow/v1/gameflow-phase', async (data, _) => { let bflag = false if (data === 'Lobby') { while (bflag === false) { bflag = await fillUpDices1() } } }) console.log('开始监听,保持后台运行即可') }))
|
这里搞一个 flag 简单粗暴地进行 throttle,这样不用因为各种边界条件头疼,重试就完了奥
接下来我们就可以做 step 2 和 step3 了,先放代码,可以看到下面用到了Runtime.evaluate来求值,说人话就是执行代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| export async function fillUpDices1() { const targets = await CDP.List({ 'host': 'localhost', port: REMOTE_DEBUGGING_PORT }) const theFrame = targets.find(t => { return t.type === 'iframe' && t.title.includes('aram/random') }) if (!theFrame) { return false }
const isAURF = theFrame.url.includes('infinite') ? true : false const client = await CDP({ 'host': 'localhost', 'port': REMOTE_DEBUGGING_PORT, 'target': theFrame.id })
let isMiloReady = await client.Runtime.evaluate({ 'expression': `typeof Milo !== 'undefined'` }) while (!isMiloReady.result.value) { isMiloReady = await client.Runtime.evaluate({ 'expression': `typeof Milo === 'undefined'` }) await new Promise<void>((r, j) => { setTimeout(() => { r() }, 200) }) }
let ret = false if(isAURF){ ret = await AURF(client) } else{ ret= await ARAM(client) }
await updateCount(client,isAURF) client.close()
return ret }
|
这里有一点上面没提到,就是这个 Milo,这是腾讯 IEG 的一个业务框架,简单来说就是拿来做活动上报之类的,本次活动使用 Milo 提交业务实现补骰子
所以我们要等一下 Milo 的初始化,不然后面没得玩了
最后只需要把 F12 逆向出来的业务代码扒出来,贴这里就可以了,代码较长,使用注释讲解
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
| function miloEmitExpressionString(actId:string,token:string){ return ` (async function(){ return await Milo.emit({actId:'${actId}',token:'${token}',sData:{sArea:window.area}}) })() .then((r)=>{ return JSON.stringify(r) }) ` }
export async function ARAM(client:Client){ const evalRes = await client.Runtime.evaluate({ expression: miloEmitExpressionString('705790','7b9c56'), awaitPromise: true })
if (evalRes.result.value === undefined) { return false } const miloResObject = JSON.parse(evalRes.result.value) console.log(`[${new Date().toTimeString()}]尝试补充大乱斗骰子,返回结果: ${miloResObject.sMsg}`) return true }
export async function AURF(client:Client){ const evalRes = await client.Runtime.evaluate({ expression: miloEmitExpressionString('705790','656963'), awaitPromise: true }) }
export async function remainingDicesCount(client:Client,isAURF:boolean){ const evalRes = await client.Runtime.evaluate({ expression: miloEmitExpressionString('705790','c43e85'), awaitPromise: true }) if (evalRes.result.value === undefined) { return false } const miloResObject = JSON.parse(evalRes.result.value) console.log(`[${new Date().toTimeString()}]查询ARAM骰子数量,返回结果: 剩余${miloResObject.details.jData.scoreNum},目前${miloResObject.details.jData.diceNum}/2`)
const evalRes2 = await client.Runtime.evaluate({ expression: miloEmitExpressionString('705790','6ae6d9'), awaitPromise: true }) if (evalRes2.result.value === undefined) { return false } const miloResObject2 = JSON.parse(evalRes2.result.value) console.log(`[${new Date().toTimeString()}]查询AURF骰子数量,返回结果: 剩余${miloResObject2.details.jData.scoreNum},目前${miloResObject2.details.jData.diceNum}/2`)
client.Runtime.evaluate({expression:` document.querySelector('#diceNum').innerText = ${ isAURF ? miloResObject2.details.jData.diceNum : miloResObject.details.jData.diceNum}; document.querySelector('#scoreNum').innerText = ${miloResObject.details.jData.scoreNum}; ` }) }
|
这样,一个简单的补骰子就做好了

其他思路
上面的代码中可以看出,补骰子的核心是执行 Milo 业务的提交,那么基于此可以发散出其他思路
客户端内直接求值
这小标题有点不明不白的,简单解释一下
上面提到的 PenguLoader 提供了运行时注入代码的能力,使用 CEF 的frame->execute_java_script直接在 Renderer process 求值即可。那有的人就会问了,那你为啥不这么干,非要绕一圈去搞 CDP 呢?
因为跨域了…但是这个理由不够合理,因为这个可以通过--disable-web-security做到无视跨域,和上面 CDP 前置要求--remote-debugging-port其实也差不多。就当多个思路吧
跨域问题
为什么跨域呢?因为腾讯通过自己定义一个 LeaueClientUx Frontend Plugin 做到了加载自己的代码,这也是为什么国服的 LCUX 并不进行完整性校验,因为自己也要插代码。包括客户端主页一些奇奇怪怪的功能在内的东西,都是通过这个实现的。
然后呢,PenguLoader 只在游戏客户端自己的localhost:{RANDOM_PORT}这个页面帧,也就是 Frame 上执行代码
但是呢,腾讯的东西都在lol.qq.com下面,看图:

所以其实写 PenguLoader 插件直接执行会被 CSP 拦下来…
代码实现
当然,通过参数关闭 CSP 之后就不是问题了,一位群友(Joi 的作者其实是,watchingfun@github)写了一个插件
原理是一样的,这里贴一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| export function init(context) { let area; window.addEventListener("message", (event) => { if (event?.data?.messageType === "aram_reroll_main_show") { const src = document.querySelector('#aram_reroll').src; const type = src.substr(src.lastIndexOf('/') + 1).split('.html')[0] if (!area) { area = new URLSearchParams(src.split('?')[1]).get('area') } getDice({ ...paramsConfig[type], sArea: area }); } }) }
const paramsConfig = { 'random-infinite': { iChartId: '393050', iSubChartId: '393050', sIdeToken: '6f9Yvi' }, 'random': { iChartId: '378916', iSubChartId: '378916', sIdeToken: 'Rb22Nt', } };
async function getDice(params) { const url = 'https://comm.ams.game.qq.com/ide/'; const options = { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(params) };
try { const response = await fetch(url, options); const data = await response.json(); console.log('尝试补充骰子', data); Toast.success(data.sMsg) } catch (error) { Toast.error('尝试补充骰子失败:' + error?.message || JSON.stringify(error)) } }
|
这个插件并没有做到对 UI 的骰子数量进行更新,不过无伤大雅,内部实现他就是方便啊
野路子
B 站有一位高人发了个补骰子工具(BV1QfVWzTE1S)
简单逆向了一下,发现是通过搜索 WeGame 和 QQ 的内存获取用户的某个状态 Key,然后直接使用 Key 请求https://comm.ams.game.qq.com/ide/这个接口
这个比较变态,简单看看就行了…