雀魂(Mahjong Soul)是一款猫粮工作室开发的一款多人在线日本麻将游戏。
majsoul_wrapper封装了基于websocket抓包的输入接口(input)与基于图像识别的鼠标操作输出接口(output),作为sdk供第三方AI调用,以实现自动化在《雀魂》中打麻将。一个使用majsoul_wrapper的MajsoulAI示例可以参考这里。
majsoul_wrapper使用mitmproxy以中间人攻击(Man-in-the-middle Attack)的形式抓取经过代理服务器的网络通信。 如果你还没有安装mitmproxy,那么需要先安装:
$pip install mitmproxy
$mitmdump --version
如果你正处在majsoul_wrapper的目录中,可以通过以下命令开启mitmproxy控制台进程:
$mitmdump -s addons.py
如果你正在将majsoul_wrapper作为PyPI第三方Package使用,可以通过以下命令开启mitmproxy进程:
$python3 -m majsoul_wrapper
上述这两种方式是等价的。在开启mitmproxy后,将会在本地37247端口开启xmlrpc服务,任何第三方程序均可通过远程过程调用获取mitmproxy抓取的websocket原始二进制报文与websocket报文对象。
在开启mitmproxy的同时,程序会自动打开Chrome浏览器并绑定至mitmproxy的代理服务器端口,mitmproxy仅会截获由上述命令打开的Chrome浏览器中的流量数据。打开浏览器的过程需要安装selenium并正确配置ChromeDriver,安装selenium可以输入:
$pip install selenium
配置ChromeDriver可以参考这里,将对应版本的ChromeDriver简单的添加至环境变量即可。
在浏览器弹出后,即可登录至雀魂官网至主菜单。一切就绪后,你应该看到如下画面,并看到持续的websocket数据在终端中显示:
mitmproxy启动后会缓存最新的一个WebSocketFlow对象以及该对象所有的收发数据包。第三方程序可以使用RPC获得mitmproxy记录的所有websocket数据:
import pickle
from xmlrpc.client import ServerProxy
server = ServerProxy("http://127.0.0.1:37247") # 初始化服务器
n = server.get_len() # websocket中数据包的总数
flow = pickle.loads(server.get_items(0, n).data) # 获得所有数据对象的列表
flow_msg = pickle.loads(server.get_item(0).data) # 获得第0号数据对象
buffer = flow_msg.content # 数据二进制内容
from_client = flow_msg.from_client # 数据是否来自客户端
majsoul_wrapper提供了liqi.py和sdk.py两个不同层次的工具来帮助分析websocket数据,在下文中$python liqi.py
与$python -m majsoul_wrapper.liqi
是等价的,sdk.py同理。
解析雀魂websocket数据需要安装protobuf:
$pip install protobuf
liqi.py 通过反解析liqi proto粗略的将websocket二进制数据解码为Json与Dict可解释对象。
$python liqi.py --dump FILE # 将mitmproxy消息对象缓存至FILE
$python liqi.py --load FILE # low level的解析FILE并打印至stdout
运行liqi.py一个期待得到的结果应类似:
...
{'id': 1082, 'type': <MsgType.Res: 3>, 'method': '.lq.FastTest.checkNetworkDelay', 'data': {}}
-----------------------------------------------------------------
{'id': 660, 'type': <MsgType.Notify: 1>, 'method': '.lq.ActionPrototype', 'data': {'step': 52, 'name': 'ActionChiPengGang', 'data': {'seat': 3, 'tiles': ['5m', '6m', '7m'], 'froms': [3, 3, 2], 'tileStates': [0, 0]}}}
-----------------------------------------------------------------
{'id': 1083, 'type': <MsgType.Req: 2>, 'method': '.lq.FastTest.checkNetworkDelay', 'data': {}}
-----------------------------------------------------------------
{'id': 1083, 'type': <MsgType.Res: 3>, 'method': '.lq.FastTest.checkNetworkDelay', 'data': {}}
-----------------------------------------------------------------
{'id': 663, 'type': <MsgType.Notify: 1>, 'method': '.lq.ActionPrototype', 'data': {'step': 53, 'name': 'ActionDiscardTile', 'data': {'seat': 3, 'tile': '9s'}}}
-----------------------------------------------------------------
{'id': 664, 'type': <MsgType.Notify: 1>, 'method': '.lq.ActionPrototype', 'data': {'step': 54, 'name': 'ActionDealTile', 'data': {'leftTileCount': 43}}}
...
sdk.py 以更高层次的抽象进一步解析liqi proto,将websocket动作绑定至一系列回调函数,这一组函数提供了实现一个雀魂AI的全部动作(参数含义详见sdk.py注释),这些函数包含两个部分:
局面信息输入(Input):
函数名 | 功能 |
---|---|
authGame | 开始整场对局,告知AI自己的座次 |
newRound | 新的一轮,初始手牌、座次、分数与明宝牌 |
discardTile | 某一个玩家打出一张牌 |
dealTile | 某一个玩家(非自己)摸了一张牌 |
iDealTile | 自己摸了一张牌 |
chiPengGang | 某玩家吃碰杠了 |
anGangAddGang | 某玩家暗杠加杠了 |
newDora | 出现杠而新增明宝牌 |
hule | 某玩家胡牌了 |
liuju | 无牌流局 |
specialLiuju | 四风连打、九种九牌、四杠散了引起的流局 |
beginGame | 在整场比赛开始前调用 |
endGame | 在整场比赛结束后调用 |
AI动作反馈(Output):
函数名 | 功能 |
---|---|
actionDiscardTile | 普通的出牌 |
actionLiqi | 立直并出牌 |
actionHu | 我要和牌 |
actionZimo | 我要自摸 |
actionChiPengGang | 我要吃、碰、杠 |
任何一个通过mitmproxy作为输入的雀魂AI都应继承MajsoulHandler,并重载其所有除了parse以外的动作函数(一个AI代码示例可以参考MajsoulAI)。
我们可以直接运行sdk.py来简单的观察所有可解释的动作:
$python sdk.py --dump FILE # 将mitmproxy消息对象缓存至FILE
$python sdk.py --load FILE # high level的解析FILE并打印至stdout
运行sdk.py一个期待得到的结果应与上面类似的:
...
discardTile (seat = 0, tile = '3s', moqie = False, isLiqi = False, doras = [], operation = None)
dealTile (seat = 1, leftTileCount = 51, liqi = None)
discardTile (seat = 1, tile = '7s', moqie = False, isLiqi = False, doras = [], operation = {'seat': 2, 'operationList': [{'type': 2, 'combination': ['5s|6s', '6s|8s']}], 'timeAdd': 20000, 'timeFixed': 5000})
chiPengGang (type_ = 1, seat = 3, tiles = ['7s', '7s', '7s'], froms = [3, 3, 1], tileStates = [0, 0])
discardTile (seat = 3, tile = '5z', moqie = False, isLiqi = False, doras = [], operation = None)
...
项目提供了一个websocket消息流的样例文件"ws_dump.pkl"可以参考。
当AI通过MajsoulHandler获取局面信息,并作出决策以后,需要向雀魂服务器发出动作反馈。我们提供了一个基于图像识别的动作模块GUIInterface,该模块通过模拟鼠标操作的方式执行AI的动作。GUIInterface重写了MajsoulHandler中所有未实现的5个AI动作反馈函数,以及一系列辅助函数。
函数名 | 功能 |
---|---|
calibrateMenu | 校准界面位置(需保持雀魂主菜单完整的悬浮于桌面上) |
actionBeginGame | 从主菜单开始一场段位场匹配 |
actionDiscardTile | 普通的出牌 |
actionLiqi | 点击'立直'并出牌 |
actionHu | 点击'和'按钮 |
actionZimo | 点击'自摸'按钮 |
actionChiPengGang | 点击'吃、碰、杠'按钮 |
clickCandidateMeld | 如果有多种'吃'法时的二次选择 |
actionReturnToMenu | 在对局结束后返回至主菜单 |
在使用时只需让AI继承majsoul_wrapper.GUIInterface,在初始化时调用calibrateMenu,并在AI运行过程中始终保持Chrome悬浮于桌面顶部。唯一需要特殊处理的是clickCandidateMeld,因为是否需要二次选择取决于input和AI维护的手牌信息(一个AI代码示例可以参考MajsoulAI)。
使用基于GUI的动作输出需要安装pyautogui与OpenCV-Python:
$pip install pyautogui opencv-python
另外还需安装对应版本的Pytorch。由于图像识别使用了神经网络,如果需要使用GPU加速而非CPU还需安装对应版本的CUDA(虽然CPU也可以很快的计算出结果)。
至此你已经可以尽情的开心自动雀魂了 : )
雀魂的数据是实时websocket,在进入主菜单后会开启第一层websocket长连接,在每一局对局开始时会发起对局专用websocket链接,我们抓包的重点在对局数据这部分,以提供实时局面信息。
在雀魂与浏览器之间的websocket数据由消息头+Protobuf载荷两部分构成,消息头有三种类型:
消息类型 | 编码格式 | 首字节 | 用途 |
---|---|---|---|
Notify | 1+n | 0x01 | 服务器向客户端发送通知 |
Request | 1+2+n | 0x02 | 客户端向服务器发送请求 |
Response | 1+2+n | 0x03 | 服务器回复客户端的请求 |
如果消息类型为req或res,则第2,3字节按小端序存储16bit滑动窗口消息id,这样可以将乱序出现的req和res对应上。
后n字节采用protobuf编码,n字节分为两个层次,第一层固定为{'methodName':str, 'data':protobufBytes}
,可以手动解析出methodName,data部分的protobuf编码协议是在雀魂游戏加载过程中下载的,会不定时更新,目前项目中./proto已同步0.8.1.w版本的liqi proto,如有更新可按如下方式同步新proto:
在打开浏览器雀魂登录页面时会加载liqi.json,可用Chrome F12抓出放于proto文件夹中。liqi.json是以json编码的protobuf格式,需编译成liqi_pb2.py。
编译过程先借助protobuf.js将liqi.json编译为liqi.proto,以nodejs为例:
$npm install -g protobufjs
$pbjs -t proto3 liqi.json > liqi.proto
然后使用protobuf将liqi.proto编译为liqi_pb2.py将旧版proto替换即可。
注意,本项目使用protobuf==3.14.0,你同样应该使用这个版本提供的protoc
protoc --python_out=. liqi.proto
有了liqi_pb2.py以后就可以在parse过程中根据methodName反射的构造protobuf对象进行解析,最终通过MessageToDict转为Python Dict对象。
动作输出使用pyautogui截屏并进行鼠标操作。由于实时对局效率是至关重要的,因此我们使用OpenCV进行快速的图像定位,使用CNN进行牌的分类。
在一切开始之前需要使用GUIInterface.calibrateMenu校准雀魂界面的坐标。我们需要得到1920x1080的模板到当前雀魂界面位置的单应矩阵,这使得无论浏览器在哪里,无论当前分辨率如何,都能够找到正确的位置。我们使用ORB特征提取与FLANN特征匹配快速的得到匹配点对,进而通过RANSAC得到单应矩阵的估计。虽然大多数时候校准都是准确的,但如果你想要得到更好的校准效果,可以尝试用自己的1920x1080屏幕截图替换./action/template/menu.png。
对局中我们需要定位与识别13张手牌以及有多种'吃'方式时的候选操作牌。牌区域分割使用固定阈值的floodfill,沿直线搜索。牌的识别使用简单的两层CNN实现(action/classify.py)。
对于吃碰杠等图标的定位,由于已经得到了单应矩阵,可以简单的通过基于FFT的template matching十分快速地实现。
该第三方工具需监听网络通信并主动以中间人攻击的形式窃取websocket数据,但除了过滤雀魂对局信息并提供RPC服务以外不会窃取个人隐私,也不会篡改网络数据(如有担心可核验addons.py)。如果使用该工具自动出牌,将会主动在特定位置点击鼠标。请自行判断使用该工具的风险,如果滥用该工具或(不存在的)该工具的衍生物产生的一切后果,包括电脑损坏、数据丢失、账号被封禁等,作者均不承担任何责任。