炉石卡组代码解析

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

注释

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

DBF ID

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

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

格式

如前所述,卡组代码是 Base64 编码的字节串,我们先来解码它。许多编程语言都可以做到这一点,以最好的语言 $PHP 为例:

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

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

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

这样我们就得到了一个数组 $arr,它的每个元素都是 8 位无符号整数。更具体的来说,它的编码方式其实是 varint,一种自解释、不定长的编码方案。炉石卡组代码中包含卡牌 DBF ID 和卡牌数量等信息,它们都是整数,但大小相差很多。如果用定长编码,将会需要大量填 0,并且在未来推出 DBF ID 更大的卡牌后可能会不兼容。而 varint 编码则非常适合炉石卡组的情况。对于 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 会将数组的第一个元素移出数组,并返回它的值。而 |&<< 等都是位运算的运算符。read_varint 函数完成了解码过程,如果有兴趣的话可以试试用 C 或 Python 实现它。
不断地执行 read_varint($arr),直到取完 $arr 中的元素,就能把内容全部解码出来。解码的结果同样是一个无符号整数的数组,但每个元素的大小不再是只有 8 位,其元素个数也少于原始的 $arr

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

元数据块

在解码的数组中,前五个元素分别为:

  • 保留字节 0x00
  • 版本号(固定为 1)
  • 模式(1 为狂野,2 为标准)
  • 使用英雄卡牌的数量(固定为 1)
  • 使用的英雄卡牌的类型

可见卡组代码以字节 0x00 开头,然后是编码版本号,目前始终为 1。虽然这五个元素并没有官方名称,不过从作用上可以看作元数据。
还有一点是,这里所谓「英雄卡牌」是指你保存的卡组所使用的英雄皮肤,而不是卡组里面可打出的英雄牌(如冰封王座的 DK)。卡组所属的职业也正是这个英雄的职业,因而这个参数必不可少。如果卡组代码指定的英雄皮肤不是默认的英雄(例如法师选择了卡德加而不是吉安娜),那么在复制卡组时,系统就会选择使用皮肤而不是默认英雄(前提是在复制卡组的人买了这个皮肤)。

卡牌块

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

  • 卡组中存在一张的卡的数量
  • 存在一张的卡的 DBF ID(数组)
  • 卡组中存在两张的卡的数量
  • 存在两张的卡的 DBF ID(数组)
  • 卡组中存在 n 张的卡的数量
  • 存在 n 张的卡的 DBF ID 和数量 n(数组)

这种结构的目标是使卡组代码尽可能紧凑,并且在解析的时候不会出现歧义。

所谓「卡组中存在 n 张的卡」,指的是卡组中的所有其他卡牌。此数组的每个元素包含一对整数,其中第一个整数表示 DBF ID,第二个整数是该卡牌在卡组中出现的次数。它应当只包含在卡组中出现至少三次的牌,由于在天梯构筑卡组中,一张卡至多只能带两张,因此其数量应当为 0。当然,理论上它也可以包含有一张的卡和有两张的卡。

卡组中卡牌的排序方式有很多,例如按照费用、版本或者稀有度,这对玩家来说这是无关紧要的。而炉石对卡组代码的编码标准是将以上三个数组中的卡牌分别按照 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,这是本文中所使用的 PHP 版本。笔者后来又用 Node.js 重写了这个应用:hearthstone-deck
其他语言的实现方式可以在 HearthSim 的官方 GitHub 账户下找到。


笔者搭建的 API:炉石传说卡组

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