一天,兴起而至,想在mac osx上看lol ob,要看国服的,没现成方案,实现不了,未遂,便决定研究一番。
在拳头游戏官方的api中SPECTATING GAMES riot games api提到观看ob模式的程序启动参数:
“C:\Riot Games\League of Legends\RADS\solutions\lol_game_client_sln\releases\0.0.1.74\
deploy\League of Legends.exe” “8394” “LoLLauncher.exe” “” “spectator spectator.na.lol.riotgames.com:80 P+C3YqI3Mg9oHc6t9eTAKWE4T8prxwzR 1726229459 NA1”
程序启动的第三个参数中:
"spectator spectator.na.lol.riotgames.com:80 P+C3YqI3Mg9oHc6t9eTAKWE4T8prxwzR 1726229459 NA1"
这里面包括ob server地址、端口,以及本场录像文件加密key,以及游戏id,游戏所属平台id。其中 encryptionKey参数每次都不一样,从数据包分析发现,这个key并不是为了在获取录像时的验证,我觉得是roitgames为了安全,为每场游戏的协议数据包做的加密key,客户端观看ob 录像时,用这个key解密,以提高协议安全度。
在riotgames官方api介绍上,以及github上几个开源的lol项目上,以及本人wireshark抓包分析总结出一场ob游戏观看,一共调用了如下http api接口
GET /observer-mode/rest/consumer/version //获取录像文件版本信息,返回字符串
GET /observer-mode/rest/consumer/getGameMetaData/:region/:id/*ignore //获取游戏的基本信息,包括游戏当前桢,最后一个chunkid,游戏开始时间等信息。返回json格式字符串
GET /observer-mode/rest/consumer/getLastChunkInfo/:region/:id/:end/*ignore //获取录像中最后一个chunk基本信息,包括了当前chunkid,下一个chunkid,当前桢的id之类信息,返回json格式字符串
GET /observer-mode/rest/consumer/getGameDataChunk/:region/:id/:chunkId/*ignore //获取对应chunkid的协议数据,返回字节流
GET /observer-mode/rest/consumer/getKeyFrame/:region/:id/:frame/*ignore //获取对应chunkid的协议数据,返回字节流
lol ob观战模式的实现,是将游戏中所有玩家操作,合并成一个chunk块,来发送到客户端的,以减少通讯量。
GET /record/:region/:gameId/:encryptionKey 这个api在github上有提到过,但我抓包过程中,没有看到被调用过。暂时忽略。
至此已经知道lol 英雄联盟的观战通讯过程,但全球大区中,我们腾讯服务器跟欧美服务器不一样,并没有公开的ob server地址。而是在tgp腾讯游戏助手中实现的。
这是个嵌入的html,真实地址是 http://api.tgp.qq.com/spectator/v1410/watch.shtml ,对应的ob 观战列表文件为 http://api.pallas.tgp.qq.com/core/get_ob_list ,以ob文件http://ob.pallas.tgp.qq.com/ob_data/1_1632443320.ob 来举例子。对于国外其它服务器的观战,是连接到官方的观战服务器进行的。而腾讯lol的观战,却是将录像文件下载到本地的,那是否可说明tgp助手在本地建立了一个http服务器,并解析ob文件,实现ob观战服务器的功能呢?
为此开始确认这个过程,在tgp腾讯游戏助手上观战了这场游戏,查看游戏进程的启动参数中,ob server地址是多少,开Process Monitor抓进程启动参数,开tgp助手观战:
d:\program files\腾讯游戏\英雄联盟\Game\League of Legends.exe" "8391" "" "" "spectator 127.0.0.1:36722 KEY 1644911434 HN1"
如上图所示,启动参数复合riotgame的api介绍,我们来确认下127.0.0.1: 36722 是否正确,以及是哪个进程开启的。
replay.exe进程启动详情:
"D:\Program Files(x86)\Tencent\TGP\apps\pallas\replay.exe" -u QQ号 -p 1668 -t "D:\Program Files(x86)\Tencent\TGP\" -g ""d:\Program Files\腾讯游戏\英雄联盟"
腾讯tgp助手,确实在本地开启http ob server来实现的。那么进一步抓包验证。对于回环地址,wireshark抓不到的,要用RawCap来抓。
rawcap.exe 127.0.0.1 localhost.pcapng //捕获回环地址上的tcp数据通讯包
同时,wireshark 抓取 ob录像文件下载的数据包,查看localhost.pcanpng ,果然是lol ob录像系统调用的几个http请求。
一一查看version\getGameMetaData\getLastChunkInfo\getGameDataChunk\getKeyFrame几个请求。也确实以相应字符串、json字符串、字节流的形式返回,但数据源头来自哪里呢?肯定是最初的ob文件了。
如上图,其中开头的每行分隔的字符串,、json字符串也比较好理解,里面的元素也是lol ob录像系统请求相应的json字符串
LOLOB/1.0
abstract:{“src”: 1, “area_id”: 1, “score”: 5625000, “game_length”: 1260, “battle_type”: 4, “max_tier”: [0, 0], “game_id”: 1632443320, “start_time”: “2015-10-21 08:13:37”, “ob_ver”: “1.82.89”, “encryption_key”: “x83U9UPUuB/+INJyaU2Wz8lUuLn5aXEt”}
source:{“gameId”: 1632443320, “gameStartTime”: 1445386417603, “platformId”: “HN1”, “gameMode”: “CLASSIC”, “mapId”: 11, “gameType”: “MATCHED_GAME”, “gameQueueConfigId”: 4, “observers”: {“encryptionKey”: “x83U9UPUuB/+INJyaU2Wz8lUuLn5aXEt”}, “participants”: [{“profileIconId”: 7, “championId”: 60, “summonerName”: “\u5fc3\u6001\u597d\u80fd\u4e0a\u5206\u5417”, “skinIndex”: 2, “bot”: false, “spell2Id”: 11, “teamId”: 100, “spell1Id”: 4}, {“profileIconId”: 15, “championId”: 117, “summonerName”: “\u4e5d\u670813\u65e5\u4e36”, “skinIndex”: 5, “bot”: false, “spell2Id”: 4, “teamId”: 100, “spell1Id”: 12}, {“profileIconId”: 841, “championId”: 58, “summonerName”: “\u501a\u7af9\u8f7b\u8bed\u767d\u886b\u5982\u6545”, “skinIndex”: 2, “bot”: false, “spell2Id”: 4, “teamId”: 100, “spell1Id”: 12}, {“profileIconId”: 17, “championId”: 429, “summonerName”: “\u840c\u515c\u515cTcT”, “skinIndex”: 2, “bot”: false, “spell2Id”: 7, “teamId”: 100, “spell1Id”: 4}, {“profileIconId”: 914, “championId”: 122, “summonerName”: “\u91cd\u5e86\u897f\u5357\u5927\u5b66\u6821\u8349”, “skinIndex”: 0, “bot”: false, “spell2Id”: 4, “teamId”: 100, “spell1Id”: 14}, {“profileIconId”: 922, “championId”: 105, “summonerName”: “L4ugh1ng”, “skinIndex”: 8, “bot”: false, “spell2Id”: 12, “teamId”: 200, “spell1Id”: 4}, {“profileIconId”: 1, “championId”: 28, “summonerName”: “\u5170\u4ead\u5e8f”, “skinIndex”: 0, “bot”: false, “spell2Id”: 4, “teamId”: 200, “spell1Id”: 11}, {“profileIconId”: 9, “championId”: 203, “summonerName”: “\u8292\u5e02\u9189\u9999\u56ed\u996d\u5e84”, “skinIndex”: 0, “bot”: false, “spell2Id”: 4, “teamId”: 200, “spell1Id”: 12}, {“profileIconId”: 931, “championId”: 110, “summonerName”: “15\u5c81\u5357\u660c\u4eba\u670d\u4e0d\u670d”, “skinIndex”: 0, “bot”: false, “spell2Id”: 4, “teamId”: 200, “spell1Id”: 7}, {“profileIconId”: 28, “championId”: 432, “summonerName”: “\u6218\u4e36\u65d7TV\u767d\u8272\u98ce\u8f66”, “skinIndex”: 1, “bot”: false, “spell2Id”: 14, “teamId”: 200, “spell1Id”: 4}], “gameTypeConfigId”: 2, “gameLength”: 5, “bannedChampions”: [{“teamId”: 100, “championId”: 41, “pickTurn”: 1}, {“teamId”: 200, “championId”: 157, “pickTurn”: 2}, {“teamId”: 100, “championId”: 82, “pickTurn”: 3}, {“teamId”: 200, “championId”: 114, “pickTurn”: 4}, {“teamId”: 100, “championId”: 4, “pickTurn”: 5}, {“teamId”: 200, “championId”: 76, “pickTurn”: 6}]}
obmeta:{“lastChunkId”: 14, “gameKey”: {“platformId”: “HN1”, “gameId”: 1632443320}, “startTime”: “Oct 21, 2015 8:13:37 AM”, “endGameKeyFrameId”: -1, “gameLength”: 0, “keyFrameTimeInterval”: 60000, “port”: 0, “gameServerAddress”: “”, “interestScore”: 2989, “clientBackFetchingEnabled”: false, “clientAddedLag”: 30000, “endStartupChunkId”: 7, “chunkTimeInterval”: 30000, “clientBackFetchingFreq”: 1000, “pendingAvailableKeyFrameInfo”: [], “decodedEncryptionKey”: “”, “createTime”: “Oct 21, 2015 8:10:24 AM”, “featuredGame”: true, “gameEnded”: false, “encryptionKey”: “”, “delayTime”: 150000, “endGameChunkId”: -1, “pendingAvailableChunkInfo”: [], “startGameChunkId”: 9, “lastKeyFrameId”: 3}
keyframe_tab:[[1, 9], [2, 11], [3, 13], [4, 15], [5, 17], [6, 19], [7, 21], [8, 23], [9, 25], [10, 27], [11, 29], [12, 31], [13, 33], [14, 35], [15, 37], [16, 39], [17, 41], [18, 43], [19, 45], [20, 47], [21, 49]]
chunk_tab:[[1, 0], [2, 0], [3, 0], [4, 0], [5, 0], [6, 0], [7, 0], [8, 0], [9, 30018], [10, 30008], [11, 29986], [12, 30007], [13, 30000], [14, 29985], [15, 30019], [16, 29981], [17, 30000], [18, 30008], [19, 30003], [20, 29982], [21, 30012], [22, 30011], [23, 29980], [24, 29993], [25, 30018], [26, 29982], [27, 30018], [28, 29984], [29, 30020], [30, 29984], [31, 30016], [32, 29980], [33, 29994], [34, 30009], [35, 30016], [36, 29997], [37, 29976], [38, 30006], [39, 30008], [40, 29985], [41, 30000], [42, 30027], [43, 29972], [44, 30006], [45, 30016], [46, 29977], [47, 30024], [48, 29990], [49, 29994], [50, 20015]]
不难看出,其中“LOLOB/1.0”为tgp助手本身识别的版本信息;abstract是ob录像版本,以及录像数据加密key存储的基本信息;obmeta对应着getGameMetaData的数据;keyframe_tab跟chunk_tab暂时不知道是什么意思,可稍后再回来看。
ob文件的剩下其它部分,显然就是录像中,每个chunk包的录像数据的了,那它是如何拼装的呢?
腾讯tgp助手下载的ob文件中,录像部分开头的字节流是01 00 01 00 00 35 88 85 a0 4a 30 1b 30 27 d5 3b eb b2 12 4d 8c 0d 62 52 0b 15 9f d1 7a d4 98 0c 9d 01 7e d7 b6 6b,回头看看lol游戏进程获取ob录像chunk部分时数据包的内容开头部分
开头的字节流是85 a0 4a 30 1b 30 27 d5 3b eb b2 12 4d 8c 0d 62 52 0b 15 9f d1 7a d4 98 0c 9d 01 7e d7 b6 6b 7d,相比之下,重复部分是85 a0 4a 30 1b 30 27 d5 3b eb b2 12 4d 8c 0d 62 52 0b 15 9f d1 7a d4 98…,再多找几个录像获取数据的数据包,核对一下
可以确认,lol的ob录像回放协议中,协议头是85 a0 4a 30 1b 30 27 d5,也就是说,tgp下载的ob文件字节流部分,85 a0 4a 30 1b 30 27 d5前面的都不是lol协议的,这开头的01 00 01 00 00 35 88这7个字节为tgp读取识别的协议头部分。最初replay.exe程序读取理解的部分,本身不是lol客户端读取的协议部分。那么,这01 00 01 00 00 35 887个字节分别代表什么含义呢?
那我们开始找85 a0 4a 30 1b 30 27 d5出现的位置即可(关于在数M数据中,查找几个字节所在位置的地方,你最好有比较简便的方法,千万别告诉我你是一行一行看的。)
可以看出如下结果:
- 第一个在 00001124 部分,前面7个字节是01 00 01 00 00 35 88;
- 第二个在 000046B3 部分,前面7个字节是02 00 01 00 00 2b 10;
- 第三个在 000071CA 部分,前面7个字节是01 00 02 00 00 60 48;
- (wireshark会告诉你当前行所在的位置编号,记得加上所关注字节的偏移位置)
到了这里,根据编程灵(经)感(验),会这么猜想:第二个位置跟第一个位置相差了46B3-1124=358F个位置,也就是3588+7=358F(tgp协议头中,最后两个字节3588 + 协议头长度 7),用第三个跟第二个最比较以确认猜想对不对。71CA-46B3=2B17,即2B10+7符合猜测。
到此,可确认tgp协议头的7个字节中,最后2个字节表示这后续包的长度。但这后续包长度是否仅仅是后面2个字节所标示的,是不是后面3字节,还是4字节?还不能确定。能确定的是,开头的3个字节肯定不会表示后续包长度作用。如果你还是没发现这规律,那可不能怪我了,谁让你当时没有灵(jing)感(yan)呢?
如此一来,可以用更快的方法验证这个猜想,写一段程序,从ob录像文件中,分析tgp协议头,根据后2个字节对应长度的数据包,打印出前面tgp协议头的7字节,再打印后面8个字节(确认是否为85 a0 4a 30 1b 30 27 d5)
[1 0 1 0 0 53 136] [133 160 74 48 27 48 39 213 59 235 178 18 77 140 13 98 82 11 21 159 209 122 212]
[2 0 1 0 0 43 16] [133 160 74 48 27 48 39 213 54 53 74 75 93 254 127 91 102 147 88 122 160 207 84]
[1 0 2 0 0 96 72] [133 160 74 48 27 48 39 213 240 224 50 221 162 250 17 161 77 240 23 76 184 68 252]
[2 0 2 0 0 78 80] [133 160 74 48 27 48 39 213 137 33 26 130 55 190 142 64 241 79 180 111 211 4 8]
[1 0 3 0 0 125 208] [133 160 74 48 27 48 39 213 156 71 54 190 104 32 173 195 78 15 247 95 154 20 4]
[2 0 3 0 0 124 192] [133 160 74 48 27 48 39 213 71 93 96 97 131 246 103 242 117 117 242 122 102 57 252]
[1 0 4 0 0 153 128] [133 160 74 48 27 48 39 213 199 1 236 10 13 19 41 186 138 120 76 94 198 235 231]
[2 0 4 0 0 126 48] [133 160 74 48 27 48 39 213 161 190 218 133 34 189 202 6 4 210 38 115 68 138 93]
[1 0 5 0 0 145 56] [133 160 74 48 27 48 39 213 181 160 40 167 228 7 106 250 239 177 147 143 223 218 189]
[2 0 5 0 0 124 208] [133 160 74 48 27 48 39 213 133 89 166 5 196 161 242 20 46 198 139 26 18 52 142]
[1 0 6 0 0 180 152] [133 160 74 48 27 48 39 213 199 1 236 10 13 19 41 186 60 43 67 53 87 214 163]
[2 0 6 0 0 130 120] [133 160 74 48 27 48 39 213 158 207 13 38 213 94 203 78 214 51 158 92 104 197 112]
[1 0 7 0 0 34 32] [133 160 74 48 27 48 39 213 178 213 133 20 42 35 24 186 160 140 25 92 252 210 242]
[2 0 7 0 0 140 112] [133 160 74 48 27 48 39 213 46 67 112 101 89 72 41 205 82 117 228 27 204 109 200]
[1 0 8 0 0 82 24] [133 160 74 48 27 48 39 213 142 246 116 70 48 41 45 65 120 25 184 155 91 123 174]
[2 0 8 0 0 132 128] [133 160 74 48 27 48 39 213 253 10 27 170 202 136 197 234 239 187 196 5 166 215 182]
[1 0 9 0 0 132 32] [133 160 74 48 27 48 39 213 63 45 71 158 103 71 129 113 196 148 131 177 29 128 162]
[2 0 9 0 0 131 200] [133 160 74 48 27 48 39 213 52 170 14 71 17 205 63 28 76 253 41 134 241 227 200]
[1 0 10 0 0 70 0] [133 160 74 48 27 48 39 213 9 8 197 104 10 153 18 70 24 239 30 230 123 22 102]
[2 0 10 0 0 151 72] [133 160 74 48 27 48 39 213 208 112 28 134 21 105 39 113 51 90 157 36 215 94 173]
[1 0 11 0 0 69 192] [133 160 74 48 27 48 39 213 207 49 23 198 37 84 251 4 153 255 28 239 177 47 13]
[2 0 11 0 0 147 232] [133 160 74 48 27 48 39 213 64 115 149 233 120 244 31 210 191 42 252 38 126 32 27]
[1 0 12 0 1 2 80] [133 160 74 48 27 48 39 213 186 139 142 5 35 179 166 152 135 234 102 89 109 33 6]
[178 161 62 95 113 100 101] [113 58 47 114 56 216 181 49 171 213 150 80 50 222 228 94 34 110 55 170 212 163 177]
[2 213 106 162 142 22 91] [8 155 150 15 27 47 191 79 102 112 16 90 168 160 96 244 195 13 72 185 16 118 29]
当tgp协议头的第五个字节不为0时,开始发生了错乱,截取后续包的内容是113 58 47 114 56 216 181 49,并非133 160 74 48 27 48 39 213(即85 a0 4a 30 1b 30 27 d5的十进制),说明tgp最后2字节表示后续包长度不对,应该继续增加,从目前的错误来看,起码是3个字节,即第5、6、7个字节。但如果是3个字节,而第4个字节也一直是0,显然也不合适。我决定将用后面4个字节表示剩余包的长度来检验一次。如下图:
结果都正确的打印了tgp协议头,以及后续8字节的85 a0 4a 30 1b 30 27 d5(对应十进制为:133 160 74 48 27 48 39 213)
至此,tgp协议头的后4个字节已经确认含义了,那么剩余前3个字节中,第一个字节有两种数值01、02,这两个数值的含义也比较好弄明白,这时,我的灵(jing)感(yan),他们分别对应getGameDataChunk、getKeyFrame的两种类型,可以从tgp录像请求的http中加以确认,如下图:
从本地抓取127.0.0.1的通讯包中,找http请求为getGameDataChunk、getKeyFrame的相应包,对照响应body中,字节流跟ob文件分析结果中,对应的是否一致。请求url为getGameDataChunk的,到tgp协议头第一个字节01的字节流中找。请求为getKeyFrame的,到第一个字节02中查找。这里也可以确认无误了。
至此,tgp协议头中,只剩下第2、3个字节不名含义。在汇总录像播放时,请求的所有url,你会发现getGameDataChunk、getKeyFrame请求中,对应的id参数是递增的,如下图:
再根据最初打印的所有tgp协议头7字节内容中,当第1字节一样时,发现第2字节不变,而第3个字节持续递增,这时有灵(jing)感(yan)了吗?第3个字节肯定是url请求中第n个包的含义。第二字节也可能跟第三字节构成uint16类型的总包数的含义。若表示一场游戏中,录像回放系统中,全部数据包分包数只用1字节表示的话,那一共可存储1<<8 -1 也就是255个,数量可能不够,所以,我假设第二、第三字节一起表示录像回放总包的含义。至此,tgp的分包方式中,包头含义已经全部表示明白了。那么,剩下的,就时写个结构体或者map来存储这些字节流即可。
到这里,ob文件分析完成了,对于lol ob server请求的几个url中,只剩下getLastChunkInfo请求没有处理了,你要知道,ob文件最初的几个json字符串中,还剩下chunk_tab、keyframe_tab
type ChunkInfo struct { ChunkId int <code>json:"chunkId"</code> AvailableSince int <code>json:"availableSince"</code> NextAvailableChunk int <code>json:"nextAvailableChunk"</code> KeyFrameId int <code>json:"keyFrameId"</code> NextChunkId int <code>json:"nextChunkId"</code> EndStartupChunkId int <code>json:"endStartupChunkId"</code> StartGameChunkId int <code>json:"startGameChunkId"</code> EndGameChunkId int <code>json:"endGameChunkId"</code> Duration int <code>json:"duration"</code> }
聪明的你,这时应该有灵(jing)感(yan)了吧。你来实现实时?什么?还是没灵(jing)感(yan)?再见……
程序写好后,可以体验一下mac上lol客户端,看国服ob咯,不联网也可以看哦,只要从tgp助手那下载了ob录像文件。
国服客户端,提供观战功能并不能用,每次都是无法观战,或者观战数据加载错误之类的,不提供实时观战功能。ob录像请求的url返回404。
spectator tj-sg-varnish.lol.qq.com:8088 token***token 1439993577 WT1_NEW
英雄联盟,游戏分为4个主进程
- 更新进程:LoLpatcher
- 启动进程:LoLLauncher
- 客户端(游戏大厅):LolClient
- 游戏进程:LeagueofLegends
这里已实现mac osx上观看lol ob录像,故可得知mac lol 游戏主进程 可以解析 国服lol 产生的lol协议文件,可大约证明游戏进程是兼容的,甚至一致的。。 更新进程上关系不大,只要保证版本一致即可。启动进程跟客户端两个进程实现了登陆认证功能,要做的,是实现这国服qq登陆认证绑定,也就是说mac osx上玩国服lol 理论上可行….要不,你来试试?
代码开源:
CFC4N的博客 由 CFC4N 创作,采用 署名—非商业性使用—相同方式共享 4.0 进行许可。基于https://www.cnxct.com上的作品创作。转载请注明转自:在mac osx上看lol国服ob录像的技术分析
千里迢迢,给咱群的游戏开发大师,点赞
这种游戏真他咩的XX。最讨厌多个进程的游戏。
这是神马IDE啊~~
IntelliJ IDEA