炉石卡组代码解析

在前面的文章Base64编码的原理与实现中,讲到了Base64编码的诸多用处,例如编码图片以嵌入HTML代码。如果你对此有所了解,同时还是一个炉石玩家,就不难发现,暴雪推出的卡组代码功能,本质上就是用一个Base64编码的字符串来存储卡组信息。毕竟,你有时会见到以=或者==结尾的卡组代码,这正是Base64编码的典型特征。

注释

当把卡组代码粘贴到炉石传说时,客户端将忽略以#字符开头的任何行,这就提供了在卡组代码中插入注释的可能性。一个例外是在卡组代码最前面的,以###开头的第一行。如果可以的话,它将被用作套牌名称。你可以从炉石中复制一套卡组到文本编辑器看看效果。
没有被注释的那一行,包含了除套牌名称外的所有信息。玩家在各大论坛、社区分享卡组时,也往往只需要这一行卡组代码。

DBF ID

为了了解编码的机制,我们需要先介绍DBF ID。这是每张炉石卡牌(包括可收集卡牌、衍生卡牌、冒险模式专属卡牌、英雄皮肤等)的唯一标识符——是的,这也是能够卡出下图这种BUG的原因。皮肤被标记为了一张紫卡。

而炉石卡组代码正是使用DBF ID来表示每张卡牌。DBF ID可在游戏文件中找到。更加方便的方法是通过HearthstoneJSON,这上面提供了相关API。你也可以在这个网站上直接下载最新的cards.collectible.json,这里面包含了所有可收集卡牌的信息,例如各种语言的卡牌名称、描述等。

格式

除非另有说明,否则后面的每个值都是一个整数,编码为无符号的varint
如前所述,卡组代码是Base64编码的字节串。我们先来解码它。许多编程语言都可以做到这一点,以最好的语言$PHP为例:

1
2
3
4
5
$deckstring = "AAEBAf0GAA/OBpcHzAjiDP8PyBTmFrasAq6wAqW+Avi/Avm/AqLNAvjQAqbvAgA=";
#这是一个非常有趣的萨满卡组
$binary = base64_decode($deckstring);
$hex = bin2hex($binary);
#对于这个卡组,$hex="00010101fd06000fce069707cc08e20cff0fc814e616b6ac02aeb002a5be02f8bf02f9bf02a2cd02f8d002a6ef0200"

这样就能得到初步解码后的十六进制字符串。如果你不是很擅长编程,也可以搜索一些现成的Base64解码工具,看看它是如何工作的。
将这个十六进制字符串按每两个元素切割,再把它们从16进制字符串转化为整型。也就是说,它们可以是00-ff,即0-255。一种实现的方法是这样:

1
2
$arr = str_split($hex, 2);
$arr = array_map("hexdec", $arr);

$arr是一个由整数组成的数组。更具体的来说,就是varint。我们还需要进一步的解码,才能读取出有用的信息:

1
2
3
4
5
6
7
8
9
10
11
function read_varint(&$data) {
$shift = 0;
$result = 0;
do {
$c = array_shift($data);
$result |= ($c & 0x7f) << $shift;
$shift += 7;
}
while ($c & 0x80);
return $result;
}

这里的array_shift会将数组的第一个元素移出数组,并返回它的值。在其它的编程语言中,类似的方法可能被叫做lpoppopshift等等。而|&<<等都是位运算的运算符。read_varint函数完成了解码过程,如果有兴趣的话可以试试用C或Python实现它。
不断地执行read_varint($arr),直到取完$arr中的元素,就能把内容全部解码出来。

根据作用,我们可以把解码后的卡组代码分为两个部分:元数据块和卡牌块。

元数据块

$arr中,前五个元素分别为:

  • 保留字节0x00
  • 版本号(固定为1)
  • 模式(1为狂野,2为标准)
  • 使用英雄卡牌的数量(固定为1)
  • 使用的英雄卡牌的类型(长度不确定,一般是1-3位)

可见卡组代码以字节0x00开头。然后是编码版本号,目前始终为1。虽然这五个元素并没有官方名称,不过从作用上可以看作元数据。
还有一点是,所谓『英雄卡牌的类型』就是你所使用的英雄皮肤。这个参数必不可少。英雄的职业决定了套牌所用的职业,但是套牌表面上是为英雄制作的,而不是为一个职业。如果卡组代码指定的英雄是皮肤而不是默认的英雄,那么在你复制卡组时,系统就会选择使用皮肤而不是默认英雄(前提是你买了这个皮肤)。
关于英雄的注意事项:尽管它是一个数组,但它是用于初始英雄,而不是可打出的英雄牌(如冰封王座的DK)。

卡牌块

在元数据块之后,继续读取$arr的元素,那么接下来就轮到卡牌块了。它按以下顺序分为三对长度+数组的组合:

  • 卡组中存在一张的卡
  • 卡组中存在两张的卡
  • 卡组中存在n张的卡

每对都有一个前导varint,指定数组中的项目数。这种结构的目标是使卡组代码尽可能紧凑。如前所述,每张卡都用varint型的DBF ID表示。

所谓『卡组中存在n张的卡』,指的是卡组中的所有其他卡牌。此数组是一个varint对组成的列表,每一对的第一个元素表示DBF ID,第二个元素是该卡牌在卡组中出现的次数。它应该只包含在牌组中至少出现三次的牌,因而意味着它(在这种情况下)对于构筑卡组应当是空的(毕竟一张卡至多只能带两张);但理论上它也可以包含有一张的卡和有两张的卡。

尽管最终排序无关紧要,但卡牌仍会在各自所在的数组中,按DBF ID的升序进行排序,以便始终为相同的卡组生成相同的卡组代码。我们称所有按照这种方式排列卡牌列表(包括英雄)的卡组代码为规范的卡组代码。下面的参考实现应该已经可以生成规范的卡组代码。

一种实现

看代码可能比文字描述更加直观。下面的parse_deck()函数就会按照前面介绍的方法,依次读取元数据块和卡牌块的信息。

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
function parse_deck($data) {
$reserve = read_varint($data);
if ($reserve != 0) {
printf("Invalid deckstring");
die;
}
$version = read_varint($data);
if ($version != 1) {
printf("Unsupported deckstring version %s", $version);
die;
}
$format = read_varint($data);
$heroes = [];
$num_heroes = read_varint($data);
for ($i = 0; $i < $num_heroes; $i++) {
$heroes[] = read_varint($data);
}
$cards = [];
$num_cards_x1 = read_varint($data);
for ($i = 0; $i < $num_cards_x1; $i++) {
$card_id = read_varint($data);
$cards[] = [$card_id, 1];
}
$num_cards_x2 = read_varint($data);
for ($i = 0; $i < $num_cards_x2; $i++) {
$card_id = read_varint($data);
$cards[] = [$card_id, 2];
}
$num_cards_xn = read_varint($data);
for ($i = 0; $i < $num_cards_xn; $i++) {
$card_id = read_varint($data);
$count = read_varint($data);
$cards[] = [$card_id, $count];
}
return [$cards, $heroes, $format];
}
parse_deck($arr);

这时,再通过建立DBF ID与卡牌对应关系的数据库,就能够实现在游戏外导入、导出和编辑卡组的功能了。不论是官方的卡牌工具,还是第三方平台(如旅法师营地)的套牌编辑功能,原理都是相似的。
所有代码已开源:hearthstone-deck,这是本文中所使用的PHP版本。博主后来又用Node.js重写了这个应用,方便在Heroku上部署:hsdeck
其他语言的实现方式可以在HearthSim的官方GitHub账户下找到。


博主搭建的API:炉石传说卡组

拓展阅读:
在网页上展示你的炉石卡组吧
炉石传说卡组代码生成机制
Hearthstone Deckstrings

🍭支持一根棒棒糖!
0%