【明日方舟/ARG】小刻也能听懂的 AMa-10 解谜二维码复原解析
智能BGM
编辑于 2026年05月14日 03:00
明日方舟创作者应援计划

本文按 CC BY-NC-SA 4.0 署名-非商业性使用-相同方式共享 协议开放转载权,转载请注明出处

专栏撰写@智能BGM​,由 deepseek 辅助,感谢 @ZeroAurora零极光酱​ 提供完整思路


由于 【明日方舟/ARG】预言家的最初指令 —— AMa-10 解谜过程记录​ 中复原二维码的过程不够严谨,以及一众解析视频中似乎都没有详细描写该过程,故写此文补全推理链条

本文仅会简要的介绍纠错过程中涉及到的二维码技术,如果你想深入了解二维码背后的的完整工作原理,这里有个推荐的视频

我在围棋板上做了个二维码来探索其背后的工作原理​


前文提到,可恢复出二维码如下,该二维码为大小 25*25 的 Version 2 标准二维码(QR Code)

而根据二维码的结构组成,我们可以画出一个示意图

其中橙色的部分为定位码和对齐码,蓝色的部分为时序图形,这两个部分是所有版本 2 的标准二维码中都会出现的部分,用于相机从不同角度扫描二维码时计算图像畸变并恢复原始图像。这些部分对恢复数据没有帮助,真正值得关注的是红色的格式信息

上图中被红色标出的部分为二维码的格式信息串,为了保证二维码的抗污损能力会有一份副本,两份数据均可读出该二维码的格式信息为 110100101110110,对应的纠错等级为 L,掩码为 7

https://www.thonky.com/qr-code-tutorial/format-version-tables

这时候就要涉及到二维码的数据编排方式。在构造二维码的数据编码区域时,数据的填充顺序是从右下角开始,沿着特定的蛇形路径,从右向左、从下向上交替进行

而其中的数据又可以被分为两部分:原始数据和纠错数据。原始数据保存真正要表达的内容,而纠错数据则是根据原始数据计算出来的一组“校验线索”。按照图中信息,可计算如下:

%E5%8E%9F%E5%A7%8B%E6%95%B0%E6%8D%AE%E6%8D%9F%E5%9D%8F%E7%8E%87%20%3D%20%5Cfrac%7B%E7%BC%BA%E5%A4%B1%E5%8E%9F%E5%A7%8B%E6%95%B0%E6%8D%AE%7D%7B%E6%80%BB%E5%8E%9F%E5%A7%8B%E6%95%B0%E6%8D%AE%7D%20%3D%20%5Cfrac%7B145%5C%20bit%7D%7B272%5C%20bit%7D%20%20%5Capprox%2053.3%5C%25

%E7%BA%A0%E9%94%99%E6%95%B0%E6%8D%AE%E6%8D%9F%E5%9D%8F%E7%8E%87%20%3D%20%5Cfrac%7B%E7%BC%BA%E5%A4%B1%E7%BA%A0%E9%94%99%E6%95%B0%E6%8D%AE%7D%7B%E6%80%BB%E7%BA%A0%E9%94%99%E6%95%B0%E6%8D%AE%7D%20%3D%20%5Cfrac%7B23%5C%20bit%7D%7B80%5C%20bit%7D%20%20%5Capprox%2028.8%5C%25

而由于等级 L 的纠错能力约为 7%,显然无法直接计算出原始二维码数据。也正是在这里,解谜陷入了停滞。为了继续推进下去,我们需要理清原始数据和纠错数据之间的关系


二维码在存储数据时,会将每 8 bit 的信息作为一组,称作一个“码字”,原始数据对应原始码字,纠错数据对应纠错码字。如果将原始数据简要地理解为 m 个未知数,那么纠错码就是 n 个线性无关的方程,这些方程组每个都包含前面的 m 个未知数

因此,在满足一定条件的情况下,就可以通过解方程的方式算出所有未知数。在 里德-所罗门(Reed-Solomon)纠错算法 下, 恢复信息的要求是:可读的码字数量 ≥ m,即 损坏的码字数量 ≤ n

这里也很好理解:假设 m 个可读码字中有 m-x 个原始码字,那么就有 x 个纠错码字,也就是说有 x 个未知数和 x 个方程组要解,那么一定能解出这些剩余的未知数。当 x = 0 时,所有可读码字全为原始码字,那么可直接读出原始信息,也就是未知数全部已知,不需要解方程组。当 x = m 时,所有可读码字全为纠错码字,则我们有 m 个方程组,去解出这些方程组中的 m 个未知数,同样可解


有了以上的理论知识,解谜就可以继续了。

对于 Version 2 的标准二维码,按照其标准格式有 m=34,n=10,也就是原始数据码字为 34,纠错数据码字为 10,那么我们只需要至少 34 个可读码字就能恢复所有信息

而我们手头这个二维码,已知的部分有原始可读数据为 127 bit,为 15 码字;纠错数据 57 bit,为 7 码字。15 原始 + 7 纠错 = 22 个可读码字,显然无法恢复信息,我们之前的推论是正确的(无法读取整个码字的部分需要直接丢弃)

但是注意力惊人的同学可能已经想到了,二维码的右半边可见代表着我们拥有一部分原始数据。并且根据二维码的数据排列方式,这部分原始数据对应的正是完整数据的开头。因此可以通过运算直接解出,这里给出脚本的运行结果

可以看到 readable 部分明显出现了 https://ak. 的样式,并且整个二维码包含的信息为 32 字节。由于 URL 中的英文字母和符号在 UTF-8 编码下各占 1 字节,因此可直接对应为 32 个字符

根据过往的解谜经验补全网址为方舟官网后,该字符串可更新为 https://ak.hypergryph.com/[26][27][28][29][30][31],也即缺失的信息为 6 个字符

此时,则可以按照已知内容填充原始码字到图中,加上固有的时序图形,二维码看起来长这样

此时,二维码情况如下:原始数据由 127 bit 变为 216 bit,即 27 个码字。那么加上之前的 7 个纠错码字,总的可读码字数变为 27 + 7 = 34 码字,正好等于 Version 2 标准二维码的最低可恢复限度,那么根据里德-所罗门算法去接方程组,即可得出原先二维码的全部信息

至此,二维码复原过程完成补全

信息学,多么神奇啊


下面给出使用的脚本,也可前往 gist 查看 SherkeyXD@gist: ama10.py​

代码块
Python
自动换行
复制代码
import math
import numpy as np
import qrcode.base as qrbase
from qrcode import constants
from qrcode.main import QRCode
from qrcode.util import QRData, MODE_8BIT_BYTE, create_data
from reedsolo import RSCodec, ReedSolomonError

QR_MATRIX = np.array([
    [1,1,1,1,1,1,1,0,0,-1,-1,-1,-1,-1,-1,-1,0,0,1,1,1,1,1,1,1],
    [1,0,0,0,0,0,1,0,1,-1,-1,-1,-1,-1,-1,-1,0,0,1,0,0,0,0,0,1],
    [1,0,1,1,1,0,1,0,1,-1,-1,-1,-1,-1,-1,-1,0,0,1,0,1,1,1,0,1],
    [1,0,1,1,1,0,1,0,0,-1,-1,-1,-1,-1,-1,-1,0,0,1,0,1,1,1,0,1],
    [1,0,1,1,1,0,1,0,1,-1,-1,-1,-1,-1,-1,-1,1,0,1,0,1,1,1,0,1],
    [1,0,0,0,0,0,1,0,1,-1,-1,-1,-1,-1,-1,-1,0,0,1,0,0,0,0,0,1],
    [1,1,1,1,1,1,1,0,1,-1,-1,-1,-1,-1,-1,-1,1,0,1,1,1,1,1,1,1],
    [0,0,0,0,0,0,0,0,1,-1,-1,-1,-1,-1,-1,-1,1,0,0,0,0,0,0,0,0],
    [1,1,0,1,0,0,1,1,0,-1,-1,-1,-1,-1,-1,-1,0,0,1,1,1,0,1,1,0],
    [1,1,1,1,0,0,0,0,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,0,0,0,0,0,1],
    [1,0,0,0,1,0,1,1,0,-1,-1,-1,-1,-1,-1,-1,1,0,0,0,1,0,0,1,1],
    [1,0,1,0,0,0,0,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,0,0,0,0],
    [1,1,0,0,1,1,1,0,1,-1,-1,-1,-1,-1,-1,-1,1,0,1,0,0,1,0,1,1],
    [0,0,1,0,1,0,0,1,1,-1,-1,-1,-1,-1,-1,-1,0,1,1,1,0,1,1,0,1],
    [1,0,1,0,1,0,1,0,1,-1,-1,-1,-1,-1,-1,-1,1,0,0,1,1,0,1,0,1],
    [0,1,0,1,1,0,0,1,0,-1,-1,-1,-1,-1,-1,-1,0,1,1,0,1,0,0,1,0],
    [1,1,0,1,0,0,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,1,1,0,0],
    [0,0,0,0,0,0,0,0,1,-1,-1,-1,-1,-1,-1,-1,1,0,0,0,1,1,0,0,1],
    [1,1,1,1,1,1,1,0,1,-1,-1,-1,-1,-1,-1,-1,1,0,1,0,1,1,0,1,1],
    [1,0,0,0,0,0,1,0,0,-1,-1,-1,-1,-1,-1,-1,1,0,0,0,1,1,1,1,0],
    [1,0,1,1,1,0,1,0,0,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,1,0,1,1],
    [1,0,1,1,1,0,1,0,1,-1,-1,-1,-1,-1,-1,-1,0,0,0,1,1,1,1,1,1],
    [1,0,1,1,1,0,1,0,0,-1,-1,-1,-1,-1,-1,-1,1,0,0,1,1,0,1,0,1],
    [1,0,0,0,0,0,1,0,1,-1,-1,-1,-1,-1,-1,-1,1,0,1,0,0,1,0,0,0],
    [1,1,1,1,1,1,1,0,1,-1,-1,-1,-1,-1,-1,-1,1,0,1,1,0,0,0,1,1],
], dtype=int)

DAMAGED_COLS = frozenset(range(9, 16))
KNOWN_PREFIX = b"https://ak.hypergryph.com/"

MASK_TABLE = [
    lambda r, c: (r + c) % 2 == 0,
    lambda r, c: r % 2 == 0,
    lambda r, c: c % 3 == 0,
    lambda r, c: (r + c) % 3 == 0,
    lambda r, c: (r // 2 + c // 3) % 2 == 0,
    lambda r, c: (r * c) % 2 + (r * c) % 3 == 0,
    lambda r, c: ((r * c) % 2 + (r * c) % 3) % 2 == 0,
    lambda r, c: (((r + c) % 2) + ((r * c) % 3)) % 2 == 0,
]


def mask_applies(mask_idx, r, c):
    return MASK_TABLE[mask_idx](r, c)


def demask(val, mask_idx, r, c):
    return 1 - val if mask_applies(mask_idx, r, c) else val


def build_data_order(version, ecc, mask_idx):
    qr = QRCode(version=version, error_correction=ecc, box_size=1, border=0)
    qr.map_data = lambda data, mask: None
    qr.makeImpl(False, mask_idx)

    order, inc, row = [], -1, qr.modules_count - 1
    for col in range(qr.modules_count - 1, 0, -2):
        if col <= 6:
            col -= 1
        while True:
            for c in (col, col - 1):
                if qr.modules[row][c] is None:
                    order.append((row, c))
            row += inc
            if not (0 <= row < qr.modules_count):
                row -= inc
                inc = -inc
                break
    return order


def extract_codewords(matrix, data_order, damaged_cols, mask_idx):
    n = len(data_order) // 8
    cw = bytearray(n)
    erased = [False] * n
    for i in range(n):
        byte_val = 0
        has_erasure = False
        for j in range(8):
            r, c = data_order[i * 8 + j]
            v = matrix[r, c]
            if v == -1 or c in damaged_cols:
                has_erasure = True
            elif demask(v, mask_idx, r, c):
                byte_val |= 1 << (7 - j)
        cw[i] = byte_val
        erased[i] = has_erasure
    return cw, erased


def parse_header(cw):
    mode = cw[0] >> 4
    count = ((cw[0] & 0x0F) << 4) | (cw[1] >> 4)
    return mode, count


def read_data_byte(cw, idx, bit_offset=12):
    lo, hi = bit_offset + idx * 8, bit_offset + idx * 8 + 7
    byte_val = 0
    for j in range(8):
        p = lo + j
        if (cw[p // 8] >> (7 - p % 8)) & 1:
            byte_val |= 1 << (7 - j)
    return byte_val


def decode_readable_bytes(cw, erased, count, bit_offset=12):
    result, lost = [], []
    for d in range(count):
        s = bit_offset + d * 8
        involved = range(s // 8, (s + 7) // 8 + 1)
        if any(erased[i] for i in involved if i < len(erased)):
            lost.append(d)
            result.append(None)
        else:
            result.append(read_data_byte(cw, d, bit_offset))
    return result, lost


def step1(matrix, damaged_cols, version, ecc, mask_idx):
    order = build_data_order(version, ecc, mask_idx)
    cw, erased = extract_codewords(matrix, order, damaged_cols, mask_idx)
    mode, count = parse_header(cw)
    decoded, lost = decode_readable_bytes(cw, erased, count)
    return dict(
        cw=cw, erased=erased, mode=mode, count=count, decoded=decoded, lost_bytes=lost
    )


def cw_data_bytes(cw_idx, bit_offset=12):
    lo, hi = cw_idx * 8, cw_idx * 8 + 7
    first = max(0, math.ceil((lo - bit_offset - 7) / 8))
    last = (hi - bit_offset) // 8
    return range(first, last + 1) if first <= last else range(0)


def extract_bytes(cw, start, n, bit_offset=12):
    return bytes(read_data_byte(cw, d, bit_offset) for d in range(start, start + n))


def step2(cw, erased, prefix, version, ecc, mask_idx):
    count = ((cw[0] & 0x0F) << 4) | (cw[1] >> 4)
    path_len = count - len(prefix)

    blocks = qrbase.rs_blocks(version, ecc)
    total_cw = sum(b.total_count for b in blocks)
    data_cw = sum(b.data_count for b in blocks)
    ec_cw = total_cw - data_cw

    ref = create_data(
        version, ecc, [QRData(prefix + b"X" * path_len, mode=MODE_8BIT_BYTE)]
    )

    inp, erasures = bytearray(total_cw), []
    for i in range(total_cw):
        if not erased[i]:
            inp[i] = cw[i]
            continue
        valid = [d for d in cw_data_bytes(i) if d < count]
        is_prefix = len(valid) > 0 and all(d < len(prefix) for d in valid)
        if is_prefix:
            inp[i] = ref[i]
        else:
            erasures.append(i)

    if len(erasures) > ec_cw:
        raise RuntimeError(f"Too many erasures ({len(erasures)} > {ec_cw})")

    try:
        corrected = RSCodec(ec_cw).decode(inp, erase_pos=erasures)[0]
    except ReedSolomonError as e:
        raise RuntimeError(f"RS decode failed: {e}")

    path = extract_bytes(corrected, len(prefix), path_len)
    return dict(path=path, full_url=prefix + path, erasures=erasures)


def main():
    V, E, M = 2, constants.ERROR_CORRECT_L, 7

    print("=" * 56)
    print(f"  QR 修复  V{V} ECC=L Mask={M}")
    print("=" * 56)

    r1 = step1(QR_MATRIX, DAMAGED_COLS, V, E, M)
    readable = "".join(
        chr(b) if b is not None else f"[{i}]" for i, b in enumerate(r1["decoded"])
    )
    print(f"\n  count={r1['count']}  mode={'Byte' if r1['mode']==4 else '?'}")
    print(f"  readable: {readable}")
    print(f"  erased:   {sum(r1['erased'])}/44 codewords")

    r2 = step2(r1["cw"], r1["erased"], KNOWN_PREFIX, V, E, M)
    print(f"  erasures: {r2['erasures']}")

    print("\n" + "=" * 56)
    print(f"\n  prefix: {KNOWN_PREFIX.decode()}")
    print(f"  path: {r2['path'].decode()}")
    print(f"  URL:  {r2['full_url'].decode()}")
    print("\n" + "=" * 56)


if __name__ == "__main__":
    main()
复制成功

为什么这篇文章拖了这么久呢 因为我懒

为什么今天又想起来写呢?一时兴起了,一时兴起了