#!/usr/bin/env python3
# hrust 1.0: Hrust 2 (hr2) packing and unpacking for the ZX Spectrum: packs as Quick Commander 3.11's h command
# does (its Z80 packer, Hrust 2.4 with Alone Coder's lazy matching), byte for byte; -o as oh2c does (Eugene
# Larchenko's optimal packer, v2020.01.04), byte for byte; unpacks as QC's depacker does.
# Copyright (c) 2026 Spectre (Optical Brothers), https://www.zxby.org. MIT License (see LICENSE); -o is a port
# of oh2c, MIT too (see LICENSE-oh2c).
#
#   hrust [-f] [-q | -o] INPUT [OUTPUT]   pack (OUTPUT: game.C -> game.p_C, other names INPUT.hr2)
#   hrust -d [-f] INPUT [OUTPUT]          unpack (OUTPUT: game.p_C -> game.C, NAME.hr2 -> NAME, else INPUT.dpk)
#
# OUTPUT - is the standard output (for mc's viewer). A hobeta file is taken by its header. See README.md.
#
# The block: "hr2", flags (#31; bit 7: stored, not packed), the data's length, the length of the rest (a word each),
# the data's last 6 bytes, the stream of the others. The stream: the first byte as it is, then tokens; their bits go
# MSB first in bytes that take the place where their first bit came, the tokens' whole bytes between them:
#   1 BYTE              a byte
#   0 00 ddd            1 byte from 1..8 back (ddd: the distance's minus, low 3 bits)
#   0 01 BYTE           2 bytes from 1..256 back (BYTE: the minus distance)
#   0 10 DIST           3 bytes
#   0 11.. xx DIST      4..14: 11 for each 3, then the rest: 4 = 0 11 01, 6 = 0 11 11 00; 15 = 0 11 11 11 11 11
#   0 11 00 0 nnnn      12 + 2n bytes as they are (12..42)
#   0 11 00 1 BYTE      BYTE 16..255: as many bytes, DIST; BYTE 1..15 and LOW: 256*BYTE+LOW bytes, DIST; 0: the end
# DIST: 1 BYTE: 1..256 back; 0 and the high byte H of the minus distance: 11 b (H = #FD+b), 10 bb (#F9+bb),
# 01 bbb (#F1+bbb), 00 bbbb (#E1+bbbb; 0000: H follows whole), then the low byte.

import os
import re
import sys

HEADER = 8          # "hr2", the flags, the data's length, the rest's length (counted from here)
TAIL = 6            # the data's last bytes, as they are
FLAGS = 0x31        # "hr21": Hrust 2.1's format
STORED = 0x80       # flags bit 7: the data as they are
LONGEST = 0xFFF     # the longest match (4095)
RUN_MIN, RUN_MAX = 12, 42  # a run of bytes as they are

# QC 3.11's packer (QC's src/hrust/pack.asm) and its h command (3.11's src/hrust/packfile.asm)
QC_MAX = 0x8000     # Pack takes up to #8000 bytes (h: files of up to 128 sectors)
WINDOW = 0x3FFF     # how far back the search starts (window-1), but not below the packed data (see pack_qc)
OUT_BUFFER = 0x7E00  # Pack builds the block from here; the file lies up to #FFFF: the block meets it past
MEET = 0x10000 - OUT_BUFFER  # ...#8200 bytes of both
DEAR = 0x1E00       # a distance farther than this has the long code (H below #E2)
NEAR3 = 0x1D00      # a match of 3 is taken only this near (H #E3 and up)

OH2C_MAX = 0xFFFF   # oh2c's MAX_INPUT_SIZE
EMPTY = 'empty: nothing to pack (QC unpacks no empty block)'
OVERTAKES = ('the packed data overtakes the data being packed: QC 3.11 makes a broken block of it (data near 32K '
             'that hardly packs; -o packs it)')


class Error(Exception):
    pass


class Writer(object):
    """The block as it grows: whole bytes, and bits in a byte put where its first bit came."""

    def __init__(self, head):
        self.out = bytearray(head)
        self.at = 0     # the bits' byte
        self.n = 0      # bits in it
        self.acc = 0

    def bits(self, value, count):
        out = self.out
        while count:
            if not self.n:
                self.at = len(out)
                out.append(0)
            take = 8 - self.n
            if take > count:
                take = count
            count -= take
            self.acc = self.acc << take | value >> count & ((1 << take) - 1)
            self.n += take
            if self.n == 8:
                out[self.at] = self.acc
                self.n = self.acc = 0

    def end(self):
        if self.n:
            self.out[self.at] = self.acc << 8 - self.n
            self.n = self.acc = 0

    def match(self, size, dist):
        """A match of size bytes from dist back (size 1: up to 8, 2: up to 256, else up to 65535)."""
        if size == 1:
            self.bits(-dist & 7, 6)
            return
        if size == 2:
            self.bits(1, 3)
            self.out.append(-dist & 0xFF)
            return
        if size == 3:
            self.bits(0b010, 3)
        elif size < 15:
            k = (size - 3) // 3 + 1
            self.bits(((1 << 2 * k) - 1) << 2 | (size - 3) % 3, 2 * k + 3)
        elif size == 15:
            self.bits(0x3FF, 11)
        else:
            self.bits(0b011001, 6)
            if size > 255:
                self.out.append(size >> 8)
            self.out.append(size & 0xFF)
        if dist <= 256:
            self.bits(1, 1)
        else:
            h = (0x10000 - dist) >> 8
            if h >= 0xFD:
                self.bits(0b0110 | h - 0xFD, 4)
            elif h >= 0xF9:
                self.bits(0b01000 | h - 0xF9, 5)
            elif h >= 0xF1:
                self.bits(0b001000 | h - 0xF1, 6)
            elif h >= 0xE2:
                self.bits(h - 0xE1, 7)
            else:
                self.bits(0, 7)
                self.out.append(h)
        self.out.append(-dist & 0xFF)

    def literals(self, data, start, count):
        """count bytes as they are: one by one below 12, else runs of 12..42 and an odd one by itself."""
        if count < RUN_MIN:
            for j in range(start, start + count):
                self.bits(1, 1)
                self.out.append(data[j])
            return
        self.bits(0b011000 << 4 | (count - RUN_MIN) >> 1, 10)
        self.out += data[start:start + (count & ~1)]
        if count & 1:
            self.bits(1, 1)
            self.out.append(data[start + count - 1])

    def finish(self):
        self.bits(0b011001, 6)
        self.out.append(0)
        self.end()
        size = len(self.out) - HEADER
        self.out[6:8] = bytes((size & 0xFF, size >> 8))
        return bytes(self.out)


def common(data, a, b, limit):
    """How many bytes from a and b are the same, up to limit."""
    k, step = 0, 16
    while k < limit:
        e = k + step
        if e > limit:
            e = limit
        x, y = data[a + k:a + e], data[b + k:b + e]
        if x != y:
            return e - ((int.from_bytes(x, 'big') ^ int.from_bytes(y, 'big')).bit_length() + 7 >> 3)
        k = e
        step <<= 1
    return limit


# QC 3.11's packer. Its search: chains of the earlier places with the same pair of bytes (the first one and 5 bits of
# the second), walked nearest first, each compared up to the longest match possible; a match of 2 is the nearest
# one if 256 back at most; of the longer ones the first longer than the best by 2 bytes or more is taken, by 1 only if
# its distance is not dear (DEAR) or the best's is, of 3 only if NEAR3; the first of 256 and more ends the walk. Here
# the same matches are found with rfind: the nearest place that matches one byte more than what could count. The
# places inside a match of 255 and more aren't in the chains. Lazy matching (the default: bit 3 of QC's settings):
# a match one byte on that is longer goes after the byte as it is; QC compares the sizes as bytes (255 for 255 and
# more), and so a match of 255 or more with none a byte on gives two bytes as they are.
# The block grows from OUT_BUFFER up and the file lies up to #FFFF: past #8200 bytes of both the packed data lies
# over the data already packed. The window then starts at the packed data's end (the place before it is taken too:
# its first byte is the chain's), and a byte read under it is the packed data's: if the packed data overtakes what is
# still to be read, the block is spoilt (Error).

def pack_qc(data, lazy=True):
    n = len(data)
    if not n:
        raise Error(EMPTY)
    if n <= TAIL:  # Pack needs the 6 bytes and one more (h took 2 sectors and more): stored, as oh2c does
        return stored(data)
    if n > QC_MAX:
        raise Error('%d bytes: QC 3.11 packs up to %d bytes (128 sectors): its packer works in the top 32K of the '
                    "Spectrum's memory" % (n, QC_MAX))
    end = n - TAIL
    w = Writer(b'hr2' + bytes((FLAGS, n & 0xFF, n >> 8, 0, 0)) + data[end:])
    out = w.out
    out.append(data[0])
    shift = MEET - n    # a place of the data -> the block's byte over it
    run = [-1] * n      # the start of the match of 255+ a place is inside (not in the chains), or -1
    rfind = data.rfind

    def under(j):
        """The byte the packer reads at place j: the data's, or the block's over it."""
        k = j + shift
        if k < len(out) and not (w.n and k == w.at):  # a bits' byte is written when full
            return out[k]
        return data[j]

    def search(i, most, low):
        """The match at i: (size, distance); size 0: none (or 1, below 8 back, if low)."""
        if i < len(out) - shift:
            raise Error(OVERTAKES)
        best = dist = 0
        if most >= 2:
            ws = i - WINDOW
            o = len(out) - shift
            if o > ws:
                ws = o
            lo = ws - 1 if ws > 0 else 0
            pair = data[i:i + 2]
            plo = i - 256 if i - 256 > lo else lo
            hi = i + 1
            while True:
                j = rfind(pair, plo, hi)
                if j < 0:
                    break
                if run[j] >= 0:
                    hi = run[j] + 2
                    continue
                best, dist = 2, i - j
                break
            need, top, dear = 3, i, False
            while need <= most:
                j = rfind(data[i:i + need], lo, top - 1 + need)
                if j < 0:
                    break
                if run[j] >= 0:
                    top = run[j] + 1
                    continue
                size = need + common(data, j + need, i + need, most - need)
                d = i - j
                if size >= 256:
                    return size, d
                if (size >= best + 2 or dear or d <= DEAR) and (size > 3 or d <= NEAR3):
                    best, dist, dear = size, d, d > DEAR
                need, top = size + 1, j
        if not best and low:
            x = data[i]
            for d in range(1, 9):
                if i - d < 0:
                    break
                if under(i - d) == x:
                    if data[i - d] != x:
                        raise Error(OVERTAKES)
                    return 1, d
        return best, dist

    def literals(start, count):
        if start + shift >= len(out) + 8:
            w.literals(data, start, count)
            return
        # near the block's end: each byte read as the packer reads it, after the bytes before it are written

        def put(j):
            if under(j) != data[j]:
                raise Error(OVERTAKES)
            out.append(data[j])
        if count < RUN_MIN:
            for j in range(start, start + count):
                w.bits(1, 1)
                put(j)
            return
        w.bits(0b011000 << 4 | (count - RUN_MIN) >> 1, 10)
        for j in range(start, start + (count & ~1)):
            put(j)
        if count & 1:
            w.bits(1, 1)
            put(start + count - 1)

    i, waiting, first = 1, 0, 0
    while i < end:
        left = end - i
        most = left if left < LONGEST else LONGEST
        size, dist = search(i, most, waiting < RUN_MIN)
        if lazy and size >= 2 and left > 1:
            size2, dist2 = search(i + 1, left - 1 if left - 1 < LONGEST else LONGEST, waiting < RUN_MIN)
            a, b = min(size, 255), min(size2, 255)  # QC compares them as bytes: 255 for 255 and more
            if (b - a - 1) & 0xFF == 0:  # one longer, or (a quirk) 255 and then nothing: the old distance
                later = dist > DEAR or (dist2 if size2 else dist) <= DEAR
            else:
                later = b > a
            if later:
                if not waiting:
                    first = i
                waiting += 1
                if waiting == RUN_MAX:
                    literals(first, waiting)
                    waiting = 0
                i, size, dist = i + 1, size2, dist2
        if not size:
            if not waiting:
                first = i
            waiting += 1
            if waiting == RUN_MAX:
                literals(first, waiting)
                waiting = 0
            i += 1
            continue
        if waiting:
            literals(first, waiting)
            waiting = 0
        w.match(size, dist)
        if size >= 255:
            run[i + 1:i + size] = [i] * (size - 1)
        i += size
    if waiting:
        literals(first, waiting)
    block = w.finish()
    if len(block) > QC_MAX:
        raise Error('the block would be %d bytes: QC 3.11 keeps only its first %d' % (len(block), QC_MAX))
    return block


# oh2c (Copyright (c) 2015-2020 Eugene Larchenko, the MIT licence: LICENSE-oh2c; this is a port of its compress.cpp):
# the cheapest stream, by dynamic programming from the end: cost[p] is the fewest bits from p to the end. At
# each place: a byte (9 bits), a run of 12..42 bytes (10 + 8 each), a match of each size at the nearest distance
# that has it (the bits of a distance grow with it), ties to the first tried. The nearest distances of the sizes come
# with rfind as in pack_qc (any distance: the whole data before); the cheapest size of a range that costs the same
# bits, from a table of the minima (sparse). If the stream isn't shorter than the data, they are stored.

SIZE_BITS = [(3, 3, 3), (4, 5, 5), (6, 8, 7), (9, 11, 9), (12, 15, 11), (16, 255, 14), (256, LONGEST, 22)]


def dist_bits(d):
    if d <= 256:
        return 9
    if d <= 768:
        return 12
    if d <= 1792:
        return 13
    if d <= 3840:
        return 14
    if d <= DEAR:
        return 15
    return 23


def stored(data):
    n = len(data)
    return b'hr2' + bytes((FLAGS | STORED, n & 0xFF, n >> 8, n & 0xFF, n >> 8)) + data


def pack_oh2c(data):
    n = len(data)
    if not n:
        raise Error(EMPTY)
    if n > OH2C_MAX:
        raise Error('%d bytes: oh2c packs up to %d' % (n, OH2C_MAX))
    if n < TAIL + 1:
        return stored(data)
    end = n - TAIL
    inp = data[:end]
    rfind = inp.rfind
    cost = [0] * (end + 1)
    op = [0] * (end + 1)        # size << 17 | distance; a run of bytes: -size; one byte: 0
    levels = max(1, min(LONGEST, end).bit_length())
    mins = [[0] * (end + 1) for _ in range(levels)]  # mins[k][j]: the least cost << 17 | place of j..j+2^k-1
    mins[0][end] = end
    for pos in range(end - 1, 0, -1):
        best = 9 + cost[pos + 1]
        what = 0
        c = RUN_MIN
        top = end - pos
        if top > RUN_MAX:
            top = RUN_MAX
        while c <= top:
            t = 10 + 8 * c + cost[pos + c]
            if t < best:
                best, what = t, -c
            c += 2
        most = end - pos
        if most > LONGEST:
            most = LONGEST
        done, need, below = 0, 1, pos
        while done < most:
            j = rfind(inp[pos:pos + need], 0, below - 1 + need)
            if j < 0:
                break
            size = need + common(inp, j + need, pos + need, most - need)
            d = pos - j
            # the sizes done+1..size at d
            if done < 1 and d <= 8:
                t = 6 + cost[pos + 1]
                if t < best:
                    best, what = t, 1 << 17 | d
            if done < 2 <= size and d <= 256:
                t = 11 + cost[pos + 2]
                if t < best:
                    best, what = t, 2 << 17 | d
            if size >= 3:
                db = dist_bits(d)
                for lo, hi, sb in SIZE_BITS:
                    if hi <= done:
                        continue
                    if lo > size:
                        break
                    a = pos + (lo if lo > done else done + 1)
                    b = pos + (hi if hi < size else size)
                    if b - a < 4:
                        m = cost[a] << 17 | a
                        for k in range(a + 1, b + 1):
                            v = cost[k] << 17 | k
                            if v < m:
                                m = v
                    else:
                        k = (b - a + 1).bit_length() - 1
                        m = mins[k][a]
                        v = mins[k][b - (1 << k) + 1]
                        if v < m:
                            m = v
                    t = sb + db + (m >> 17)
                    if t < best:
                        best, what = t, ((m & 0x1FFFF) - pos) << 17 | d
            done, need, below = size, size + 1, j
        cost[pos] = best
        op[pos] = what
        v = best << 17 | pos
        mins[0][pos] = v
        k, step = 1, 1
        while k < levels and pos + 2 * step <= end + 1:
            u = mins[k - 1][pos + step]
            if u < v:
                v = u
            mins[k][pos] = v
            k += 1
            step <<= 1
    size = HEADER + TAIL + (8 + cost[1] + 14 + 7) // 8
    if n + HEADER <= size or size > 0xFFFF:
        return stored(data)
    w = Writer(b'hr2' + bytes((FLAGS, n & 0xFF, n >> 8, 0, 0)) + data[end:])
    w.out.append(data[0])
    pos = 1
    while pos < end:
        what = op[pos]
        if what == 0:
            w.bits(1, 1)
            w.out.append(data[pos])
            pos += 1
        elif what < 0:
            w.literals(data, pos, -what)
            pos -= what
        else:
            w.match(what >> 17, what & 0x1FFFF)
            pos += what >> 17
    block = w.finish()
    assert len(block) == size
    return block


# Unpacking, as QC's depacker (QC's src/hrust/unpack.asm): the stream gives all but the last 6 bytes, then they
# follow. A stored block: its data as they are.

def unpack(block):
    if len(block) < HEADER or block[:3] != b'hr2':
        raise Error('not a Hrust 2 block (no "hr2" at its start)')
    size = block[4] | block[5] << 8
    length = block[6] | block[7] << 8
    if not size:  # QC 3.20's unpacker refuses it (and one of length 0: below)
        raise Error('an empty block (QC unpacks none)')
    if HEADER + length > len(block):
        raise Error('the block is cut: %d bytes of %d' % (len(block), HEADER + length))
    if block[3] & STORED:
        if length < size:
            raise Error('a stored block of %d bytes for %d' % (length, size))
        return bytes(block[HEADER:HEADER + size])
    if length < TAIL + 1 or size < TAIL:
        raise Error('a broken block: %d bytes for %d' % (length, size))
    src = block[:HEADER + length]
    out = bytearray()
    p = HEADER + TAIL
    st = [p, 0, 0]  # the stream's place, the bits' byte, the bits left in it

    def byte():
        i = st[0]
        if i >= len(src):
            raise Error('the stream is cut')
        st[0] = i + 1
        return src[i]

    def bits(count):
        v = 0
        for _ in range(count):
            if not st[2]:
                st[1], st[2] = byte(), 8
            st[2] -= 1
            v = v << 1 | st[1] >> st[2] & 1
        return v

    out.append(byte())
    limit = size - TAIL
    while True:
        if bits(1):
            out.append(byte())
        else:
            n = 1
            while True:  # the length's groups of 2 bits: 3 adds 3 and goes on, up to 16
                g = bits(2)
                if g < 3:
                    n += g
                    break
                n += 3
                if n == 16:
                    break
            if n == 4:
                if not bits(1):  # a run of bytes as they are
                    for _ in range(2 * (bits(4) + 6)):
                        out.append(byte())
                    if len(out) > limit:
                        break
                    continue
                n = byte()
                if n == 0:
                    break
                if n < 16:
                    n = n << 8 | byte()
            elif n > 4:
                n -= 1
            if n == 1:
                d = 8 - bits(3)
            elif n == 2:
                d = 256 - byte()
            elif bits(1):
                d = 256 - byte()
            else:
                h = (0xFF, 0xFD, 0xF9, 0xF1, 0xE1)[1 + (3 - bits(2))]
                h += bits({0xFD: 1, 0xF9: 2, 0xF1: 3, 0xE1: 4}[h])
                if h == 0xE1:
                    h = byte()
                d = 0x10000 - (h << 8 | byte())
            if d > len(out):
                raise Error('a match points before the start of the data')
            k = len(out) - d
            if d >= n:
                out += out[k:k + n]
            else:
                for j in range(k, k + n):
                    out.append(out[j])
        if len(out) > limit:
            break
    if len(out) != limit:
        raise Error('the stream gives %d bytes, the header says %d' % (len(out) + TAIL, size))
    return bytes(out) + bytes(block[HEADER:HEADER + TAIL])


# Files and names. A TR-DOS file is NAME.T on the PC (T, the type: the trdos plugin's names); QC 3.11's h named the
# packed one NAME.p_T: type p, "_" and T in the start field; n gave the type back.

def show_char(c):
    """A byte of a TR-DOS name in a PC name, as the trdos plugin writes it."""
    if c <= 0x20 or c in (0x25, 0x2F, 0x7F, 0xFF):
        return '%%%02X' % c
    return bytes([c]).decode('cp866') if c >= 0x80 else chr(c)


def hobeta(data):
    """A hobeta file -> (type, start, the data as the trdos plugin takes them), or None: the header's checksum."""
    if len(data) < 17 or (105 + 257 * sum(data[:15])) & 0xFFFF != data[15] | data[16] << 8:
        return None
    h = data[:15]
    start, length = h[9] | h[10] << 8, h[11] | h[12] << 8
    sectors = h[14] if h[13] == 0 else h[13] if h[14] == 0 else (h[13] + 256 * h[14] + 255) // 256
    if not sectors:  # none said: as many as the data fill
        sectors = (len(data) - 17 + 255) // 256
    if sectors > 255:
        return None
    body = data[17:17 + sectors * 256]
    n = start if h[8] == ord('B') else length  # BASIC: with its variables
    if (n + 255) // 256 != sectors and not (h[8] == ord('B') and (n + 4 + 255) // 256 == sectors):
        n = len(body)
    return h[8], start, body[:n]


def default_name(src, unpacking, hob):
    """OUTPUT's name by INPUT's: NAME.T <-> NAME.p_T, NAME.$T (hobeta) -> NAME.p_T, NAME.$p (_T) -> NAME.T."""
    folder, base = os.path.split(src)
    stem = base.rpartition('.')[0] if '.' in base[1:] else base
    if not unpacking:
        if hob:
            return os.path.join(folder, stem + '.p_' + show_char(hob[0]))
        if re.match(r'.+\.[^.]$', base):
            return os.path.join(folder, stem + '.p_' + base[-1])
        return src + '.hr2'
    if hob:
        if hob[0] == ord('p') and hob[1] & 0xFF == ord('_'):
            return os.path.join(folder, stem + '.' + show_char(hob[1] >> 8))
    else:
        m = re.match(r'(.+)\.p_([^.])$', base, re.I)
        if m:
            return os.path.join(folder, m.group(1) + '.' + m.group(2))
        if re.match(r'.+\.hr2$', base, re.I):
            return src[:-4]
    return src + '.dpk'


def show(name):
    return name.encode('utf-8', 'surrogateescape').decode('utf-8', 'replace')


def say(text):
    """A line on the standard output, what its encoding lacks as \\x.. (as on the standard error): it can't fail."""
    code = getattr(sys.stdout, 'encoding', None) or 'utf-8'
    sys.stdout.write(text.encode(code, 'backslashreplace').decode(code) + '\n')
    sys.stdout.flush()  # a closed pipe: the error here, not at exit


def write_output(name, data, force):
    if name == '-':
        sys.stdout.buffer.write(data)
        sys.stdout.buffer.flush()
        return
    existed = os.path.lexists(name)
    if existed and not force:
        raise Error('%s exists (-f overwrites it)' % show(name))
    try:
        with open(name, 'wb') as f:
            f.write(data)
    except BaseException:
        if not existed and os.path.isfile(name):  # no half-written file left
            os.unlink(name)
        raise


USAGE = '''usage: hrust [-f] [-q | -o] INPUT [OUTPUT]   pack
       hrust -d [-f] INPUT [OUTPUT]          unpack
Packs INPUT into a Hrust 2 block (hr2) as Quick Commander 3.11's h does (up to 32768 bytes), or unpacks one.
OUTPUT: game.C <-> game.p_C (QC's names), NAME <-> NAME.hr2, else INPUT.dpk; - is the standard output.
A hobeta INPUT (NAME.$T for packing) is taken by its header.
  -f  overwrite OUTPUT     -q  no lazy matching (QC's "simple pack": faster on the Spectrum, a little longer)
  -o  pack as oh2c does: the shortest block (any size up to 65535 bytes)
'''


def main(argv):
    args = argv[1:]
    if args and args[0] in ('-h', '--help'):
        sys.stdout.write(USAGE)
        return 0
    flags = set()
    while args and args[0] in ('-d', '-f', '-q', '-o'):
        flags.add(args.pop(0)[1])
    names = args and args[0] == '--'  # then -x is a name too (- still the standard output)
    if names:
        args.pop(0)
    if not 1 <= len(args) <= 2 or not names and any(a.startswith('-') and a != '-' for a in args) or \
            'q' in flags and 'o' in flags or 'd' in flags and flags & set('qo') or args[0] == '-':
        sys.stderr.write(USAGE)
        return 2
    src = args[0]
    try:
        with open(src, 'rb') as f:
            data = f.read()
        hob = None
        if 'd' in flags:  # hobeta first: its name may start with "hr2" too
            hob = hobeta(data)
            if hob and data[17:20] == b'hr2':
                data = data[17:]
            elif data[:3] == b'hr2':
                hob = None
            else:
                raise Error('%s: not a Hrust 2 block (no "hr2" at its start)' % show(src))
        elif re.match(r'.+\.\$[^.]$', os.path.basename(src)):
            hob = hobeta(data)
            if hob:
                data = hob[2]
        dest = args[1] if len(args) == 2 else default_name(src, 'd' in flags, hob)
        if dest != '-' and 'f' not in flags and os.path.lexists(dest):
            raise Error('%s exists (-f overwrites it)' % show(dest))
        try:
            if 'd' in flags:
                out = unpack(data)
            elif 'o' in flags:
                out = pack_oh2c(data)
            else:
                out = pack_qc(data, 'q' not in flags)
                if unpack(out) != data:
                    raise Error('the block does not unpack (a bug: please report it)')
        except Error as e:
            raise Error('%s: %s' % (show(src), e))
        write_output(dest, out, 'f' in flags)
        if dest != '-':
            note = ' (stored: the data does not pack)' if 'd' not in flags and out[3] & STORED else ''
            size = HEADER + (data[6] | data[7] << 8) if 'd' in flags else len(data)
            say('%s: %d -> %d bytes%s' % (show(dest), size, len(out), note))
    except (Error, OSError) as e:
        if isinstance(e, BrokenPipeError):  # the reader went away: no flush of the rest at exit, one line
            try:
                os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
            except (OSError, ValueError, AttributeError):
                pass
            e = 'the standard output: %s' % e.strerror
        elif isinstance(e, OSError) and e.filename is not None:
            e = '%s: %s' % (show(e.filename), e.strerror)
        sys.stderr.write('hrust: %s\n' % e)
        return 1
    except KeyboardInterrupt:
        sys.stderr.write('\nhrust: interrupted\n')
        return 1
    return 0


if __name__ == '__main__':
    sys.exit(main(sys.argv))
