炉石卡组原理解析


最近看Base64的时候看到了知乎上一个关于炉石卡组代码的帖子,它本质上就是用一个Base64编码的字符串来存储卡组信息。你有时会见到以=或者==结尾的卡组代码,这正是Base64编码的典型特征。

DBF ID

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

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

格式

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

function parse_deckstring(deckstring) {
    let binary = Buffer.from(deckstring, "base64");
    let hex = binary.toString("hex");
    let arr = hex.match(/.{1,2}/g);
    return arr.map(x => parseInt(x, 16));
}

这个函数会先将Base64字符串解码成2进制,然后转换成16进制,再按每两个元素切割,最后从16进制字符串转化为整型并返回。也就是说,它们可以是 0x00-0xff,即 0-255。

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

function read_varint(data) {
    let shift = 0;
    let result = 0;
    let c;
    do {
        c = data.shift();
        result |= (c & 0x7f) << shift;
        shift += 7;
    }
    while (c & 0x80);
    return result;
}

这里的data.shift()会将数组的第一个元素移出数组,并返回它的值。read_varint()函数完成了解码过程,不断地执行read_varint(),直到取完arr中的元素,就能把内容全部解码出来。

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

元数据块

在arr数组中,前五个元素分别为:

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

可见卡组代码以字节 0x00 开头。然后是编码版本号,目前始终为 1。虽然这五个元素并没有官方名称,不过从作用上可以看作元数据。

卡牌块

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

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

每张卡都用 varint 型的 DBF ID 表示。

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

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

function parse_deck(data) {
    let reserve = read_varint(data);
    if (reserve !== 0) {
        return "Invalid deckstring";
    }
    let version = read_varint(data);
    if (version !== 1) {
        return "Unsupported deckstring version " + version;
    }
    let format = read_varint(data);
    let heroes = [];
    let num_heroes = read_varint(data);
    for (let i = 0; i < num_heroes; i++) {
        heroes.push(read_varint(data));
    }
    let cards = [];
    let num_cards_x1 = read_varint(data);
    for (let i = 0; i < num_cards_x1; i++) {
        card_id = read_varint(data);
        cards.push([card_id, 1]);
    }
    let num_cards_x2 = read_varint(data);
    for (let i = 0; i < num_cards_x2; i++) {
        card_id = read_varint(data);
        cards.push([card_id, 2]);
    }
    let num_cards_xn = read_varint(data);
    for (let i = 0; i < num_cards_xn; i++) {
        let card_id = read_varint(data);
        let count = read_varint(data);
        cards.push([card_id, count]);
    }
    return {
        cards,
        heroes,
        format
    };
}

这时,再通过建立 DBF ID与卡牌对应关系的数据库,就能够实现在游戏外导入、导出和编辑卡组的功能了。不论是官方的卡牌工具,还是第三方平台(如旅法师营地、盒子)的套牌编辑功能,原理都是相似的。

搭在heroku上的API