Logo

site iconMayx

兴趣范围从技术到网络开发,还对编程、DIY和设计感兴趣。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Mayx RSS 预览

如何制作一个“完整”的博客压缩包?

2026-10-01 00:00:00

让AI做出真正的“完整”将不再是难事……

起因

在上次用AI制作了各种东西之后,我已经完全理解了AI确实是无所不能的。既然如此,那就让它帮我解决曾经未能解决的事情吧?
去年,我为了让下载全站压缩包的按钮不断链,让这个压缩包也包含它本身而研究了ZIP Quine,但限于DEFLATE的回溯窗口大小没能做到……但那是人做的东西,人还是太弱小了,现在换AI来试试,也许一切将变得不一样?

制作基于LZMA2的博客压缩包

首先,我把Ruben Van Mello写的那篇论文《A Generator for Recursive Zip Files》以及生成器zip-quine-generator发给了AI,问它这个限制是不是真的,是不是真的无法创造出超过32KiB的ZIP Quine?没过多久它分析完了,告诉我确实存在这样的问题,但并不是无法解决的,最简单的办法就是换个回溯窗口更大的压缩算法。我看了一下它给我列出的几个算法,看起来LZMA2和zstd比较符合要求。不过zstd感觉不是很知名,一般的解压软件应该处理不了,所以我就选择让它基于LZMA2来制作了。
不过LZMA2只是算法,还得选个容器,考虑到7z算是最出名,而且LZMA2本来就是7-Zip的作者开发的,所以就直接让它做7z版本的了。另外因为Nate Choe做了通过扩展欧几里得求逆元来计算CRC32的PR,所以我也告诉它要用这种方法在多项式时间内解出CRC32的值,而不是爆破。就这样AI花了两个小时左右成功把代码写出来了,效果非常完美,另外它还偷懒地将计算CRC32的算法改成了高斯消元法😆,因为7z格式有3个相互影响的CRC32值,用扩展欧几里得会更复杂一点,不过对7z来说不写入文件的CRC32也不影响,只是会不校验罢了,至少它还是按我要求做了……但其实这个问题也并不是完全不能用扩展欧几里得算法,后来我又强烈要求了一下,AI还是给我写出来了。以下是代码片段,有兴趣的人可以参考一下(不过这个看起来写的就是很复杂,所以我自己只用了高斯消元法):

RU_POLY = 0x104C11DB7          # x^32+x^26+x^23+...+1(含 x^32 首项)


def _find_probe(p):
    ret = 0
    for i in range(64):
        if (1 << i) & p:
            ret = i
    return ret


def _mul_raw(p1, p2):
    """多项式乘法(无模)。"""
    ret = 0
    probe = _find_probe(p1)
    for i in range(64):
        if p2 & (1 << i):
            assert probe + i < 64, "多项式乘法溢出"
            ret ^= p1 << i
    return ret


def _poly_divmod(dividend, divisor):
    """多项式长除法,返回 (商, 余)。"""
    probe = _find_probe(divisor)
    probe_bit = 1 << probe
    quot = 0
    rem = dividend
    for i in range(63 - probe, -1, -1):
        if rem & (probe_bit << i):
            quot |= 1 << i
            rem ^= divisor << i
    return quot, rem


def _mul(p1, p2, mod):
    """p1*p2 % mod。"""
    return _poly_divmod(_mul_raw(p1, p2), mod)[1]


def _xgcd(p1, p2):
    """扩展欧几里得:返回 (k1,k2,gcd),满足 p1*k1+p2*k2=gcd。"""
    if _find_probe(p1) < _find_probe(p2):
        k1, k2, g = _xgcd(p2, p1)
        return k2, k1, g
    if p2 == 0:
        return p1, 0, p1
    q, r = _poly_divmod(p1, p2)
    c1, c2, g = _xgcd(p2, r)
    return c2, c1 ^ _mul_raw(c2, q), g


def _minv(p, mod):
    """p 在环上的乘法逆元;不存在时返回 0。"""
    k1, _, g = _xgcd(p, mod)
    if g != 1:
        return 0
    return _poly_divmod(k1, mod)[1]


def _bitrev32(x):
    """32 位比特反转。CRC 的输出循环冗余(binascii 反射)↔ 本环(非反射)互为 bit-reverse。"""
    return int(format(x & 0xFFFFFFFF, "032b")[::-1], 2)


def _ring_pow_x(k):
    """环内 x^k mod P(P=RU_POLY),二进制幂 O(log k)。变量列就是单个环幂。"""
    res, base, mod, e = 1, 2, RU_POLY, k
    while e:
        if e & 1:
            res = _mul(res, base, mod)
        base = _mul(base, base, mod)
        e >>= 1
    return res


def solve_crc_system(files):
    """解析解 CRC 定点系统

    - 常数列     :对“变量清零”的数据做一次 binascii.crc32,再 bit-reverse 32 位
                    (binascii 反射 CRC 与本环非反射 CRC 互为 bit-reverse,见 _bitrev32)。
    - 变量列     :单个环幂 x^(32 + 8*(len-pos-4)),二进制幂 O(log N) 一步算出;
                    同一变量出现在多处时按位异或累加。
    各自 O(#rows) 次 CRC + 少量环幂即可建出整矩阵,Gauss 消元不变。
    """
    n = len(files)
    mod = RU_POLY
    matrix = [[0] * (n + 1) for _ in range(n)]

    for file in range(n):
        data = files[file][0]
        offsets = files[file][1]
        N = len(data)
        zero = bytearray(data)
        for pos in offsets:                      # 未知量占 4 字节,清零后求常数项
            zero[pos:pos + 4] = b"\x00\x00\x00\x00"
        matrix[file][n] = _bitrev32(crc32(bytes(zero)))
        for pos, vid in offsets.items():
            rest = N - pos - 4                    # 变量被 4 字节(x^32)+ 其后字节(x^8/个)推到底
            matrix[file][vid] ^= _ring_pow_x(32 + 8 * rest)
        matrix[file][file] ^= 1

    for sr in range(n):
        ex = sr
        while ex < n and matrix[ex][sr] == 0:
            ex += 1
        if ex == n:
            raise RuntimeError("CRC 多项式系统奇异 @row%d" % sr)
        if ex != sr:
            matrix[sr], matrix[ex] = matrix[ex], matrix[sr]
        inv = _minv(matrix[sr][sr], mod)
        for er in range(sr + 1, n):
            mult = _mul(inv, matrix[er][sr], mod)
            for ec in range(n + 1):
                matrix[er][ec] ^= _mul(mult, matrix[sr][ec], mod)

    res = [0] * n
    for row in range(n - 1, -1, -1):
        v = matrix[row][n]
        for col in range(row + 1, n):
            v ^= _mul(res[col], matrix[row][col], mod)
        res[row] = _mul(v, _minv(matrix[row][row], mod), mod)

    return [_impl_val(res[i]) for i in range(n)]

def _impl_val(math_val):
    imp = 0
    for bit in range(32):
        if math_val & (1 << bit):
            imp |= 1 << (31 - bit)
    return imp

class BlogQuine:
    def solve_crc(self, F, lay, seed):
        T, h, d = lay["T"], lay["h"], lay["d"]
        k, n_sub, crc_base = lay["k"], lay["n_sub"], lay["crc_base"]
        hoff = lay["total"] - h
        vout_f = lay["total"] - 2 * T
        hcopy = vout_f + (T - h)
        # 已知 CRC:seed + 各文件(quine 的是未知量 D)
        known = [crc32(seed)] + [crc32(data) for _, data in self.files]
        assert len(known) == n_sub - 1
        for i, e in enumerate(known):
            struct.pack_into("<I", F, hoff + crc_base + 4 * i, e)
            struct.pack_into("<I", F, hcopy + crc_base + 4 * i, e)
        # 未知量 D N S:D=整个文件、N=头部 F[hoff:hoff+h]、S=签名 F[12:32]
        dpos = crc_base + 4 * (n_sub - 1)
        ncopy = 32 + 3 * k + d                            # C1k 载荷里 F[0:35) 副本起点
        groups = [[hoff + dpos, hcopy + dpos],            # D 出现两处
                  [28, ncopy + 28],                        # N
                  [8, ncopy + 8]]                          # S

        # 构造 3 个“文件”喂给解析解系统:
        #   文件0 = 整个 F,三个未知量各自出现在 groups 两处
        #   文件1 = 头部块,只有 D 出现在相对 dpos(另一处 hcopy+dpos 在头外)
        #   文件2 = 签名块,只有 N 出现在相对 16(28-12)
        whole = bytes(F)
        hdr = bytes(F[hoff:hoff + h])
        sig = bytes(F[12:32])
        f0_off = {hoff + dpos: 0, hcopy + dpos: 0,
                  28: 1, ncopy + 28: 1,
                  8: 2, ncopy + 8: 2}
        f1_off = {dpos: 0}
        f2_off = {28 - 12: 1}
        vals = solve_crc_system([(whole, f0_off), (hdr, f1_off), (sig, f2_off)])

        for g in range(3):
            for p in groups[g]:
                F[p:p + 4] = struct.pack("<I", vals[g])
        real = (crc32(F), crc32(F[hoff:hoff + h]), crc32(F[12:32]))
        assert real == tuple(vals), "CRC 定点失败: %s != %s" % (real, vals)
        return vals

于是我第一时间就把原来的TGZ压缩命令换掉,换成了AI给我写的blogquine.py,现在就可以通过这里下载到“完整”包含我博客所有内容的压缩包了。
不过唯一的问题就是这样做出来的压缩包并没有压缩😂,相当于给做成了普通的归档了。当然我的博客本身倒是不大,没压缩也多不了多少空间,但相比于能做出“完整”的效果来说,这点浪费的空间也是小问题了。

对TXZ格式的尝试

在做完7z格式的压缩包之后,我发现了一个问题,虽然7z确实很流行,但是在Linux下解压起来有点麻烦,7-Zip历史上主要面向Windows,Linux上长期以来更多依赖p7zip等第三方移植,因此生态集成度不如tar.xz,想要解压7z文件还得额外安装。
不过Linux下也有个支持LZMA2算法的压缩软件,那就是前些年出过后门的XZ Utils,配合tar就可以做出TXZ(tar.xz)文件,甚至用我博客终端中的BusyBox也能解压。我想了一下反正有AI,干脆一句话让AI帮我把blogquine.py改成tar.xz格式的好了,结果倒也没费多少功夫,AI就这样写出来了:

Show Code
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import argparse
import binascii
import io
import lzma
import os
import struct
import sys
import tarfile
import time
import ctypes
import ctypes.util

# ============================================================
# Part 1: 最小 LZMA1 range coder(只做编码)
# ============================================================

kNumBitModelTotalBits = 11
kBitModelTotal = 1 << kNumBitModelTotalBits          # 2048
kNumMoveBits = 5
kTopValue = 1 << 24                                   # 0x1000000

kNumStates = 12
kNumLitStates = 7
kNumPosBitsMax = 4
kNumLenToPosStates = 4
kNumAlignBits = 4
kEndPosModelIndex = 14
kNumFullDistances = 1 << (kEndPosModelIndex >> 1)     # 128
kMatchMinLen = 2
kNumLowLenBits = 3
kNumMidLenBits = 3
kNumHighLenBits = 8
kNumLowLenSymbols = 1 << kNumLowLenBits               # 8
kNumMidLenSymbols = 1 << kNumMidLenBits               # 8
kNumPosSlotBits = 6

PROB_INIT = kBitModelTotal >> 1                       # 1024


class RangeEncoder:
    """LZMA 的区间编码器。low 是 64 位(要容纳进位),range 是 32 位。"""

    def __init__(self):
        self.low = 0
        self.range = 0xFFFFFFFF
        self.cache = 0
        self.cache_size = 1          # 初值 1 -> 第一个输出字节恒为 0x00
        self.buf = bytearray()

    def _shift_low(self):
        if (self.low & 0xFFFFFFFF) < 0xFF000000 or (self.low >> 32) != 0:
            temp = self.cache
            while True:
                self.buf.append((temp + (self.low >> 32)) & 0xFF)
                temp = 0xFF
                self.cache_size -= 1
                if self.cache_size == 0:
                    break
            self.cache = (self.low >> 24) & 0xFF
        self.cache_size += 1
        self.low = ((self.low & 0xFFFFFFFF) << 8) & 0xFFFFFFFF

    def encode_bit(self, probs, idx, bit):
        p = probs[idx]
        bound = (self.range >> kNumBitModelTotalBits) * p
        if bit == 0:
            self.range = bound
            probs[idx] = p + ((kBitModelTotal - p) >> kNumMoveBits)
        else:
            self.low += bound
            self.range -= bound
            probs[idx] = p - (p >> kNumMoveBits)
        while self.range < kTopValue:
            self.range = (self.range << 8) & 0xFFFFFFFF
            self._shift_low()

    def encode_direct_bits(self, value, num_bits):
        for i in range(num_bits - 1, -1, -1):
            self.range >>= 1
            if (value >> i) & 1:
                self.low += self.range
            while self.range < kTopValue:
                self.range = (self.range << 8) & 0xFFFFFFFF
                self._shift_low()

    def bittree_encode(self, probs, off, num_bits, symbol):
        m = 1
        for i in range(num_bits - 1, -1, -1):
            bit = (symbol >> i) & 1
            self.encode_bit(probs, off + m, bit)
            m = (m << 1) | bit

    def bittree_reverse_encode(self, probs, off, num_bits, symbol):
        m = 1
        for i in range(num_bits):
            bit = symbol & 1
            symbol >>= 1
            self.encode_bit(probs, off + m, bit)
            m = (m << 1) | bit

    def finish(self):
        for _ in range(5):
            self._shift_low()
        return bytes(self.buf)


def _pos_slot_and_bits(d):
    if d < 4:
        return d, 0, 0
    for slot in range(4, 64):
        n = (slot >> 1) - 1
        base = (2 | (slot & 1)) << n
        if d < base + (1 << n):
            return slot, n, d - base
    raise ValueError("distance too large: %d" % d)


# pb 必须与写进每个 LZMA2 chunk 头的 PROPS_BYTE(0x5D) 一致。
# lc/lp 只影响 literal 编码的概率表索引,而本编码器不实现 literal 路径,故无需常量。
PB = 2


class LzmaEncoder:
    """只实现 quine 用到的两条路径:match 与 rep0-match。

    literal / rep-g1 / rep-g2 三条分支从未被构造,因此它们对应的概率表
    (p_lit、p_is_rep_g1、p_is_rep_g2)以及 prev_byte 都不必存在。
    reps 同理:编码器从不回读(rep0 的距离由解码器自行维护),故不保存。
    """

    def __init__(self):
        self.pos_mask = (1 << PB) - 1
        self.rc = RangeEncoder()
        self.pos = 0
        self.state = 0
        n = PROB_INIT
        self.p_is_match = [n] * (kNumStates << kNumPosBitsMax)
        self.p_is_rep = [n] * kNumStates
        self.p_is_rep_g0 = [n] * kNumStates
        self.p_rep0_long = [n] * (kNumStates << kNumPosBitsMax)
        self.p_pos_slot = [n] * (kNumLenToPosStates << kNumPosSlotBits)
        self.p_spec_pos = [n] * (kNumFullDistances - kEndPosModelIndex)
        self.p_align = [n] * (1 << kNumAlignBits)
        self.p_len_choice = [n] * 2
        self.p_len_low = [n] * (16 * kNumLowLenSymbols)
        self.p_len_mid = [n] * (16 * kNumMidLenSymbols)
        self.p_len_high = [n] * (1 << kNumHighLenBits)
        self.p_rep_len_choice = [n] * 2
        self.p_rep_len_low = [n] * (16 * kNumLowLenSymbols)
        self.p_rep_len_mid = [n] * (16 * kNumMidLenSymbols)
        self.p_rep_len_high = [n] * (1 << kNumHighLenBits)

    @property
    def pos_state(self):
        return self.pos & self.pos_mask

    def _encode_len(self, choice, low, mid, high, length):
        ps = self.pos_state
        l = length - kMatchMinLen
        if l < kNumLowLenSymbols:
            self.rc.encode_bit(choice, 0, 0)
            self.rc.bittree_encode(low, ps << kNumLowLenBits, kNumLowLenBits, l)
        else:
            self.rc.encode_bit(choice, 0, 1)
            l -= kNumLowLenSymbols
            if l < kNumMidLenSymbols:
                self.rc.encode_bit(choice, 1, 0)
                self.rc.bittree_encode(mid, ps << kNumMidLenBits, kNumMidLenBits, l)
            else:
                self.rc.encode_bit(choice, 1, 1)
                self.rc.bittree_encode(high, 0, kNumHighLenBits, l - kNumMidLenSymbols)

    def _write_dist(self, dist, lts):
        d = dist - 1
        slot, n, low_bits = _pos_slot_and_bits(d)
        self.rc.bittree_encode(self.p_pos_slot, lts << kNumPosSlotBits,
                               kNumPosSlotBits, slot)
        if slot >= 4:
            if slot < kEndPosModelIndex:
                base = (2 | (slot & 1)) << n
                off = base - slot - 1
                self.rc.bittree_reverse_encode(self.p_spec_pos, off, n, low_bits)
            else:
                self.rc.encode_direct_bits(low_bits >> kNumAlignBits, n - kNumAlignBits)
                self.rc.bittree_reverse_encode(self.p_align, 0, kNumAlignBits,
                                               low_bits & ((1 << kNumAlignBits) - 1))

    def match(self, dist, length):
        assert kMatchMinLen <= length <= 273, length
        ps = self.pos_state
        self.rc.encode_bit(self.p_is_match, (self.state << kNumPosBitsMax) + ps, 1)
        self.rc.encode_bit(self.p_is_rep, self.state, 0)
        lts = min(length - kMatchMinLen, kNumLenToPosStates - 1)
        self._encode_len(self.p_len_choice,
                         self.p_len_low, self.p_len_mid, self.p_len_high, length)
        self._write_dist(dist, lts)
        self.state = 7 if self.state < kNumLitStates else 10
        self.pos += length

    def rep_match(self, length):
        assert kMatchMinLen <= length <= 273, length
        ps = self.pos_state
        self.rc.encode_bit(self.p_is_match, (self.state << kNumPosBitsMax) + ps, 1)
        self.rc.encode_bit(self.p_is_rep, self.state, 1)
        self.rc.encode_bit(self.p_is_rep_g0, self.state, 0)
        self.rc.encode_bit(self.p_rep0_long, (self.state << kNumPosBitsMax) + ps, 1)
        self._encode_len(self.p_rep_len_choice,
                         self.p_rep_len_low, self.p_rep_len_mid, self.p_rep_len_high, length)
        self.state = 8 if self.state < kNumLitStates else 11
        self.pos += length

    def finish(self):
        return self.rc.finish()


# ============================================================
# Part 2: tar + xz 格式原语
# ============================================================

MAX_MATCH = 273                       # LZMA1 单个 match 长度上限
CHUNK = 65536                          # LZMA2 单 chunk 解压上限
PROPS_BYTE = 0x5D                      # lc=3, lp=0, pb=2
SEED_NOTE = ("\nMayx's Blog!").encode("utf-8")

# xz 格式常量
XZ_MAGIC = b"\xFD7zXZ\x00"
XZ_FOOTER_MAGIC = b"YZ"
XZ_CHECK_CRC64 = 0x04
LZMA2_FILTER_ID = 0x21
CHECK_SIZE = 8  # CRC64 占 8 字节
_XZ_FLAGS = bytes([0x00, XZ_CHECK_CRC64])   # Stream Flags: reserved + check_type

# tar 格式常量
TAR_BLOCK = 512


def round_up_512(n):
    """长度按 tar 块 512 字节向上取整。"""
    return (n + TAR_BLOCK - 1) // TAR_BLOCK * TAR_BLOCK


def crc32(b):
    return binascii.crc32(b) & 0xFFFFFFFF


# ---------- CRC64 (ECMA-182, reflected) ----------

_CRC64_POLY = 0xC96C5795D7870F42  # reflected ECMA-182
_CRC64_INIT = 0xFFFFFFFFFFFFFFFF
_CRC64_XOROUT = 0xFFFFFFFFFFFFFFFF
_CRC64_NON_REFLECTED = 0x142F0E1EBA9EA3693  # 非反射多项式 (65 bit)
_CRC64_MOD_BIT = 1 << 64  # x^64 对应的 bit


_liblzma = None
try:
    _lzma_name = ctypes.util.find_library('lzma')
    if _lzma_name:
        _liblzma = ctypes.CDLL(_lzma_name)
        _liblzma.lzma_crc64.argtypes = [ctypes.c_char_p, ctypes.c_size_t, ctypes.c_uint64]
        _liblzma.lzma_crc64.restype = ctypes.c_uint64
        # 验证:CRC64("123456789") 应为 0x995dc9bbdf1939fa
        if _liblzma.lzma_crc64(b'123456789', 9, 0) != 0x995dc9bbdf1939fa:
            _liblzma = None
except (OSError, AttributeError):
    _liblzma = None


def _make_crc64_table():
    table = []
    for i in range(256):
        crc = i
        for _ in range(8):
            crc = (crc >> 1) ^ _CRC64_POLY if (crc & 1) else crc >> 1
        table.append(crc)
    return table


_CRC64_TABLE = _make_crc64_table()


def _crc64_pure(data):
    """纯 Python CRC64(ECMA-182 反射,init/xorout = 0xFFFF...FFFF)。"""
    crc = _CRC64_INIT
    for byte in data:
        crc = (crc >> 8) ^ _CRC64_TABLE[(crc ^ byte) & 0xFF]
    return crc ^ _CRC64_XOROUT


def _crc64_lib(data):
    """liblzma CRC64(init=0,内部处理 init/xorout)。"""
    return _liblzma.lzma_crc64(data, len(data), 0)


crc64 = _crc64_lib if _liblzma else _crc64_pure


# ---------- GF(2^64) 多项式运算 ----------
# 用于 CRC64 自引用求解。CRC64 的线性贡献可表示为
# bit_reverse(contribution(D)) = D_poly * P (mod G_non)
# 其中 D_poly = bit_reverse(D),P 是位置决定的多项式。
# 方程 D_poly * (1 ^ P1 ^ P2) = bit_reverse(CRC64(W)) 通过多项式逆元求解。

def _bit_reverse64(v):
    r = 0
    for _ in range(64):
        r = (r << 1) | (v & 1)
        v >>= 1
    return r


def _poly_mul_mod(a, b):
    """GF(2) 多项式乘法 mod G(非反射)。"""
    result = 0
    while b:
        if b & 1:
            result ^= a
        b >>= 1
        a <<= 1
        if a & _CRC64_MOD_BIT:
            a ^= _CRC64_NON_REFLECTED
    return result


def _poly_pow(base, exp):
    """多项式幂:base^exp mod G。"""
    result = 1
    while exp:
        if exp & 1:
            result = _poly_mul_mod(result, base)
        base = _poly_mul_mod(base, base)
        exp >>= 1
    return result


def _poly_minv(p):
    """多项式逆元 p^(-1) mod G,用扩展欧几里得算法。"""
    if p == 0:
        return 0
    old_r, r = p, _CRC64_NON_REFLECTED
    old_s, s = 1, 0
    while r:
        # poly divmod(old_r, r) -> (q, rem)
        probe_r = r.bit_length() - 1
        rem = old_r
        q = 0
        while rem.bit_length() - 1 >= probe_r and rem:
            shift = rem.bit_length() - 1 - probe_r
            q |= 1 << shift
            rem ^= r << shift
        old_r, r = r, rem
        # s = old_s ^ q*s (raw 多项式乘法)
        prod = 0
        a, b = q, s
        while b:
            if b & 1:
                prod ^= a
            b >>= 1
            a <<= 1
        old_s, s = s, old_s ^ prod
    if old_r != 1:
        return 0
    # old_s mod G
    probe_m = _CRC64_NON_REFLECTED.bit_length() - 1
    rem = old_s
    while rem.bit_length() - 1 >= probe_m and rem:
        shift = rem.bit_length() - 1 - probe_m
        rem ^= _CRC64_NON_REFLECTED << shift
    return rem


def store_hdr(payload_len, first=False):
    """LZMA2 uncompressed chunk 头(3 字节,大端 size-1)。"""
    assert 1 <= payload_len <= 65536
    return bytes([0x01 if first else 0x02]) + (payload_len - 1).to_bytes(2, "big")


def matches_for(dist, total):
    """dist 固定、总长 total 的 token 序列:首个 match + 后续 rep0。"""
    assert total >= 2
    toks = []
    first = min(MAX_MATCH, total)
    if total - first == 1:
        first -= 1
    toks.append(("m", dist, first))
    total -= first
    while total > 0:
        l = min(MAX_MATCH, total)
        if total - l == 1:
            l -= 1
        toks.append(("r0", l))
        total -= l
    return toks


def _add_gear(chunks, o, f, q):
    """追加一组标准 gear:store 载荷 x = slip+3,紧跟一个 lzma 把那 x 字节复制一遍。

    lzma chunk 解压出的字节数正好等于 x,所以齿轮转完之后 slip (f - (o-q)) 精确
    归约为 len(cb)(很小的正数,x 被 CHUNK 截断时则按 CHUNK 缩减)。
    _layout_iterate 反复套用把 slip 压到 <= 16,_trim_layouts 用它做相位微调。
    返回追加后的 (o, f)。
    """
    x = min(f - (o - q) + 3, CHUNK)
    chunks.append({"kind": "store", "foff": f, "size": 3 + x,
                   "ooff": o, "olen": x})
    o += x
    f += 3 + x
    cb, cu = lzma_chunk(matches_for(x, x), o)
    chunks.append({"kind": "lzma", "foff": f, "size": len(cb),
                   "ooff": o, "olen": cu, "bytes": cb})
    return o + cu, f + len(cb)


def _forward_vac(F, vac, q):
    """vac_store:把紧随其后的等长字节块前移覆盖自身。"""
    fo = vac["ooff"] - q
    olen = vac["olen"]
    F[fo:fo + olen] = F[fo + olen:fo + 2 * olen]


def dict_prop_for(maxdist):
    """选最小的 LZMA2 dict prop 使字典 >= maxdist。"""
    for p in range(41):
        if (2 | (p & 1)) << (p // 2 + 11) >= maxdist:
            return p, (2 | (p & 1)) << (p // 2 + 11)
    raise ValueError("distance too large")


def lzma_chunk(tokens, out_pos):
    """编一个 LZMA chunk(0xC0: state+props reset,无 dict reset,无 end marker)。"""
    enc = LzmaEncoder()
    enc.pos = out_pos
    total = 0
    for t in tokens:
        if t[0] == "m":
            enc.match(t[1], t[2])
            total += t[2]
        else:
            enc.rep_match(t[1])
            total += t[1]
    data = enc.finish()
    assert total - 1 < 65536 and len(data) - 1 < 65536
    hdr = bytes([0xC0]) + (total - 1).to_bytes(2, "big") + \
          (len(data) - 1).to_bytes(2, "big") + bytes([PROPS_BYTE])
    return hdr + data, total


# ---------- tar 原语 ----------

def tar_header(name, size, mtime, mode=0o644, typeflag=b'0', uid=0, gid=0):
    """构建 512 字节 POSIX ustar tar header。
    注意:tar 数值字段包含 null 终止符,slice 范围必须精确匹配字段宽度。
    mode/uid/gid 各 8 字节(7 octal + NUL),size/mtime 各 12 字节(11 octal + NUL)。
    """
    h = bytearray(512)
    name_bytes = name.encode('utf-8')[:100]
    h[0:len(name_bytes)] = name_bytes
    h[100:108] = b'%07o\x00' % mode       # 8 bytes (field 100-107)
    h[108:116] = b'%07o\x00' % uid        # 8 bytes (field 108-115)
    h[116:124] = b'%07o\x00' % gid        # 8 bytes (field 116-123)
    h[124:136] = b'%011o\x00' % size      # 12 bytes (field 124-135)
    h[136:148] = b'%011o\x00' % mtime     # 12 bytes (field 136-147)
    h[148:156] = b'        '              # checksum placeholder (8 spaces)
    h[156:157] = typeflag
    h[257:263] = b'ustar\x00'
    h[263:265] = b'00'
    assert len(h) == 512, "header length %d != 512" % len(h)
    # 计算校验和:所有字节之和,checksum 字段视为空格
    chksum = sum(h) & 0o7777777
    h[148:156] = b'%06o\x00 ' % chksum    # 6 octal + NUL + space = 8 bytes
    assert len(h) == 512, "header length %d != 512 after checksum" % len(h)
    return bytes(h)


# ---------- xz 原语 ----------

def xz_varint(v):
    """xz 变长整数编码:每字节 7 位数据(LSB first),MSB 为续位。"""
    b = []
    while v >= 0x80:
        b.append((v & 0x7F) | 0x80)
        v >>= 7
    b.append(v & 0x7F)
    return bytes(b)


def xz_stream_header():
    """12 字节 xz stream header (CHECK_CRC64)。
    Stream Flags = [reserved(0x00), check_type(CRC64=0x04)]。
    """
    flags = _XZ_FLAGS
    crc = crc32(flags)
    return XZ_MAGIC + flags + struct.pack('<I', crc)


def xz_block_header(dict_size):
    """xz block header(单 LZMA2 filter)。
    返回 (header_bytes, 实际字典大小);header 长度即 block header 总长度。
    """
    prop, real_dict = dict_prop_for(dict_size)
    # filter flags: filter_id(1) + props_size(1) + props(1=prop byte)
    filter_flags = bytes([LZMA2_FILTER_ID, 1, prop])
    # block header content: flags(1, 0x00=1 filter) + filter_flags
    content = bytes([0x00]) + filter_flags
    # 填充到 (1 + len(content) + 4) 是 4 的倍数
    total = 1 + len(content) + 4
    pad = (4 - total % 4) % 4
    content += b'\x00' * pad
    bh = 1 + len(content) + 4
    assert bh % 4 == 0, "bh not 4-aligned: %d" % bh
    crc = crc32(bytes([bh // 4 - 1]) + content)
    header = bytes([bh // 4 - 1]) + content + struct.pack('<I', crc)
    assert len(header) == bh
    return header, real_dict


def xz_index(records):
    """xz index。records = [(unpadded_size, uncompressed_size), ...]。"""
    data = bytes([0x00])  # index indicator
    data += xz_varint(len(records))
    for unpadded, uncompressed in records:
        data += xz_varint(unpadded)
        data += xz_varint(uncompressed)
    # 填充到 (len(data) + 4) 是 4 的倍数
    pad = (4 - (len(data) + 4) % 4) % 4
    data += b'\x00' * pad
    return data + struct.pack('<I', crc32(data))


def xz_stream_footer(backward_size):
    """12 字节 xz stream footer (CHECK_CRC64)。
    backward_size = (index_size / 4) - 1。
    Stream Flags = [reserved(0x00), check_type(CRC64=0x04)]。
    CRC32 覆盖 Backward Size + Stream Flags(6 字节,不含 CRC32 和 Footer Magic)。
    """
    flags = _XZ_FLAGS
    crc_data = struct.pack('<I', backward_size) + flags  # 6 bytes, no magic
    crc = crc32(crc_data)
    return struct.pack('<I', crc) + crc_data + XZ_FOOTER_MAGIC


# ============================================================
# Part 3: 多文件 tar.xz quine 构造器
# ============================================================

class TarXzQuine:
    def __init__(self, root, quine_name="quine.tar.xz", seed_name=".this_is_mayx_blog",
                 src_dir=""):
        self.quine_name = quine_name
        self.seed_name = seed_name
        self.src_dir = src_dir.strip("/")
        self.mtime = int(time.time())
        # walk 源目录
        self.walk_entries = []
        self.files = []
        if self.src_dir:
            parts = self.src_dir.split("/")
            for i in range(1, len(parts) + 1):
                self.walk_entries.append(("/".join(parts[:i]), True))
        for dirpath, dirnames, filenames in os.walk(root):
            dirnames.sort()
            for dn in dirnames:
                rel = os.path.relpath(os.path.join(dirpath, dn), root).replace(os.sep, "/")
                self.walk_entries.append((self._prefixed(rel), True))
            for fn in sorted(filenames):
                p = os.path.join(dirpath, fn)
                rel = os.path.relpath(p, root).replace(os.sep, "/")
                with open(p, "rb") as fp:
                    data = fp.read()
                self.walk_entries.append((self._prefixed(rel), False))
                self.files.append((self._prefixed(rel), data))
        # rel -> data 映射,避免下面三处按 rel 回头线性搜索整个 files 列表
        self.file_map = dict(self.files)
        self.dirs = [r for r, isd in self.walk_entries if isd]
        self.content = b"".join(data for _, data in self.files)

    def _prefixed(self, rel):
        return self.src_dir + "/" + rel if self.src_dir else rel

    def tar_entries(self, d_seed, xz_size):
        """计算 tar 归档中每个条目的 (header, data_size, is_dir)。
        顺序:seed, walk_entries (interleaved files+dirs), quine。
        """
        entries = []
        # seed 文件
        seed_hdr = tar_header(self.seed_name, d_seed, self.mtime)
        entries.append({"name": self.seed_name, "hdr": seed_hdr,
                        "data_size": d_seed, "is_dir": False})
        # walk 顺序的文件和目录
        for rel, isd in self.walk_entries:
            if isd:
                hdr = tar_header(rel, 0, self.mtime, mode=0o755, typeflag=b'5')
                entries.append({"name": rel, "hdr": hdr,
                                "data_size": 0, "is_dir": True})
            else:
                # file_map 与 walk_entries 在 __init__ 里同步构造,这里必有
                data = self.file_map[rel]
                entries.append({"name": rel, "hdr": tar_header(rel, len(data), self.mtime),
                                "data_size": len(data), "is_dir": False})
        # quine 文件(放在最后)
        qhdr = tar_header(self.quine_name, xz_size, self.mtime)
        entries.append({"name": self.quine_name, "hdr": qhdr,
                        "data_size": xz_size, "is_dir": False})
        return entries

    # ---------- 结构计算 ----------
    def _compute_q(self, d_seed):
        """计算 xz_bytes 在 tar 中的偏移 q。
        q = seed + files + dirs 的 tar 占用 + quine tar header。
        q 不依赖 xz_size(quine 的 data 在 q 之后)。"""
        q = 0
        # seed 文件
        q += TAR_BLOCK  # seed tar header
        q += round_up_512(d_seed)      # seed data + pad
        # walk 顺序的文件和目录
        for rel, isd in self.walk_entries:
            q += TAR_BLOCK  # tar header
            if not isd:
                q += round_up_512(len(self.file_map[rel]))
        # quine tar header
        q += TAR_BLOCK
        return q

    def _trim_layouts(self, base, o_g, f_g, q, w_plant_off, d_seed):
        """枚举 trim 配置,产出一批「相位」不同的候选布局。

        trim = [可选的裸 store chunk,载荷长度 x0] + 一组标准 gear
               (store 载荷 x = s+3,紧接着一个 lzma 复制这 x 字节)。

        裸 store 把 slip 抬高 3、把 f 抬高 3+x0;随后这组 gear 把 slip 重新归一到
        len(cb),jump 再归一到 -3。净效果是 (o_pre, f_pre) 被整体平移
        δ ≈ s_g + 6 + len(cb) + 3 + x0,而 δ 决定了自洽方程的模 4 相位。
        旧实现 δ 恒为 0,相位一旦不对就永远无解 —— 而 d_seed += 1 根本不改变
        布局(q 只在跨 512 字节块时才变),所以那 80 次重试是纯粹的无效空转。

        注意:不能只插一个裸的 lzma 复制块。lzma 复制块的输出必须等于
        F[o-q : o-q+olen],而这只能靠前面那个「载荷 = F[o-q : o-q+x] 且 x = s+3」
        的 store chunk 把字节搬到正确位置来实现;裸插复制块解压出来和 F 对不上。

        返回 [(chunks, o_before_jump, f_before_jump, y, jb), ...],按文件体积升序。
        """
        s_g = f_g - (o_g - q)
        out = []
        seen = set()
        for x0 in range(0, s_g + 4):
            chunks = list(base)
            o, f = o_g, f_g
            if x0:
                # 裸 store:载荷 = F[o-q : o-q+x0],要求 x0 <= s+3 以免读到尚未定稿的字节
                chunks.append({"kind": "store", "foff": f, "size": 3 + x0,
                               "ooff": o, "olen": x0})
                o += x0
                f += 3 + x0
            o, f = _add_gear(chunks, o, f, q)   # 一组标准 gear
            # jump 不动点:y = s + 3 + len(jb)
            s = f - (o - q)
            y = max(2, s + 17)
            jb = ju = None
            for _ in range(64):
                try:
                    jb, ju = lzma_chunk([("m", o - w_plant_off, y)], o)
                except AssertionError:
                    jb = None
                    break
                y2 = s + 3 + len(jb)
                if y2 == y:
                    break
                if y2 < 2 or y2 > 273:
                    jb = None
                    break
                y = y2
            if jb is None or ju != y:
                continue
            if y + w_plant_off - TAR_BLOCK > d_seed:
                continue
            key = (o + y, f + len(jb))
            if key in seen:
                continue
            seen.add(key)
            out.append((chunks, o, f, y, jb))

        out.sort(key=lambda t: t[2] + len(t[4]))
        return out

    def _solve_gadget(self, chunks_pre, o_j, f_j, y, jb, q, bh, FEW,
                      block_hdr, dict_size, hdrA, hdrB, d_seed, k):
        """在一个给定相位的布局上求解 gadget(T / gb / suffix / tz / k)。

        关键简化:tz 只依赖 u = gb + suffix 与 k;T = u + tz + 4 也只依赖 (u, k);
        而 gb 又由 T 唯一确定(len(lzma_chunk(matches_for(T, T))))。
        因此只需一维枚举 u 即可遍历全部解,既不漏解也远快于原来的三重暴力。
        """
        o_pre = o_j + y
        f_pre = f_j + len(jb)
        if f_pre != (o_pre - q) - 3:      # jump 应把 slip 归一到 -3
            return None

        chunks = list(chunks_pre)
        chunks.append({"kind": "lzma", "foff": f_j, "size": len(jb),
                       "ooff": o_j, "olen": y, "bytes": jb, "jump": True})

        best = None
        for u in range(8, 512):
            # 512k 必须落在 [f_pre + 2u + 2059, f_pre + 2u + 3595)
            lo = f_pre + 2 * u + 2059
            hi = f_pre + 2 * u + 3595
            for k_cand in range(max(1, (lo - 512) // 512), (hi + 512) // 512 + 2):
                num = k_cand * 512 - (o_pre + 2 * u + 8 - q - 1024)
                if num % 3 != 0:
                    continue
                tz_cand = num // 3
                if tz_cand < 1024:
                    continue
                T = u + tz_cand + 4
                if T < 2:
                    continue
                gb, gu = lzma_chunk(matches_for(T, T), o_pre + T)
                gb_len = len(gb)
                suffix = u - gb_len
                if suffix < 1:
                    continue

                # 精确自检(不再用带 9 字节偏差的预筛公式)
                f_final = f_pre + 3 + T + gb_len + 3 + tz_cand + 1
                n = f_final - (12 + bh)
                bp = (4 - (12 + bh + n) % 4) % 4
                unpadded = bh + n + CHECK_SIZE
                idx = xz_index([(unpadded, q + k_cand * 512 + 1024)])
                ix = len(idx)
                suffix_actual = bp + CHECK_SIZE + ix + 12
                if suffix_actual != suffix:
                    continue
                xz_size = 12 + bh + n + bp + CHECK_SIZE + ix + 12
                xz_pad_actual = round_up_512(xz_size)
                if xz_pad_actual != k_cand * 512:
                    continue
                total_w_actual = q + xz_pad_actual + 1024
                o_final = o_pre + 2 * T + tz_cand
                if o_final != total_w_actual:
                    continue

                if best is not None and xz_size >= best["xz_size"]:
                    continue          # 已找到更小的解,跳过

                backward_size = ix // 4 - 1
                final = list(chunks)
                final.append({"kind": "store", "foff": f_pre, "size": 3 + T,
                              "ooff": o_pre, "olen": T, "vac": True})
                final.append({"kind": "lzma", "foff": f_pre + 3 + T,
                              "size": gb_len, "ooff": o_pre + T, "olen": gu,
                              "bytes": gb})
                final.append({"kind": "store",
                              "foff": f_pre + 3 + T + gb_len,
                              "size": 3 + tz_cand, "ooff": o_pre + 2 * T,
                              "olen": tz_cand, "trailing": True})
                final.append({"kind": "term",
                              "foff": f_pre + 3 + T + gb_len + 3 + tz_cand,
                              "size": 1, "ooff": o_final, "olen": 0,
                              "bytes": b"\x00"})
                best = {
                    "q": q, "d_seed": d_seed, "k": k,
                    "chunks": final, "block_hdr": block_hdr, "bh": bh,
                    "n": n, "xz_size": xz_size,
                    "T": T, "suffix": suffix_actual,
                    "bp": bp, "ix": ix,
                    "dict_size": dict_size,
                    "FEW": FEW, "hdrA": hdrA,
                    "hdrB": hdrB, "jump_y": y, "index": idx,
                    "stream_hdr": xz_stream_header(),
                    "footer": xz_stream_footer(backward_size),
                    "entries": self.tar_entries(d_seed, xz_size),
                }
        return best

    def _layout_iterate(self, d_seed, maxdist_hint):
        """核心布局。

        阶段 1a:C1 / repro / gears(只依赖 q,所有 trim 配置共用,只算一次)
        阶段 1b:插入 trim 微调块,平移 (o_pre, f_pre) 改变模 4 相位
        阶段 2  :按 u = gb + suffix 一维搜索自洽解
        """
        block_hdr, dict_size = xz_block_header(maxdist_hint)
        bh = len(block_hdr)
        FEW = 12 + bh + 3
        w_hdrA_off = TAR_BLOCK + 12 + bh
        w_hdrB_off = TAR_BLOCK + 12 + bh + 3
        w_plant_off = TAR_BLOCK + 12 + bh + 6

        q = self._compute_q(d_seed)

        # ---------- 阶段 1a:C1 + repro + gears ----------
        base = []
        o, f = 0, 12 + bh
        Ls = []
        rem = q + FEW
        while rem > CHUNK:
            Ls.append(CHUNK)
            rem -= CHUNK
        Ls.append(rem)
        assert Ls[-1] >= 2, "末 C1 chunk 载荷 %d 太小" % Ls[-1]
        k = len(Ls)
        hdrA = store_hdr(CHUNK, first=False)
        hdrB = store_hdr(Ls[-1], first=False)

        S = 0
        for j, L in enumerate(Ls):
            base.append({"kind": "store", "foff": f, "size": 3 + L,
                         "ooff": o, "olen": L, "first": j == 0,
                         "c1": True, "S": S})
            o += L
            f += 3 + L
            S += L
        assert o == q + FEW

        S = 0
        for j, L in enumerate(Ls):
            if j > 0:
                srcpos = w_hdrA_off if (j < k - 1 or L == CHUNK) else w_hdrB_off
                dist_h = (q + FEW + S + 3 * (j - 1)) - srcpos
                hb, hu = lzma_chunk([("m", dist_h, 3)], o)
                base.append({"kind": "lzma", "foff": f, "size": len(hb),
                             "ooff": o, "olen": hu, "bytes": hb})
                o += hu
                f += len(hb)
            cb, cu = lzma_chunk(matches_for(q + FEW + 3 * j, L), o)
            base.append({"kind": "lzma", "foff": f, "size": len(cb),
                         "ooff": o, "olen": cu, "bytes": cb})
            o += cu
            f += len(cb)
            S += L

        # gears:把 slip 压到 <= 16
        while f - (o - q) > 16:
            o, f = _add_gear(base, o, f, q)

        o_g, f_g = o, f

        # ---------- 阶段 1b + 阶段 2 ----------
        # 相位按文件体积升序枚举;第一个成功的相位通常已经足够好,但不同相位的
        # (u, tz) 不同,xz_size 仍可能相差几百字节,所以再多看几个取最小的。
        tried = 0
        best = None
        since_hit = 0
        for chunks_pre, o_j, f_j, y, jb in self._trim_layouts(
                base, o_g, f_g, q, w_plant_off, d_seed):
            lay = self._solve_gadget(chunks_pre, o_j, f_j, y, jb, q, bh, FEW,
                                     block_hdr, dict_size, hdrA, hdrB,
                                     d_seed, k)
            tried += 1
            if lay is not None and (best is None
                                    or lay["xz_size"] < best["xz_size"]):
                best = lay
            if best is not None:
                since_hit += 1
                if since_hit >= 10:
                    break
            elif tried >= 80:        # 一直无解,放弃后续相位
                break
        if best is not None:
            return best

        raise RuntimeError("layout 不收敛: 无自洽解 (q=%d, f_pre=%d, o_pre=%d)"
                           % (q, f_g, o_g))

    # ---------- 装配 ----------
    def assemble(self, lay):
        """构建 xz 文件 F 和 seed。"""
        F = bytearray(lay["xz_size"])
        q = lay["q"]
        d_seed = lay["d_seed"]
        bh = lay["bh"]
        FEW = lay["FEW"]

        # bytearray 已全零初始化:block padding、CRC64 占位、trailing-zero 载荷
        # 都不必再显式写 0(后面几处赋值只会写到各自独立的区间,不会交叉覆盖)。

        # pass A: 写 xz stream header + block header + 所有 chunk bytes + suffix
        F[0:12] = lay["stream_hdr"]
        F[12:12+bh] = lay["block_hdr"]

        jump_c = None
        for c in lay["chunks"]:
            if c["kind"] == "store":
                F[c["foff"]:c["foff"] + 3] = store_hdr(c["olen"],
                                                       first=c.get("first", False))
            else:
                F[c["foff"]:c["foff"] + c["size"]] = c["bytes"]
                if c.get("jump"):
                    jump_c = c

        # block padding + CRC64(pass D 填) + index + footer
        bp_off = 12 + bh + lay["n"]
        ix_off = bp_off + lay["bp"] + CHECK_SIZE
        F[ix_off:ix_off + lay["ix"]] = lay["index"]
        sf_off = ix_off + lay["ix"]
        F[sf_off:sf_off + 12] = lay["footer"]

        assert jump_c is not None

        # pass B: 构建 seed
        y = lay["jump_y"]
        plant = bytes(F[jump_c["ooff"] - q: jump_c["ooff"] - q + y])
        seed = bytearray(d_seed)
        seed[0:12] = lay["stream_hdr"]
        seed[12:12+bh] = lay["block_hdr"]
        seed[12+bh:12+bh+3] = lay["hdrA"]
        seed[12+bh+3:12+bh+6] = lay["hdrB"]
        seed[12+bh+6:12+bh+6+y] = plant
        pad = (SEED_NOTE * (d_seed // len(SEED_NOTE) + 2))[:d_seed - 12 - bh - 6 - y]
        seed[12+bh+6+y:] = pad

        # 构建 tar 前缀 T(含 seed data)
        T = self._build_tar_prefix_with_seed(lay, seed)
        F_prefix = bytes(F[0:FEW])
        P = T + F_prefix
        assert len(P) == q + FEW, "P 长度 %d != q+FEW %d" % (len(P), q + FEW)

        # pass C: 填充 store payloads
        for c in lay["chunks"]:
            if c["kind"] != "store":
                continue
            ooff, olen = c["ooff"], c["olen"]
            if c.get("c1"):
                F[c["foff"] + 3:c["foff"] + 3 + olen] = P[c["S"]:c["S"] + olen]
            elif c.get("trailing"):
                # 载荷是尾部零:F 全零初始化,且唯一会写到 chunk 之外的 vac_store
                # 目标是 [f_pre+3, f_pre+3+T)(f_pre == (o_pre-q)-3 已由 _solve_gadget
                # 断言),与本区块不重叠,因此无需写入。
                continue
            elif c.get("vac"):
                _forward_vac(F, c, q)
            else:
                # 其余 store 的载荷 = 输出流自身对应位置的字节(自引用复制)
                fo = ooff - q
                F[c["foff"] + 3:c["foff"] + 3 + olen] = F[fo:fo + olen]

        # pass D: 求解 CRC64 自引用
        self._solve_crc64(F, lay, T)

        return bytes(F), bytes(seed)

    def _build_tar_prefix_with_seed(self, lay, seed):
        """构建 tar 前缀 T(含 seed data)。"""
        entries = lay["entries"]
        T = bytearray()
        for e in entries[:-1]:
            T += e["hdr"]
            if not e["is_dir"] and e["data_size"] > 0:
                data = seed if e["name"] == self.seed_name else self.file_map[e["name"]]
                T += data
                T += b'\x00' * (round_up_512(e["data_size"]) - e["data_size"])
        T += entries[-1]["hdr"]  # quine tar header
        return bytes(T)

    def _solve_crc64(self, F, lay, T_prefix):
        """求解 CRC64 自引用。

        CRC64 值 D = CRC64(W),W = T + F + 尾部零。
        D 在 W 中出现两次:F 的 CRC64 字段 + vac_store 副本。

        解法(多项式逆元):
          CRC64 的线性贡献可表示为 GF(2^64) 多项式乘法:
            bit_reverse(contribution(D)) = D_poly * P (mod G)
          其中 D_poly = bit_reverse(D),P = x^(64+trailing) mod G。
          方程 D_poly * (1 ^ P1 ^ P2) = bit_reverse(CRC64(W)) (mod G)
          用扩展欧几里得求 (1 ^ P1 ^ P2) 的逆元,一步求解。
        """
        q = lay["q"]
        bh = lay["bh"]
        n = lay["n"]
        bp = lay["bp"]
        xz_size = lay["xz_size"]
        T = lay["T"]

        # CRC64 位置 1:在 F 中(block padding 后)
        crc_f_off = 12 + bh + n + bp
        crc_w1 = q + crc_f_off

        # CRC64 位置 2:vac_store 副本
        crc_f_copy = crc_f_off - T
        crc_w2 = q + crc_f_copy

        # 构造 W = T + F + 尾部零(先把两处 CRC64 置 0)
        xz_pad = round_up_512(xz_size)
        tz = xz_pad - xz_size + 1024
        W = bytearray(T_prefix) + F + b'\x00' * tz
        W[crc_w1:crc_w1 + CHECK_SIZE] = b'\x00' * CHECK_SIZE
        W[crc_w2:crc_w2 + CHECK_SIZE] = b'\x00' * CHECK_SIZE

        # CRC64(W) 的多项式表示
        crc_poly = _bit_reverse64(crc64(bytes(W)))

        # P1 = x^(64 + 位置1之后的bit数) mod G
        trailing1 = (len(W) - crc_w1 - 8) * 8
        P1 = _poly_pow(2, 64 + trailing1)
        # P2 = x^(64 + 位置2之后的bit数) mod G
        trailing2 = (len(W) - crc_w2 - 8) * 8
        P2 = _poly_pow(2, 64 + trailing2)

        # D_poly = crc_poly * inv(1 ^ P1 ^ P2)
        coeff = 1 ^ P1 ^ P2
        inv = _poly_minv(coeff)
        if inv == 0:
            raise RuntimeError("CRC64 多项式无逆元")
        D_poly = _poly_mul_mod(crc_poly, inv)
        D = _bit_reverse64(D_poly)

        # 写入 D 到 F 的 CRC64 位置
        struct.pack_into('<Q', F, crc_f_off, D)

        # 重新执行 vac_store 副本
        _forward_vac(F, next(c for c in lay["chunks"] if c.get("vac")), q)

        # 验证
        W2 = bytearray(T_prefix) + F + b'\x00' * tz
        assert crc64(bytes(W2)) == D, "CRC64 求解验证失败"

    def build(self, maxdist_hint=None):
        if maxdist_hint is None:
            maxdist_hint = 2 * (len(self.content) + 128) + (1 << 21)
        last_err = None
        # d_seed 在同一 512 字节块内不改变 q(seed 数据按 512 对齐),所以旧的
        # "d_seed += 1" 重试完全不会改变布局,只是空转 80 次。真正需要 d_seed
        # 变化的情况只有两种:seed 装不下 jump 种植串,或需要跨块改变 q 作为兜底。
        for d_seed in (64, 96, 128, 160, 192, 256, 320, 384, 448,
                       576, 704, 832, 960, 1088, 1600, 2112, 2624, 3648):
            try:
                lay = self._layout_iterate(d_seed, maxdist_hint)
                F, seed = self.assemble(lay)
                return F, lay, seed
            except (RuntimeError, AssertionError, ValueError) as e:
                last_err = e
        raise RuntimeError("build 多次失败: %s" % last_err)


# ============================================================
# Part 4: 验证 + main
# ============================================================

def verify(F, lay, files, quine_name):
    """验证:解压 xz → 解析 tar → 检查文件 + quine 自复制。"""
    # 1. 解压 xz
    try:
        dec = lzma.LZMADecompressor(format=lzma.FORMAT_XZ)
        tar_data = dec.decompress(F)
    except Exception as e:
        print("[verify] xz 解压失败: %s" % e)
        return False

    # 2. 检查 quine 自复制
    if tar_data[lay["q"]:lay["q"] + lay["xz_size"]] != F:
        print("[verify] quine 自复制不匹配")
        a = tar_data[lay["q"]:lay["q"] + lay["xz_size"]]
        for i in range(min(len(a), len(F))):
            if a[i] != F[i]:
                print("[verify]   首个不匹配在偏移 %d: tar=%02x vs xz=%02x" % (i, a[i], F[i]))
                break
        return False

    # 3. 解析 tar
    try:
        tf = tarfile.open(fileobj=io.BytesIO(tar_data))
        members = tf.getmembers()
    except Exception as e:
        print("[verify] tar 解析失败: %s" % e)
        return False

    print("[verify] tar 条目: %s" % [m.name for m in members])

    # 4. 检查源文件
    ok = True
    for rel, data in files:
        try:
            f = tf.extractfile(rel)
            if f is None:
                print("[verify] 文件 %s 是目录或不存在" % rel)
                ok = False
                continue
            extracted = f.read()
            if extracted != data:
                print("[verify] 文件 %s 内容不匹配" % rel)
                ok = False
        except KeyError:
            print("[verify] 文件 %s 不在 tar 中" % rel)
            ok = False

    # 5. 检查 quine 文件
    try:
        qf = tf.extractfile(quine_name)
        if qf:
            qdata = qf.read()
            if qdata != F:
                print("[verify] quine 文件内容不匹配 (%d vs %d)" % (len(qdata), len(F)))
                ok = False
            else:
                print("[verify] quine 文件匹配!")
    except KeyError:
        print("[verify] quine 文件不在 tar 中")
        ok = False

    print("[verify] 结果: %s" % ("通过" if ok else "失败"))
    return ok


def main():
    ap = argparse.ArgumentParser(description="多文件 tar.xz quine 生成器")
    ap.add_argument("srcdir")
    ap.add_argument("output")
    ap.add_argument("--quine-name", default=None,
                    help="归档内 quine 文件名(默认 = output 的文件名)")
    ap.add_argument("--seed-name", default=".this_is_mayx_blog")
    ap.add_argument("--src-dir", default="",
                    help="源文件在归档内的前缀目录")
    ap.add_argument("--quine-dir", default="",
                    help="quine 在归档内所在目录")
    args = ap.parse_args()

    quine_name = args.quine_name or os.path.basename(args.output)
    if args.quine_dir:
        quine_dir = args.quine_dir.strip("/")
        quine_name = quine_dir + "/" + quine_name

    bq = TarXzQuine(args.srcdir, quine_name=quine_name, seed_name=args.seed_name,
                    src_dir=args.src_dir)
    print("[blogquine-tarxz] %d 个文件, %d 个目录, 内容 %d 字节"
          % (len(bq.files), len(bq.dirs), len(bq.content)))
    F, lay, seed = bq.build()
    with open(args.output, "wb") as fp:
        fp.write(F)
    print("[blogquine-tarxz] written %s: %d 字节 (q=%d, k=%d chunks, n=%d, xz_size=%d)"
          % (args.output, len(F), lay["q"], lay["k"], lay["n"], lay["xz_size"]))
    print("[blogquine-tarxz] T=%d, suffix=%d (bp=%d, ix=%d), dict_size=%d"
          % (lay["T"], lay["suffix"], lay["bp"], lay["ix"], lay["dict_size"]))
    ok = verify(F, lay, bq.files, quine_name)
    sys.exit(0 if ok else 1)


if __name__ == "__main__":
    main()

不过对我来说,相比于TXZ格式我还是更喜欢7z一点,一是因为LZMA2本来就是7-Zip的作者发明的,XZ感觉像是摘桃子的,二是TXZ这个名字听起来有点怪,感觉不像压缩包,三是XZ Utils出过后门,尽管整个Linux社区都在使用XZ,但是我还是稍微有点偏见,所以这份TXZ的生成器我就粘贴出来给需要的人吧,我博客用7z格式就好了。

感想

以前总是有人说AI没有创新能力,只是对曾经在网络上出现的东西进行重组,但人何尝不是这样呢?像这次制作的7z/TXZ Quine生成器在整个网络上没有任何公开信息,当然我也知道这并不是理论上的创新,但谁说组合创新不是创新呢?再看看最近OpenAI又解决了一大堆数学难题,完全可以相信AI是真的拥有智能,所以我相信总有一天AI将能完成人类能做的所有事情,人类将不再需要额外的思考,只需要做自己想做的事情吧。

Vibe Coding 新体验(2)

2026-09-01 00:00:00

AI is Everything!

起因

在两个月前,我体验了现在的Vibe Coding,不过那时候我其实还是没有完全信任AI,认为它的能力可能还不够强,大多数内容依然需要人来做。
但是自从上次做了终端版博客之后,我意识到,它的能力也许比我想象的更强,可能真的能做到超越99%的开发者了,因此在最近一段时间,我一直使用AI做我想做的各种东西,结果就是一次又一次地让我感到难以置信,现在的AI真的实在是太强了😨。而且我做的这一切并不是用最先进的那批AI,仅仅是国内被大家不怎么看好的免费模型——腾讯混元Hy3,那么我很难想象,如果我能用像GPT 5.6 Sol或者Fable 5那样当下最先进的AI,我做的这些东西得有多完美啊……

最近Vibe Coding出来的各种项目

Blog API + WebMCP

在上次做完终端版博客之后,我就在想,用Linux终端来呈现博客的形式真的是个好选择吗?毕竟Linux在浏览器上并不是原生的,尽管用了WASM Linux可以做到WASM原生运行Linux,但这还是额外添加的东西。于是我就想,JavaScript应该也能作为终端吧?就像Node.js一样,REPL也可以作为终端使用,正好那段时间DeepSeek-V4-Flash正式版出了,就让它直接写了一个Demo出来试试看,可以点开这里体验。
写出来效果还挺不错的,和之前做的终端版博客的主要区别就是我写的是函数,而不是命令,不管怎么说也算是“真的”终端吧,唯一体验不太好的可能就是函数需要带个括号才能运行,稍微有点麻烦。
但后来我想了想,明明开发者工具中就有个控制台,根本没必要搞什么REPL终端啊,直接把要用的功能提出来作为函数,在控制台中运行效果比那个终端的效果要好得多啊,而且这样就可以直接集成到我的博客中,不需要单独搞个页面,感觉体验更好一些。
既然有了函数,我觉得只给人类用就太浪费了,前段时间看到谷歌搞了个WebMCP,那我完全可以把这些函数做成WebMCP让AI来调用啊,相比于其他乱七八糟给AI的协议,至少WebMCP是有谷歌和微软撑腰的,所以加一个也不错。
最终我把WebMCP的文档以及之前实现的REPL代码直接发给AI,让它帮我实现对应的函数以及应用WebMCP API,没几分钟它就把所有功能实现好了,最终代码就是:blog-console.js。在开发的过程中,AI严格按照文档中的要求实现了,结果我在实际测试的时候,发现Chrome在实现registerTool方法的时候和文档的实现有差异,文档说这个方法会返回Promise,但Chrome实际并没有返回,所以AI调用.then()时直接报错了😓……没办法就只能手动给它套一层Promise,才算是能正常工作。毕竟这还是一个没有正式上线的API,出点问题倒也能理解吧。
虽然东西已经实现了,不过到底怎么才能让其他人知道我用了WebMCP的技术呢?我发现网上有一个WebMCP Directory专门收录使用了WebMCP技术的网站,于是我就随手提交了一下,现在在这里就能看到我博客的WebMCP信息了。

某个即将关服的游戏的离线快照

最近一段时间我在玩的一个使用Unity3D引擎制作的立直麻将游戏发了篇公告,说是要关服了,还给全服所有玩家送了一堆抽卡资源。我听到这个消息之后感觉还是挺遗憾的,那个游戏总体来说做得还算不错吧,而且画风也符合我的口味,角色模型有点米哈游的那种感觉。我曾经遇到过几次游戏关服的事件,但是每一次我都无能为力,只能看着游戏消失然后逐渐被遗忘。然而现在我有了AI,也许一切会变得不一样吧?这次我要使用AI,让即将消失的游戏不再消失。

重放大厅数据

最开始我其实想得很简单,游戏登录之后其实没有和服务器有太多的通信,像账号中有的角色以及各种材料等信息在登录的时候就已经发送了,直到进入对局之前客户端都没有和服务器有什么有效的沟通,而我主要需要的也就是能登进去看看角色,听听语音就好了。那么接下来我只需要让AI把整个游戏资源下载下来,然后抓个包,让它给客户端重放就好了,也不需要什么逆向操作。
AI分析了一下之后发现它用的是WebSocket+Protobuf协议,正常来说如果要使用Protobuf协议得要有.proto接口定义文件才可以,不过既然我的要求只是重放,它就没去考虑逆向了,只是简单分析了一下编码规则,就用Python把服务端给写出来了。
不过如果这个快照必须本地启动一个服务器,玩起来就有点麻烦了,这样搞得我估计就没心思玩它了,但是自从我做了终端版博客之后,我觉得浏览器可能没有做不到的事情,既然Python能运行服务器,那JavaScript一样可以,于是我就要求AI让游戏和服务端一起运行在浏览器中。
结果它还真折腾出来了,在JavaScript中似乎一切都可以被覆盖,于是AI就替换了浏览器的WebSocket对象,让客户端以为它连接到了真实的服务器,然后再按照之前录制的包发送就好了,折腾了一段时间之后游戏真的能正常启动了,真的是太离谱了😨。

制作对局引擎

其实这基本上已经满足我的要求了,但我还是有点好奇,这个客户端里真的没有.proto接口定义文件吗?正常来说既然客户端知道怎么和服务端通信,那么这个文件应该是必须要有的吧?反正目的已经达到了,接下来就让AI瞎折腾看看。
在我要求完之后它又折腾了好长时间,结果.proto也被它给搞出来了😨,也许对于会逆向Unity3D游戏的人来说这不算什么,但这已经让我很震惊了……既然有了这些,也许完成一个完整的服务端将不再是难事。
于是我让它录制了几把我实际对局的包,并理解整个对局中数据包的时序,开始考虑如何做出完整的麻将对局。首先作为离线运行的游戏来说,和人对局肯定是做不出来了,那就得想办法搓个人机出来。我最先想到的是著名的麻将AI Mortal,之前用它在雀魂上打了几把之后结果号没了😂,不过我确实觉得和人打麻将没什么意思,感觉麻将如果全按最优解打的话结果就全是靠运气……不过后来我想了一下还是不用Mortal了,首先这个模型不小,整个模型要好几百MiB,而且三麻和四麻的模型还不一样,还有三麻的模型还是收费的……另外真和Mortal打的话感觉难度也确实有点高,除了运气因素外,至少人机犯错的概率比人要低不少,那打起来估计会很难受。所以我就对AI说,做一个降低向听数为主的全攻型人机,其他细节让它自己考虑,另外我想到上次做的那个麻将Wordle它自己写的役种分析实在是有点烂,所以这次就告诉它必须要使用现成的Riichi库,免得出乱七八糟的问题。
在连续跑了好几个小时之后AI成功把完整的对局引擎给搓出来了,真的是相当了不起,要知道这个东西正常来说人开发怎么说也得好几周吧?它就几个小时开发出来了,还理解了数据包的时序,用之前分析的.proto文件成功接入到了游戏中,实在是太强了……当然因为我没有用大量真实牌谱去测试,有BUG也是正常的,比如目前的引擎还存在立直之后完全没法开杠这样的边缘问题,就只能慢慢修了,不过这都是小问题,它能做到目前的水平已经不是一般人能做到的了。
最终做出来的项目可以在这里看到,还可以通过GitHub Pages上的部署直接游玩。

隐藏彩蛋

在AI做完整个服务端之后,我闲得没事瞎改对战人机的角色和皮肤,因为游戏中的皮肤ID有一定的规律,所以可以根据角色ID来猜皮肤ID。在这个时候,我发现其中有一个角色在游戏中没有对应的泳装皮肤,但是我把人机的角色按照规律配置推测的泳装皮肤ID是能显示出来的……那也就是说这个游戏可能在配置中隐藏了这个皮肤,但是并没有删掉美术资源,所以接下来我就想着能不能让AI把这个隐藏的皮肤显示出来,这样就能在游戏中直接使用了。
所以我根据自己推测的信息,告诉AI:在这个游戏中有一个皮肤编号3708002,头像编号1018的资源被隐藏了,其中关于这些资源的配置应该在commonconfigs中,帮我解包并分析,考虑如何将这个皮肤和头像恢复显示,然后重新打包资源,使其可以正常加载。就这么个需求,它从Bundle的解包,到分析FlatBuffers,然后又处理XAsset的校验……最终花了3个小时就给我做出来了,而且我试了一下,非常完美,简直就像这个皮肤本来就存在一样,不过原本这就是被官方隐藏的嘛,解除隐藏自然就和真的存在一样啊😂。

能在WASM Linux中运行的SSG

之前我做完终端版博客之后就在想,让这个完整的Linux只用来展示博客似乎有点浪费,感觉它还能做更多事,但对我来说编译在这个系统中能运行的二进制程序还是有一点困难,虽然我有用AI,但是我的Agent是运行在macOS上的,所以编译Linux二进制程序有点困难……不过单是BusyBox其实就能做相当多的事情了,毕竟我当年可是在手机上用ash做出了论坛系统Mabbs,那它能做的事情应该还有很多。
最近我在和群友聊天的时候发现有人用C#做了个静态网站生成器,也是用AI做的,看起来挺有意思。其实SSG的原理还是挺简单的,说到底就是些文字转换罢了。于是我就想,要不然我就让AI搓个能在WASM Linux中运行的SSG出来吧?
当然直接搓我还是会有一点怀疑AI的能力,所以又找了点素材,之前在体验Tilde社区的时候发现了bashblog,其实这就已经是一个现成的SSG了,只不过它用的是Bash语法,得让AI转换成POSIX风格的语法。另外它还依赖使用Perl语言编写的Markdown库,我这个系统里可没有Perl啊,也得想办法找个可以替代的东西。
我想了一下,在BusyBox中最适合处理文本的大概就是awk了,所以我首先应该找找有没有用awk编写的Markdown转换程序。结果搜了一下还真被我找到了,有一个叫做d.awk的库,里面包含一个完全用awk写的Markdown转换程序,那么接下来我只需要把这两个项目丢给AI,让它以bashblog为基础,以d.awk为引擎制作一个兼容性极强的POSIX SSG吧。
这种问题果然还是太简单了,它只用了一两个小时的时间,就真的做出来了,最终它把这个项目起名叫做possg,并且我把这个程序粘贴到了我的终端版博客中运行了一下,效果也是非常完美……如果哪一天我抛弃了Jekyll,也许就会用这个possg吧😂。

终端版博客的各种新功能

最近一段时间我在我的终端版博客又有一些新的想法,于是就对着AI反复许愿,结果AI还真的能让愿望成真。以下就是从上次做完之后又新增的一些功能:

Markdown高亮

最初博客在终端中显示文章时是直接打印的Markdown原文,感觉不太好看,所以我就想着如果能加个高亮功能体验应该会更好吧?最开始我要求AI做这个功能的时候它选择使用Shell+sed来实现,那时候我还没有做possg,没考虑用awk来做,所以性能非常差。因为Shell在处理这种任务的时候要不停地调用sed,这个系统开进程的开销又很大,所以处理一篇文章得好几秒,后来才考虑用awk来做,结果性能确实好了不少,最终的代码可以在这里查看。

挂载本地文件夹

前段时间我看到作者合并了一个关于virtiofs的PR,当时我看到这个PR的时候就想,记得Chrome浏览器有一个showDirectoryPicker API,可以允许网页对指定的文件夹进行读写。现在有了这个驱动的话,让AI做个将本地文件夹挂载到Linux的功能应该是个很简单的事情,于是我直接就闭着眼睛让AI做了。
做完之后虽然能用,但是我又回想起上次让AI搓的通过Service Worker访问系统内网络服务的功能,结果人家原作者已经做了一样的功能,我还给作者发了Issue推荐😅,所以这次我得吸取教训看看作者是不是已经做了这个功能,毕竟作者实现virtiofs的时候估计就有这个打算吧?结果看了一眼还真做了😆,我闭着眼睛让这个垃圾AI去做看来确实有点着急了,虽然我不知道原来我的AI做得到底怎么样,但是我对它还是没什么信心,也许作者这个代码也是AI写的,但很明显他的AI比我的AI水平强得多,所以让它参考作者做的这个版本重新写了一版。总之最终都能用,写出来的代码就是:hostfs.js,再配合上mount_host脚本就可以实现挂载本地文件夹的功能了。

基于WebRTC的局域网内组网

我在制作网络模块的时候一直在想,有没有办法能在不依靠服务器的情况下让两个这样的Linux系统互联呢?正常来说要想让两个浏览器内的服务互相访问就必须有一个中转服务器,因为浏览器肯定是不允许网页监听端口,而且就算能监听,我的这个网页还是放在GitHub Pages上,使用了HTTPS,也没办法访问HTTP资源,搞证书也是个麻烦事。
但其实浏览器是有P2P功能的,那就是WebRTC,只是我不太确定这个技术能不能做到无服务器,于是我问了问AI,这个功能能不能不要服务器,结果AI说可以,只要两个机器在同一个局域网,就可以通过手动交换SDP信令来实现P2P连接,而且考虑到我基本上完全没有锥形NAT网络可以用,所以就不搞STUN服务器了,反正也连不上。那既然理论上可以的话,就让AI实现吧。
最终花了一点时间AI就写出来了,使用webrtc-p2p.js配合connector就可以实现同一个局域网内两台电脑上的浏览器中的Linux互联了。
这个功能做出来之后直接就有了无限的想象空间,比如想用A电脑的浏览器控制B电脑浏览器中的Linux,就可以在B电脑中执行tcpsvd -v 0.0.0.0 4444 /usr/bin/script -qc 'stty -echo; /bin/sh -i' /dev/null,然后在A电脑用nc 10.0.2.x 4444去连接就可以了,效果就像SSH一样,而且配合之前做的挂载本地文件夹功能,甚至可以直接操作B电脑中的文件;另外之前做的Service Worker访问系统内网络服务也能用得上,假如B电脑有一个网页想要分享给A电脑,可以在B电脑上用mount_host把本地文件夹挂载到Linux的/mnt/host目录中,然后在那个目录执行httpd -p 8080,A电脑可以执行tcpsvd -v 0.0.0.0 8080 /usr/bin/nc 10.0.2.x 8080转发,接下来打开http/8080/就可以访问到B电脑上的网页了。
总之只要有了网络,这个系统的功能就能被无限扩大了,只是……说是这么说,其实还是依赖浏览器的功能,所以这些功能就算用JavaScript直接做也完全没问题,但毕竟是作为Linux终端,有了这些能力之后就显得更完整了。
最终我仔细确认了原作者确实没有做这个功能,所以就发了个Issue看看他有没有兴趣把这个功能加到他的库中,如果被接受了也算是做出贡献了吧。

使用VLESS协议的网关

虽然之前已经加了基于浏览器Fetch API的网关,但这个网关的局限性还是挺大的,基本上也就只能请求网站了。那其他能在浏览器上运行系统的项目是怎么做的呢?看了一下像v86用的是一个基于L2 Over WS的公共网关relay.widgetry.org,而WebVM用的则是Tailscale。这两种我都不喜欢,主要是这些东西基本上专门服务于这类在浏览器中运行的系统,普通人并不会特意去搭建,而我既不想用某个特定的服务,也不想自己搭……那么有什么样的网络服务既随处可见,又能在浏览器中使用呢?这时候我就想到了VLESS协议,它天生就适合运行在WebSocket中,而且搭建起来极其简单,比如有人用Cloudflare Workers做了一个叫做edgetunnel的脚本,可以轻松将Cloudflare作为代理使用,因此我决定接下来让AI实现一个基于VLESS+WSS的网关,这样除了请求网站之外还能做点别的事情,比如用telnet towel.blinkenlights.nl命令看ASCII版的《星球大战》😆。
只不过相比于其他项目用的那些L2的服务来说,VLESS承载的是L4的协议,所以我还得让AI实现TCP/IP协议栈……其实我不太想这样做的,因为AI做这种事情容易出错,但是它看了一下我之前用的tcpip.js库说是里面的API不满足要求……那没办法,AI既然自己想搓就让它搓吧。不过VLESS协议这个我觉得还是得给它个文档,免得它开始瞎编。
不管怎么说它还是搓出来了,虽然质量我不是特别满意……但毕竟手头用的AI不是最先进的AI,就先这样吧~最终的代码就是:vless-gateway.js,用法也很简单,首先要有一个证书合法的VLESS+WSS节点,然后在我的博客终端中按F12打开控制台,输入:switchToVless("wss://domain:port/path", "UUID")就可以将原来基于Fetch的网络切换到VLESS节点的网络了,考虑到避免被滥用的情况,我就不内置节点了,如果有人有兴趣可以用自己的节点进行连接。

感想

做到这里,可以感受到最近的AI所做的一切真的是令人难以置信,它不仅知道几乎所有知识,还有不知疲倦的思考能力,以及足够的动手能力,而这也仅仅建立在一个很一般的免费模型上。再回想起前段时间用的Agent,做的什么订阅源预览,简直就太简单了😂。目前据我所知其他人使用更先进的AI已经可以解决人类没能解决的数学难题、自主设计处理器指令集架构在上面运行Doom、从零开发一个Classic Mac OS、制作直接在UEFI中运行的软件、以及完全逆向Unity3D游戏,把几款游戏混合起来做成一个新游戏……(至于网上总传的什么DeepSeek的所谓的灰测模型搞出来的各种Demo就算了,根本比不了)过去各种难以想象的东西都被轻松做出来了,而这一切就发生在最近几个月,我已经很难想象未来AI能做到什么样的地步了,也许AI真的会带领人类走向无法想象的高度吧~

一次制作终端版博客的经历

2026-08-01 00:00:00

在浏览器上用真的Linux访问博客会是什么样的感觉?

起因

在去年的时候我为了想在博客上加个终端,写过一篇关于在浏览器中运行Linux的文章。不过那时候只是调查了有哪些可能的方法,并没有真的做出来,主要是自己做感觉太麻烦了😂。
但从上次进行了Vibe Coding之后,我意识到这可能不再是什么麻烦事,也许只要和AI聊聊天就能轻松做出来了,于是接着之前的方案,我开始着手完成我的想法。

终端版博客的不同形式

基于WASM Linux的版本

在之前的文章里,我其实更倾向于WASM Linux的方案,尤其是Thomas Stokes制作的WASM Linux,因为它相比于虚拟机更轻量,效率更高,而且还能完美地在Safari中运行(毕竟我用的是macOS😂),因此接下来我计划用它为基础制作我的终端版博客。
不过之前我也说过,目前这个WASM Linux还不能和外界通信,不能和外界通信就不能读取我的文章。于是我打算先让AI解决这个问题。但如果我直接给它说想要和外界通信也许它会给我整一堆奇怪的实现,所以我得自己先想一个实现起来最简单的方案。之前我记得Fabrice Bellard大神写的JSLinux中有一个剪贴板设备文件可以和外部通信,那么干脆让AI写一个能在浏览器中执行JS的设备文件吧,这样做出来只需要写简单的Shell脚本就可以和浏览器交互了,也不需要考虑如何编译WASM程序,于是就这样我开始让它实现这个功能。
最终经过几次调试和修改之后AI真的把这个设备文件给做出来了,不过刚做出来的时候有些问题。因为在这个Linux中的进程都是在Web Worker中运行,执行的JS也会在Web Worker中运行。但是Web Worker的局限性很大,并不能和当前网页进行交互,不过这种问题也都是小事,发现了之后只要给AI一反馈也是轻轻松松就改好了……最终实现的效果我感觉相当不错,既然如此,那我就把AI做的这个驱动给上游PR一下吧,也算做个贡献。不过最终原作者不太喜欢这种实现方式,他好像更喜欢标准化的方式,说是已经做了一个VsockDevice,可以直接使用Linux自带的驱动和外界通信,所以把我的PR拒了(看来AI Slop还是入不了大佬的法眼😂)。 不过我问了一下AI,它说用这个设备的话可能需要ioctl()系统调用,就没办法直接用Shell操作了,只能用别的语言写的程序才能用它通信,所以我只好自己用这种不标准的方式了。 虽然VsockDevice确实是不太好操作,但是大佬随后又新增了一个ethernetDevice可以用来上网,考虑到维护自己的分支实在是太麻烦了,所以我让AI写了一个虚拟的HTTP服务,使其能够执行JS,顺便还增加了连接互联网的功能。这样我就可以直接使用上游的代码,不需要再维护之前写的驱动了(其实以太网比Vsock复杂多了,只是因为可以用wget之类的客户端请求所以才简单😋)。
在做好与外界通信的通道后我就开始让AI写能和我博客交互的脚本,不过在这个过程中也遇到了不少问题,最开始的时候发现这个Shell的管道有些问题,执行的时候会忽略管道前的命令,后面的命令就没法执行了……感觉有点奇怪,所以我要求它在实现交互脚本时不要使用管道符。除此之外我还发现反引号和$()这些能把执行结果插入字符串的语法也不能用……总之就是局限相当多,但它还是克服了这些困难最终把脚本给写出来了,最终实现了列出文章、查看文章、打开文章和查看评论等功能,效果还是相当不错。 唯一的缺陷就是它不能进行异步操作,只能同步请求,所以在进行请求的时候会把浏览器卡住,体验稍有打折。 后来用了网络版的JS执行服务后就可以异步了,避免出现把浏览器卡住的情况。
不过这个系统如果管道符不能用的话效果会差一大截,这可是Shell的灵魂啊,所以我提交了一个Issue,希望作者能修一下。结果作者真的强得离谱,提出问题之后第二天就修复了,实在是太强了😨。但修好这个问题之后作者又整了一堆别的花活,把内存限制给取消了,结果我在Safari上一运行就报OOM的错误……于是我把这个问题又反馈了一下,顺便让AI看看这个问题怎么解决,结果AI也不辜负我的期待,轻轻松松就解决了🤣。最终我除了会提出问题之外什么也不会做,只能看着大佬和AI完成我的想法了😝。
后来增加的以太网网关也挺折腾,原理其实并不复杂,但直接给AI说想用这个接口上网的时候它会自己想办法搓一套漏洞百出的TCP/IP协议栈,所以后来我在GitHub找了个tcpip.js库让它集成,BUG就比它自己手搓的少了不少。后来我又想加一个TLS终止功能,因为在浏览器上没办法直接让系统里的程序发送原始的TLS数据包去请求网站,但我又希望它能够请求带有https协议的网址,所以要提前对TLS进行解密再转换成fetch(),这时候我就没让AI自己手搓了,在网上找了个node-forge库让AI对接,就可以避免出现一堆奇怪的BUG了。
做好网关之后我又突发奇想,想做一个展示终端内状态的页面,但是目前JS并不能主动和里面的系统直接通信,所以又让AI搓了一个通过Service Worker访问系统内网络服务的功能,然后用busybox的httpd执行sh写的cgi程序,就可以被前端读取到了。随后我还给原作者发了个Issue展示这个Service Worker的效果,结果人家已经做了这个功能,我还是太Naive了😂。
最终做好的网站可以在这里查看,效果真的不是一般的好,相比于其他模仿终端形式的博客简直是降维打击,毕竟这是真的Linux啊~虽然目前依然有些BUG,但是原作者修BUG的速度还是挺快的,相信等他发布正式版之后,体验会更进一步。

基于JSNix的版本

虽然我觉得追求真实感很重要,但是我又回去看了一下为我提供想法的JSNix。虽然这个项目并不是真的Linux,但是我让AI看了一遍之后它对这个项目的评价也很高,说是作者对Linux的了解非常深入,很多细节模拟的也非常到位。而且为WASM Linux制作的那些脚本,在这个JSNix上其实完全可以实现,还更简单,于是我直接让AI参考给WASM Linux写的脚本把那些功能全部移植到这个JSNix上。也许是因为AI更擅长编写网页,很快它就完成了这些功能,而且效果非常不错,基本上和WASM Linux上面的效果一样,表现也相当不错,最终生成的结果可以在这里查看。
不过假的毕竟还是假的,和真的Linux差异还是有不少的,像正经的Shell可以在终端中直接用if、for和while之类的语法写一些东西,它这个就必须要编写成脚本才可以,而且解析也有相当多的问题。各种命令的运行结果很多都是硬编码的,只是编了一段对应命令的输出,参数做得也不全。另外著名的Linux文本三剑客grep、sed和awk在这个系统里实现得也非常差,只做了一些非常基础的功能,基本上就是完全不可用。所以说这个系统还是不够真实,只能做最基础的体验,不能够深究。
但并不是说它没有优点,它作为纯粹的JS不像WASM Linux那样容易出现兼容性问题,比如很多手机浏览器不支持跨源隔离,导致SharedArrayBuffer之类的API没法用,所以手机上只能体验这个JSNix的版本;另外WASM Linux的体积虽然说并不算很大,但也有好几MiB,不像这个JSNix连1MiB都不到;还有这个系统没有启动过程,相比于WASM Linux来说加载更快;而且在编写和修改功能的时候比WASM Linux简单不少,像给WASM Linux做的终端状态展示在JSNix上根本不需要折腾什么Service Worker,以及请求网络之类的也非常简单,直接fetch()就可以(也正是如此,JSNix的版本AI没一会儿就做完了)……总之就是只要不涉及具体功能方面JSNix都比WASM Linux要强不少,不过这样说有点“抛开事实不谈”的感觉了😂。

感想

最终做出来的这两个版本的终端版博客基本上所有的代码都不是我写的,真的做到了仅仅是和AI聊聊天就把东西做出来了……只能说AI现在真的不是一般的强啊~当然方案还是得自己考虑,因为AI一旦走错了方向就很容易往一个方向不停地钻,结果就是花了很长时间可能效果会很差,尤其是现在的AI还特别喜欢自己造轮子,所以这时候还是自己找找看已经有的方案然后告诉它会比较好。
从效果来说这次写的东西也让我非常满意,我想我这次做的这个博客界面估计是前所未有的,毕竟这可是在浏览器上用真的Linux访问博客啊,其他人做的都是假的。至于虚拟机那些方案可能也是因为太重,所以基本上没人用,而WASM Linux只有Thomas Stokes做的效果最好,但是又没有受到应有的关注……总之从我个人的角度来看这次做的东西还挺Cool,就是不知道会不会吸引其他人来看呢……可能还是太小众了吧😂。

Vibe Coding新体验

2026-07-01 00:00:00

All in AI!

起因

前段时间,我写了一篇关于AI Agent的文章,虽然当时我说这并没有什么创新之处,但在那之后用着用着感觉还是挺好用的,至少相比以前在云端Vibe Coding来说又快又免费,所以我打算分享一下最近我用AI Agent的经历。
不过大多数情况下我写代码都可以直接让各路AI在对话中给我生成,那到底有什么样的场景才需要用AI Agent呢?一般应该就是需要边改边测的东西吧。

关于PJAX的改造

虽然说AI生成的东西可靠性并不是很高,但至少在网上已经有很多例子的代码应该不至于乱生成吧……其实我一直都想给我的博客增加全站无刷新的功能,但是每次看到PJAX都要自己重新调用当前页面的各种函数就懒得动手了😂。不过其实这也不是什么复杂的事情,主要是一个一个分析函数比较麻烦,所以既然有AI了,不如直接让AI帮我做吧。于是我让它克隆了我的仓库,分析并写出PJAX的代码。
用AI Agent最大的好处就是能让它了解整个项目的所有代码,如果是直接在对话里让AI修改的话就很难做到这一点。总之我告诉它之后等了一会它真的写出来了,我试了一下确实能用,虽然刚开始有不少问题,主要是因为我用了Live2D,不少问题也是这个功能引起的。不过只要我告诉它问题在哪里,它总是能给我改好,先不论它实现的代码怎么样,但至少从效果来说还是相当不错的。
最终生成的代码我看了一下,代码质量非常糟糕,把我在别处的代码到处复制粘贴到PJAX用的代码里了,但是体验效果不错……所以没办法,虽然代码很糟糕,但先用着吧。不过我觉得优化应该也能交给AI Agent,只是它有时候改来改去还会把正常代码改坏,所以优化看起来还是只能自己做……
最终的代码就是:pjax.js,虽然我对这个代码质量不是很满意,但是毕竟能用,效果也不错,就先这样用吧~

生成一个小游戏

前段时间,我在Bilibili上看视频的时候发现了一个有趣的微信小游戏,叫做“雀谜”,它把立直麻将和Wordle两个玩法结合到一起,看起来挺有意思。不过它作为微信上的小游戏有些问题,一是看提示要看广告,二是换题也要看广告,而且看广告就算能换题也有次数限制,一天也就几次机会,让我有点不爽。
不过从功能上来说看起来并不复杂,感觉用AI直接Vibe Coding完全不是问题,不过AI Agent从零开始写比较考验模型的水平,所以我打算先让各路网页上的AI生成初版的Demo,之后再考虑怎么改。于是我对它们说:

帮我制作一款立直麻将Wordle网页小游戏,规则为:

  1. 系统随机生成一个有役能和的14张牌,必须按照万、筒、索、字的顺序排列,同花色内数字从小到大,但是第14张用来和的牌可以不遵守排列规则
  2. 让玩家猜6次来猜到这个牌型,每次猜的牌也必须是满足有役和牌的条件
  3. 位置和内容正确的牌用绿色标注、只有内容正确但是位置不正确的用黄色标注,不在生成范围内的牌用灰色标注
  4. 界面中所有的牌使用Unicode中的麻将字符,并且提供一组输入按钮用于输入这些麻将字符
  5. 输入按钮在游玩过程中也要标注颜色
  6. 随机生成的牌需要标注场风、自风和是否自摸,如果是荣和需要额外的役,需要考虑平和之类的役种
  7. 加一个提示键,可以查看答案牌型的役种

直接生成确实很考验水平,有很多AI直接生成出来的完全用不了,像DeepSeek生成出来的按钮按上去都没有反应😅,最终只有Gemini生成出来的感觉还不错,无论是界面还是输入的感觉都很好,于是接下来的修改我就打算以Gemini生成出来的代码为主。
刚生成出来的代码虽然能玩,但是每个AI直接生成都不想做完整的役种检测,说什么一个对话不够😥,明明各个都是几百k上下文的AI,有什么不够的……总之想一口气就完美做出来显然是不现实的,不过其实最开始我也不打算把所有役都做了,毕竟条件是“有役和牌”,而且没有副露,所以好多可以叠加的役其实都不需要做,尤其是役满那些,大多都可以用其他役的条件满足。
在这期间,“平和”算是实现起来最麻烦的,主要是这个役需要根据和牌判定,必须是顺子的两面听牌才可以……虽然AI理解这些知识,但是写的时候经常出问题,像边张之类的经常误判……但是不实现又不行,毕竟这是可以nomi的牌型。
最终写的差不多了,感觉还不错,于是在GitHub Pages上部署了一份,起名叫立直麻将 Wordle。
最开始我以为这个小游戏的玩法是他们原创的,于是把生成的代码又丢给AI,让它评价一下创新性,结果AI说已经有开源了😂,有个叫做Mahjong Handle的才是最早做这个玩法的……还好这代码不是我写的,要是我写了半天结果发现有现成的那就很无语了😆。
当然我让AI写的版本也有一些特色,Mahjong Handle那个和原版Wordle一样,每天只有一道题,而且这个题是从题库里抽的,我让AI Agent看了一下它的代码,似乎是用phoenix-logs这个脚本从天凤上下载的牌谱数据解析得到的题库,所以它的题目数量是有限的,而我的代码是完全随机生成的,所以结果是无限的。但AI似乎认为作为Wordle来说一天一题才是它的特色,无限就显得没意思了……既然如此,那干脆让它给我把两个游戏合并了,让它参考Mahjong Handle把一天一题的功能也给我加上,另外Mahjong Handle还有一个MPSZ快速输入法,也让AI给我合并进我的游戏里了。
这时候就体现了AI Agent的优势,如果直接用线上的AI把Mahjong Handle中的功能移植到我的代码,我还得完整描述要实现的功能,但是AI Agent能自己看人家仓库的代码,就知道要改什么东西了。
最终实现的效果也很不错,虽然我的代码没有题库,但是使用了当天日期作为随机种子,用mulberry32算法搞出了一天一题的功能,还挺有意思的。
另外在Mahjong Handle以及原版的Wordle中,还有一个困难模式,就是每次猜测必须使用之前的猜测条件,不过我觉得如果原模原样把这个功能抄过来似乎有点无聊,而且我发现玩这个东西一般直接一个国士+两个一气就基本上把要猜的牌找完了,有点简单,所以我想了一下,要不然限制一下提交的条件,提交的牌型必须包含目标的役种之类的……不过要是做这个功能就不能像之前那样只实现部分役种了,得把所有能用到的都实现一遍,还好这种事情肯定也不是我来做,肯定还得是让AI来😋。
实现的时候还不能让AI直接一口气全加上,还得一步一步的提示,而且每个牌型还得手动测一遍……最终虽然做的看起来似乎已经很完善了,不过逻辑的根本还是有点问题,AI设计役种计算的时候是按拆解后役种数量最多的情况来算,但事实上立直麻将算役种是按照高点法,要最终点数最大才行。虽然按照目前的方法绝大多数情况下都能正常工作,但偶尔也会遇到问题,比如像这副牌:

  • 🀈🀈🀈🀉🀉🀉🀊🀊🀊🀞🀟🀗🀗 🀝

按照目前的方法计算的结果是:

  • 断幺·平和·一杯口 = 3番30符

然而正确答案是:

  • 断幺·三暗刻 = 3番50符

但是符数的计算又很麻烦……不过反正提示和验牌都是同一套程序,题倒是也能做出来……所以就懒得修了🤣。当然说到底还是因为AI没有用现有的计算库,像Mahjong Handle用的是现成的Riichi库,制作起来比我这个就简单多了。

为游戏添加字体

在我制作“立直麻将 Wordle”这个小游戏的时候,我让AI使用的是Unicode中的麻将字符,毕竟有现成的字符总比去找牌的图片要方便,而且实现起来感觉更简单一些。不过用字符确实也会遇到一些问题,我不知道为什么在macOS和Android上其他牌都是黑白的,唯有🀄️是彩色的😅,Windows上使用的Segoe UI Emoji倒都是彩色的,但是牌很丑,像是小学生设计的……虽然不影响玩,但是看着很难受,至少牌的颜色统一一下也好吧?虽然也可以搞出黑白的🀄︎,但如果用这个,在Windows上又成了只有🀄️是黑白的😅。虽然Mahjong Handle是直接用了SVG格式的牌图片,但我觉得应该有现成的麻将字体,像这种问题应该有现成的解决方法才对吧?
于是我直接问AI有没有这样的字体,AI搜了一下看起来还真有,有个叫做Mahjong Colored就是基于那些SVG做的OpenType-SVG字体,我本来以为问题就会这样解决,然而当我应用了字体之后发现牌的位置变成了白色的方块,貌似只有Firefox才支持这种字体……然而现在谁还用Firefox啊?
那遇到这种问题怎么办?当然还是让AI解决,它说要在Safari和Chrome上使用彩色字体,用COLR或者SBIX格式比较好,COLR格式的兼容性最好,三个浏览器都能用,而SBIX的话Firefox没法用。不过Firefox既然基本上没人用了,我倒是不太在乎它的兼容性,但我肯定还是更希望能用兼容性更好的方案,于是我把OpenType-SVG字体转换为COLR的工作交给了AI Agent。
结果它整了半天也没整出来,貌似是因为SVG的特效太多,没办法转换为COLR格式,于是它选择了SBIX方案,不过COLR存储的是矢量图,而SBIX存储的是位图,大小要大一些,而且放大还会有锯齿。只是目前实在是没办法了,只能用这种格式。
最终我把它转换后的字体存了一份到仓库,但是看着它1MiB多的大小以及不太好的浏览器兼容性,再考虑到它还是位图,实在是不太想在我的游戏里应用它……
结果后来我又搜了一下,原来是有现成的麻将字体啊,有个叫做Mahjong Font的项目用的就是COLR格式,兼容性非常好,而且大小只有80KiB,算是最完美的解决方案了。垃圾AI给我推荐的Mahjong Colored真的是……浪费了不少时间,看来有的时候自己调查要比问AI好一些吧。

感想

总的来看,目前Vibe Coding的体验确实是相当不错,只是问题也非常明显,完全让AI生成的代码要么实现的不优雅,要么就会出BUG,如果完全不懂要写的东西,纯粹由AI生成,那出问题的概率就非常高了。不过要是看不出来错误,完全被AI忽悠过去了,也许这时候的Vibe Coding在另一方面算是完美吧😂?至少对用的人来说也算是解决了问题呢。

如何节约游戏占用的硬盘空间?

2026-06-01 00:00:00

浪费硬盘空间是可耻的!

起因

在几年前,我写过一篇在MacBook上玩游戏的文章,在那之后,我已经在我的Mac上下载了几十部游戏。只不过有个问题……我的Mac只有256GiB的硬盘存储空间,下载一堆游戏会让我的硬盘空间不够用,但是又不太想删,所以我该怎么尽可能让游戏占用更少的空间呢?
首先为了能在Mac上尽可能流畅地玩,我玩的游戏大多都是用跨平台能力很强的引擎编写的游戏,比如Ren’Py、RPG制作大师、Godot之类的,而像RPG制作大师这种引擎制作的游戏还有一个特点,开发者一般都会使用引擎自带的素材进行开发,有时候还会用不少第三方的罐头素材之类的(实际上甚至还有好多AVG为了蹭这些引擎的公用素材刻意用它们),所以这几十个游戏里应该有非常多的重复素材,如果能想办法把它们去个重,应该能节省相当多的空间吧……

去重的方法

如果想要对文件进行去重,我搜了一下,有个叫做jdupes的工具就很不错,它支持多种去重方式,比如使用硬链接,或者用一些文件系统的写时复制特性。不过如果用写时复制特性,jdupes在第二次执行的时候会认为去重后的文件还是单独的文件,就会重复去重了,而且最终也不好统计,反正对我玩的游戏来说,要去重的都是游戏素材,不存在后续修改的可能性,所以我打算全部用硬链接。
所以最终要执行的命令也非常简单,直接一句jdupes -r -L Game就可以了,这样以后每次下载了新的游戏之后重复执行这个操作,就可以将游戏中和其他游戏里有的素材去重了。
不过实际上很多游戏并不能直接用这种方式去重,因为它们的资源文件有些是打包成单个文件,有些进行了简单的加密,导致即使是相同的素材,文件也并不相同,所以我必须让所有的资源以单独原始的形态出现。对于不同的引擎也有不同的处理方式,所以接下来我需要对它们进行一些研究。

不同引擎的处理方式

RPG制作大师MV/MZ

对于RPG制作大师MV/MZ开发的游戏来说,解密很简单,比较知名的是一个叫做RPG-Maker-MV-Decrypter的工具,它可以在浏览器中进行解密,但一个游戏的资源文件非常多……要是全上传给浏览器实在是太麻烦了……后来我又搜了一下,有一个用C#写的叫RPG Maker Decrypter工具也很不错,它作为命令行工具比在浏览器中执行简单多了,而且还能只把资源文件单独提出来,这样就可以剔除掉游戏自带的浏览器文件。不过他这个仓库的代码有个问题,它在选择文件的时候似乎会区分大小写,文件夹名中含有大写字母的似乎会被剔除……这样不太符合我的要求啊,当然我不会C#,于是我用AI改了一下,还给他提了个PR,不过这家伙看起来似乎不太喜欢AI写的代码,看起来不打算合我的PR😅。不过无所谓了,反正我也是自用,他爱合不合吧。
这个工具的用法也非常简单,一句RPGMakerDecrypter-cli [input] -p -o [output]就处理好了,处理完之后只需要把data/System.json中的hasEncryptedImages和hasEncryptedAudio设置为false就可以正常识别,以后在Mac中只要在游戏路径下执行python3 -m http.server就可以在浏览器中游玩了。
在这个过程中,我还发现有一些游戏喜欢把原画文件直接放到游戏里面,一张图片好几M,但RPG制作大师的引擎在渲染的时候根本不会渲染出那么高的分辨率,结果毫无意义地浪费一大堆存储空间,而且因为图片是加密的,对大多数人来说也没有收藏价值。所以在解密完之后我就想干脆把这些图片全部有损压缩一遍,估计能节省不少存储空间,于是让AI写了个简单的压缩脚本处理了一下:

#!/usr/bin/env python3
"""
图片压缩脚本(多进程版本)
将 pictures.orig 文件夹中的图片使用 WebP 格式进行高效压缩,
保持分辨率不变,肉眼看不出差异,压缩后的图片保存到 pictures 文件夹。

使用方法:
    python3 compress_images.py

压缩策略:
    - 保持原始分辨率不变
    - 使用 WebP 格式(有损压缩,高质量)
    - 质量设置为 85,在保持视觉质量的同时显著减小文件大小
    - 文件名和后缀保持不变
    - 多进程并行处理
    - 处理失败时自动复制原文件
"""

import os
import shutil
from PIL import Image
from pathlib import Path
from multiprocessing import Pool, cpu_count
from functools import partial

# 配置路径
SOURCE_DIR = "pictures.orig"
OUTPUT_DIR = "pictures"

# WebP 质量设置 (0-100,数值越高质量越好,文件也越大)
# 85 是一个很好的平衡点,肉眼几乎看不出差异
WEBP_QUALITY = 85

# 对于带有透明通道的图片,可以设置不同的质量
WEBP_QUALITY_WITH_ALPHA = 80

# 并行进程数,默认为 CPU 核心数
NUM_WORKERS = cpu_count()


def compress_single_image(img_file: tuple[str, str, str]) -> tuple[str, bool, int, int]:
    """
    压缩单个图片文件(用于多进程)
    
    Args:
        img_file: (源文件路径, 输出文件路径, 输出目录) 元组
        
    Returns:
        (文件名, 是否成功, 原始大小, 压缩后大小) 元组
    """
    source_path, output_path_str, output_dir = img_file
    source_path = Path(source_path)
    output_path = Path(output_path_str)
    
    original_size = source_path.stat().st_size
    
    try:
        img = Image.open(source_path)
        
        # 检查是否有透明通道
        has_alpha = img.mode in ('RGBA', 'LA', 'PA') or (img.mode == 'P' and 'transparency' in img.info)
        
        # 确定使用的质量
        quality = WEBP_QUALITY_WITH_ALPHA if has_alpha else WEBP_QUALITY
        
        # 保存为 WebP 格式,但使用原始的文件扩展名
        img.save(
            str(output_path),
            format='WEBP',
            quality=quality,
            method=6  # 压缩方法 0-6,6 是最慢但压缩率最高的
        )
        
        compressed_size = output_path.stat().st_size
        return (source_path.name, True, original_size, compressed_size)
        
    except Exception as e:
        # 处理失败时,复制原文件到输出目录
        try:
            shutil.copy2(source_path, output_path)
            compressed_size = output_path.stat().st_size
            return (source_path.name, False, original_size, compressed_size)
        except Exception as copy_error:
            return (source_path.name, False, original_size, 0)


def main():
    source_dir = Path(SOURCE_DIR)
    output_dir = Path(OUTPUT_DIR)
    
    # 检查源目录是否存在
    if not source_dir.exists():
        print(f"错误: 源目录 '{SOURCE_DIR}' 不存在")
        return
    
    # 创建输出目录
    output_dir.mkdir(exist_ok=True)
    
    # 获取所有图片文件(支持多种格式)
    image_extensions = ('*.png', '*.jpg', '*.jpeg', '*.bmp', '*.gif', '*.tiff', '*.webp')
    image_files = []
    for ext in image_extensions:
        image_files.extend(source_dir.glob(ext))
    image_files = sorted(set(image_files))  # 去重并排序
    
    if not image_files:
        print(f"在 '{SOURCE_DIR}' 中没有找到图片文件")
        return
    
    # 构建任务列表
    tasks = []
    for img_file in image_files:
        output_path = output_dir / img_file.name  # 保持原文件名和后缀
        tasks.append((str(img_file), str(output_path), str(output_dir)))
    
    print(f"找到 {len(tasks)} 个图片文件")
    print(f"源目录: {SOURCE_DIR}")
    print(f"输出目录: {OUTPUT_DIR}")
    print(f"WebP 质量设置: {WEBP_QUALITY}")
    print(f"并行进程数: {NUM_WORKERS}")
    print("-" * 70)
    
    # 使用多进程池处理图片
    success_count = 0
    fail_count = 0
    total_original = 0
    total_compressed = 0
    
    with Pool(processes=NUM_WORKERS) as pool:
        for i, (filename, success, original_size, compressed_size) in enumerate(pool.imap(compress_single_image, tasks), 1):
            total_original += original_size
            total_compressed += compressed_size
            
            if success:
                success_count += 1
                marker = "✓"
                reduction = (1 - compressed_size / original_size) * 100 if original_size > 0 else 0
                status_msg = f"{reduction:+.1f}%"
            else:
                fail_count += 1
                marker = "✗"
                status_msg = "复制原文件"
            
            status = f"[{i}/{len(tasks)}] {filename}"
            print(f"{marker} {status:50} {original_size/1024:>8.1f}KB -> {compressed_size/1024:>8.1f}KB ({status_msg})")
    
    # 输出总结
    print("-" * 70)
    total_reduction = (1 - total_compressed / total_original) * 100 if total_original > 0 else 0
    print(f"压缩完成!")
    print(f"  成功处理: {success_count}/{len(tasks)} 个文件")
    if fail_count > 0:
        print(f"  失败(已复制原文件): {fail_count}/{len(tasks)} 个文件")
    print(f"  原始总大小: {total_original / 1024 / 1024:.2f} MB ({total_original / 1024:.1f} KB)")
    print(f"  压缩后大小: {total_compressed / 1024 / 1024:.2f} MB ({total_compressed / 1024:.1f} KB)")
    print(f"  总压缩率: {total_reduction:.1f}%")
    print(f"  节省空间: {(total_original - total_compressed) / 1024 / 1024:.2f} MB")


if __name__ == "__main__":
    main()

最终压缩完之后我把原图上传到了EH画廊中,本地只留压缩后的图片,大小从原来的2GiB多下降到了300多MiB,可以说效果相当显著了。
除此之外还有一些游戏使用了Ogg FLAC背景音乐,这种音乐不仅占用磁盘空间很大,而且我在Safari上玩的时候浏览器根本没法解析(Chrome应该可以)。虽然我听音乐是会考虑HiFi,但玩游戏就没必要了吧……所以像这种音乐,就得用一句:

ffmpeg -i input.flac.ogg -c:a vorbis -strict -2 -q:a 10 output.ogg

转换为正常有损的Ogg音乐了。

RPG制作大师XP/VX/VA

对于RPG制作大师XP/VX/VA引擎开发的游戏来说,它们都是基于用Ruby语言开发的RGSS编写的,作为脚本来说,倒是有跨平台的条件,但因为官方并没有做跨平台,所以不能直接在Mac上运行。不过有一款叫做mkxp-z的工具允许跨平台运行使用RPG制作大师XP/VX/VA制作的游戏,因此这类游戏我也收集了一些。
这些游戏的资源通常会进行简单的混淆加密,一般会打包成单个RGSSAD文件,这个解包也很简单,用刚刚的RPG Maker Decrypter就可以。不过这种游戏还有个特点,有些游戏需要使用RTP才能运行,它这个RTP其实就是RPG制作大师自带的素材包,当时设计出来估计也是想着用来节约硬盘空间吧,就是不知道为什么到后来的MV/MZ却取消了这种方式……虽然mkxp-z是支持通过配置文件引入RTP的,但既然我已经选择了硬链接的方式,就没必要单独搞RTP了,我选择把RTP直接和游戏合并,然后让jdupes直接去重就好了,这样相比于RTP的方式还有一些好处就是XP/VX/VA可能有一些和MV/MZ使用相同的素材,这部分也可以不用占用重复的空间了。

Ren’Py

对于Ren’Py来说,因为这个引擎并没有自带的公共资源,所以重复素材的问题并不是很大。不过在我之前对Ren’Py的探索中提到过,我玩的一些游戏是系列游戏,这种系列游戏有非常多的素材复用,但显然开发者并不会为了节约玩家硬盘空间而共享这部分资源,而且Ren’Py游戏也都是打包成单个文件的,所以接下来我们依然得要解包才能进行去重处理。
Ren’Py使用的rpa文件解包起来依然很简单,有一款现成的工具unrpa可以直接解包,用pip就能安装。不知道为什么这些引擎总是喜欢把资源文件都打成一个包,明明很容易就能解包……难道是为了性能吗?
不过也正是因为Ren’Py的公共资源不多,如果玩的不是系列游戏,就没有解包的必要了,解包之后一堆小文件有可能会比整个rpa文件更大,毕竟文件系统存在“簇”,有可能会消耗没对齐的空间。

验证结果

最终进行完上述操作,可以通过执行du -sh和du -shl进行对比来验证节约的硬盘空间,我在这次游戏的瘦身中节约了:

~ % du -sh Game                                
 33G	Game
~ % du -shl Game
 47G	Game

看起来还是相当可观啊……尤其是在当下硬盘价格大涨的情况下,如果很多人能通过这些方式来节约硬盘空间,就能减少对硬盘容量的需求吧……不过说到底其实也都是网上能下到的资源,也许玩完之后就删掉才是最好的节约硬盘的方式吧😂。