#!/usr/bin/env python3
# hrum 1.0: packs and unpacks files with Hrum 3.5i, Hrumer's packer for the ZX Spectrum, byte for byte as its PC
# versions do: hrum35 1.1b (Nikita Burnashev, the depacker added by psb) and dehrum35 1.0 (Hrumer & HalfElf, psb).
# Copyright (c) 2026 Spectre (Optical Brothers), https://www.zxby.org. MIT License (see LICENSE).
#
#   hrum [-opt] [-ei | -di] [-start N] [-depackto N] [-dadr N] [-sp N] [-jp N] [-pad N | -pad auto] [-f]
#        INPUT [OUTPUT]                packs
#   hrum -d [-f] INPUT [OUTPUT]        unpacks
#
# A packed file is a block to load and run on the Spectrum: the depacker (#91 bytes, the addresses put in), the
# input's last 5 bytes as they are, #10 #10 and the packed data. -pad adds zeros to the input first, for blocks that
# would not unpack in place; the rest is hrum35's. See README.md.

import os
import sys

USAGE = '''usage: hrum [-opt] [-ei | -di] [-start N] [-depackto N] [-dadr N] [-sp N] [-jp N] [-pad N | -pad auto]
            [-f] INPUT [OUTPUT]
       hrum -d [-f] INPUT [OUTPUT]

Packs INPUT (7..49033 bytes) into a self-extracting block for the ZX Spectrum, as hrum35 1.1b does;
-d unpacks such a block, as dehrum35 1.0 does. OUTPUT: INPUT.hrm when packing; when unpacking, INPUT
without its .hrm, or INPUT.dpk. "-" is stdin or stdout. An OUTPUT that exists is kept, unless -f.

  -start N     where the block is loaded and run (default #6000)
  -depackto N  where the data unpacks to (#6000)
  -dadr N      where the depacker copies its working part, #77 bytes (#5B89)
  -sp N        the SP to set when done (default: the caller's)
  -jp N        jump to N when done (default: return)
  -ei, -di     interrupts on or off when done (-ei)
  -opt         put the bytes of 255-byte matches into the dictionary too (rarely changes anything)
  -pad N       add N zero bytes to the data's end before packing, for a block that would not unpack in
               place (hrum warns). They unpack too: N bytes after the data get zeros on the Spectrum.
  -pad auto    add as many as the block needs: none if it unpacks as it is
N: 24576, #6000, $6000 or 0x6000.
'''

# psb's depacker as hrum35.cpp has it; patch() puts the addresses in. It is run at its first byte: it keeps SP,
# copies its part from #1F and the 5 bytes after it (#77 bytes) to dadr and goes on there: moves the packed data to
# the end of the place it unpacks to, unpacks it reading it with POP (interrupts off), puts the 5 bytes after it,
# sets SP, EI or DI, RET or JP.
DEPACKER = bytes.fromhex(
    'F3ED738B80211F8011895B017700D5ED'
    'B0110080D9215094114FBA01BB13C9ED'
    'B816033195A6C1E13BF1D91213D92910'
    '02E14138F31E013E80291002E1411738'
    'F8BA3805835FA920EE83BA3F30099A28'
    '288A291002E141D94F26FF3D3E3F2804'
    '300FCBAFD9291002E1411730F8D92803'
    '673BF16F19EDB018B43BF1A720D4D921'
    'FB5B0E05EDB0215827D9310000FBC900'
    '00')
TAIL = 5            # the input's last bytes: they go as they are
HEADER = len(DEPACKER) + TAIL + 2  # then #10 #10 (the depacker's bit counters) and the packed data
COPY = 0x77         # the depacker's part copied to dadr: from #1F, with the 5 bytes
MIN_INPUT = 7
MAX_INPUT = 49033   # hrum35's limit: 48K less the copy
MIN_BLOCK = HEADER + 4  # dehrum35's: a bit word, a byte and the end
WINDOW = 4096
LONGEST = 255

# The places in the depacker of what patch() puts in (words but 0x20, 0x8D and 0x8E).
SAVE_SP = 0x03      # LD (nn),SP: start+#8B, where LD SP,nn takes it back; 0 with -sp
FROM = 0x06         # start+#1F, what is copied to dadr
DADR = 0x09
DEPACKTO = 0x12
MOVE_FROM = 0x16    # the packed data's last byte (LDDR) or first (LDIR)
MOVE_TO = 0x19      # where it goes: the last byte of the place it unpacks to, or the first
LENGTH = 0x1C       # the packed data's length, #10 #10 with it
MOVE = 0x20         # #B8 LDDR, #B0 LDIR
STREAM = 0x24       # LD SP,nn: where the moved packed data starts
TAIL_AT = 0x80      # dadr+#72, where the 5 bytes are taken from
NEW_SP = 0x8B
INTS = 0x8D         # #FB EI, #F3 DI
EXIT = 0x8E         # #C9 RET or #C3 JP nn
LDDR, LDIR = 0xB8, 0xB0


class Error(Exception):
    pass


def word(data, offset):
    return data[offset] | data[offset + 1] << 8


def put_word(data, offset, value):
    data[offset] = value & 0xFF
    data[offset + 1] = value >> 8 & 0xFF


# Packing. The bits go in 16-bit words (the low byte first), a word's top bit first; the next word's place comes as
# soon as the one before is full, and the bytes go between them. The first byte goes as it is, no bit for it; then
#   1 BYTE      a byte as it is
#   0 LEN ...   bytes from back. LEN: pairs of bits summed while a pair is 11 and the sum below 15. 0: 1 byte, ddd
#               1..8 back; 1: 2 bytes, BYTE 1..256 back; 2: 3 bytes; 4..15: as many bytes; 3 (11 00): COUNT, 16..255
#               bytes, 0 the end. From 3 bytes on, DIST: 0 BYTE 1..256 back, 1 dddd BYTE 1..4096 back (the bits of
#               the minus distance: dddd 11..8, BYTE 7..0).

def match_code(n):
    """The bits of a match of 3..15 bytes, its 0 first: (value, count)."""
    if n == 3:
        return 0b010, 3
    if n == 15:
        return 0b01111111111, 11
    value, count = 0, 1
    while n >= 3:
        value, count, n = value << 2 | 3, count + 2, n - 3
    return value << 2 | n, count + 2


MATCH_CODES = dict((n, match_code(n)) for n in range(3, 16))


class Packer(object):
    """hrum35's compress(), which can be stopped, copied and resumed.

    hrum35 parses greedily: at each place the longest match (up to 255 bytes, within 8 back for 1 byte, 256 for 2,
    4096 for more; the nearest of the longest), else a byte. Its dictionary is a chain of the earlier places with the
    same first byte; here the same places are found with rfind, which gives the same matches many times faster. One
    quirk is kept: after a 255-byte match the places inside it are not put into the dictionary (unless opt).

    A place's match depends on the 255 bytes from it on, so the places more than 255 bytes before the end pack the same
    whatever follows the data: padding() packs them once, as the count of zeros it tries grows.

    ahead: how far the depacker's output runs ahead of its reading, max(written - read + 1) over the bytes it writes,
    both counted from where they start. Unpacking in place, the packed data (#10 #10 with it) ends where the data does,
    so the output never meets packed data still to be read when ahead <= len(data) - len(#10 #10 + packed data)."""

    def __init__(self, data, opt=False):
        self.data, self.opt = data, opt
        self.out = bytearray(2)  # the first bit word's place
        self.out.append(data[0])
        self.word, self.bits = 0, 1  # the bit word's place in out; its bits so far, a 1 above them
        self.run = None  # run[j]: the first place of the 255-byte match place j is inside (not in the dictionary)
        self.i = 1
        self.ahead = 1 - (len(self.out) + 2)  # the first byte: read (with #10 #10 and a bit word) and written

    def copy(self):
        c = Packer.__new__(Packer)
        c.__dict__.update(self.__dict__)
        c.out = bytearray(self.out)
        c.run = None if self.run is None else list(self.run)
        return c

    def go(self, end, until=None):
        """Packs the places from self.i on, below end (the 5 bytes from end on go as they are), up to until."""
        data, opt, out, run, i, ahead = self.data, self.opt, self.out, self.run, self.i, self.ahead
        word, bits = self.word, self.bits
        if run is not None and len(run) < end:
            run.extend([-1] * (end - len(run)))
        last = end - 1 if until is None else min(end - 1, until)

        def put(value, count):
            nonlocal word, bits
            bits = bits << count | value
            if bits >= 0x10000:  # the word is full: in its place, the next one's here
                extra = bits.bit_length() - 17
                full = bits >> extra
                out[word] = full & 0xFF
                out[word + 1] = full >> 8 & 0xFF
                word = len(out)
                out.extend(b'\0\0')
                bits = 1 << extra | bits & ((1 << extra) - 1)

        rfind = data.rfind
        while i <= last:
            left = end - i
            size = 0
            if left >= 3:  # 3 bytes and more: look for one longer than the longest found, farther than it
                most = left if left < LONGEST else LONGEST
                lo = i - WINDOW if i > WINDOW else 0
                n, hi = 2, i + 2
                while True:
                    j = rfind(data[i:i + n + 1], lo, hi)
                    if j < 0:
                        break
                    if run is not None and run[j] >= 0:
                        hi = run[j] + n
                        continue
                    n += 1
                    while n < most and data[j + n] == data[i + n]:
                        n += 1
                    size, dist = n, i - j
                    if n == most:
                        break
                    hi = j + n
            if not size and left >= 2:
                lo, hi = (i - 256 if i > 256 else 0), i + 1
                pattern = data[i:i + 2]
                while True:
                    j = rfind(pattern, lo, hi)
                    if j < 0:
                        break
                    if run is not None and run[j] >= 0:
                        hi = run[j] + 1
                        continue
                    size, dist = 2, i - j
                    break
            if not size:
                lo, hi = (i - 8 if i > 8 else 0), i
                pattern = data[i:i + 1]
                while True:
                    j = rfind(pattern, lo, hi)
                    if j < 0:
                        break
                    if run is not None and run[j] >= 0:
                        hi = run[j]
                        continue
                    size, dist = 1, i - j
                    break

            if size == 0:
                put(1, 1)
                out.append(data[i])
                size = 1
            elif size == 1:
                put(-dist & 7, 6)
            elif size == 2:
                put(0b001, 3)
                out.append(-dist & 0xFF)
            else:
                if size < 16:
                    put(*MATCH_CODES[size])
                else:
                    put(0b01100, 5)
                    out.append(size)
                if dist <= 256:
                    put(0, 1)
                else:
                    put(0x10 | -dist >> 8 & 0xF, 5)
                out.append(-dist & 0xFF)
                if size == LONGEST and not opt:
                    if run is None:
                        run = [-1] * end
                    run[i + 1:i + size] = [i + 1] * (size - 1)
            # The depacker has read what is in out (and #10 #10) when it writes the token's bytes.
            if i + size - len(out) - 2 > ahead:
                ahead = i + size - len(out) - 2
            i += size
        self.run, self.i, self.ahead, self.word, self.bits = run, i, ahead, word, bits

    def finish(self):
        """The end mark and the last bit word: the packed data."""
        out, bits = self.out, self.bits << 5 | 0b01100
        if bits >= 0x10000:
            extra = bits.bit_length() - 17
            full = bits >> extra
            out[self.word] = full & 0xFF
            out[self.word + 1] = full >> 8 & 0xFF
            self.word = len(out)
            out.extend(b'\0\0')
            bits = 1 << extra | bits & ((1 << extra) - 1)
        out.append(0)
        if bits != 1:  # the last word: its bits to the top
            bits <<= 17 - bits.bit_length()
            out[self.word] = bits & 0xFF
            out[self.word + 1] = bits >> 8 & 0xFF
        return bytes(out)


def compress(data, opt=False):
    """The packed data of data but its last 5 bytes, as hrum35's compress() makes it."""
    p = Packer(data, opt)
    p.go(len(data) - TAIL)
    return p.finish()


def patch(start, depackto, dadr, sp, ei, jp, packed, unpacked):
    """The depacker for the block (hrum35's dehrum_patch). packed: the packed data's length with #10 #10; unpacked:
    the input's less the 5 bytes. sp, jp: None for none. The addresses are as hrum35 counts them, past #FFFF too."""
    d = bytearray(DEPACKER)
    packed_first = start + len(DEPACKER) + TAIL
    packed_last = packed_first + packed - 1
    unpacked_last = depackto + unpacked - 1 + TAIL
    moved = unpacked_last - packed + 1
    if sp is None:
        put_word(d, SAVE_SP, start + NEW_SP)
    else:
        put_word(d, SAVE_SP, 0)
        put_word(d, NEW_SP, sp)
    put_word(d, FROM, start + 0x1F)
    put_word(d, DADR, dadr)
    put_word(d, DEPACKTO, depackto)
    if packed_last <= unpacked_last:  # moved up, from its end
        put_word(d, MOVE_FROM, packed_last)
        put_word(d, MOVE_TO, unpacked_last)
    else:
        put_word(d, MOVE_FROM, packed_first)
        put_word(d, MOVE_TO, moved)
        d[MOVE] = LDIR
    put_word(d, STREAM, moved)
    put_word(d, LENGTH, packed)
    put_word(d, TAIL_AT, dadr + 0x72)
    d[INTS] = 0xFB if ei else 0xF3
    if jp is not None:
        d[EXIT] = 0xC3
        put_word(d, EXIT + 1, jp)
    return bytes(d)


def pack(data, start=0x6000, depackto=0x6000, dadr=0x5B89, sp=None, jp=None, ei=True, opt=False, packed=None):
    """The packed block of data; packed: its packed data (with #10 #10) if already made."""
    if len(data) < MIN_INPUT:
        raise Error('%d bytes: too short to pack, %d at least' % (len(data), MIN_INPUT))
    if len(data) > MAX_INPUT:
        raise Error('%d bytes: too big, Hrum packs %d at most' % (len(data), MAX_INPUT))
    # Data that does not pack may give more than hrum35's buffer of 48K: it overflows it, and either crashes or
    # (when the overflow lands on the input already read) gives just this.
    if packed is None:
        packed = b'\x10\x10' + compress(data, opt)
    head = patch(start, depackto, dadr, sp, ei, jp, len(packed), len(data) - TAIL)
    return head + data[-TAIL:] + packed


# Unpacking.

def depack(mem, i, o, end, limit):
    """Unpacks the packed data at mem[i] (after #10 #10) to mem[o], as the depacker does: reading below end, writing
    below limit. The packed data and the output may be in the same place (unpacking in place). Returns the end of the
    output."""
    first = o
    at = [i]
    bits = [0, 16]  # the word, the bits left in it

    def byte():
        i = at[0]
        if i >= end:
            raise Error('the packed data is cut short')
        at[0] = i + 1
        return mem[i]

    def bit():
        bits[1] -= 1
        b = bits[0] >> bits[1] & 1
        if not bits[1]:  # the next word is taken as soon as the last bit is out, before any byte
            bits[0] = byte() | byte() << 8
            bits[1] = 16
        return b

    bits[0] = byte() | byte() << 8
    if o >= limit:
        raise Error('the data unpacks past the memory')
    mem[o] = byte()
    o += 1
    while True:
        if bit():
            if o >= limit:
                raise Error('the data unpacks past the memory')
            mem[o] = byte()
            o += 1
            continue
        n = 1
        while True:
            two = bit() << 1 | bit()
            n += two
            if two != 3 or n == 16:
                break
        if n == 4:
            n = byte()
            if n == 0:
                return o
            back = distance(bit, byte)
        else:
            if n > 4:
                n -= 1
            if n == 1:
                back = 8 - (bit() << 2 | bit() << 1 | bit())
            elif n == 2:
                back = 256 - byte()
            else:
                back = distance(bit, byte)
        if o - back < first:
            raise Error('a match points before the start of the data')
        if o + n > limit:
            raise Error('the data unpacks past the memory')
        if back >= n:
            mem[o:o + n] = mem[o - back:o - back + n]
        else:
            for k in range(o, o + n):
                mem[k] = mem[k - back]
        o += n


def distance(bit, byte):
    """A match's distance back: 0 BYTE for 1..256, 1 dddd BYTE for up to 4096."""
    if not bit():
        return 256 - byte()
    high = bit() << 3 | bit() << 2 | bit() << 1 | bit()
    return 0x10000 - ((0xF0 | high) << 8 | byte())


def unpacked_size(block):
    """The data's size as dehrum35 takes it from the depacker, or None if block is not a packed block. dehrum35 also
    refuses files of 48K and more, which hrum35 makes of big data that does not pack, and blocks whose data runs past
    #FFFF, whose addresses hrum35 cuts to 16 bits (any data over 40960 bytes that packs, at the default addresses);
    this takes them: the size modulo #10000."""
    if len(block) < MIN_BLOCK:
        return None
    if (block[5], block[8], block[11:16], block[HEADER - 2:HEADER]) != (0x21, 0x11, b'\x01\x77\x00\xd5\xed',
                                                                       b'\x10\x10'):
        return None
    packed = word(block, LENGTH)
    unpacked = word(block, MOVE_TO) - word(block, DEPACKTO) + (1 if block[MOVE] == LDDR else packed) & 0xFFFF
    if packed + TAIL < 11 or not MIN_INPUT <= unpacked <= MAX_INPUT:
        return None
    return unpacked


def unpack(block):
    """The data of a packed block -> (data, warning or None). Like dehrum35, the size is the depacker's: a block whose
    packed data gives more is cut, one that gives less is filled with zeros."""
    size = unpacked_size(block)
    if size is None:
        raise Error('not a Hrum 3.5i block (no depacker at its start)')
    mem = bytearray(block) + bytearray(0x10000)
    o = depack(mem, HEADER, len(block), len(block), len(mem))
    data = bytes(mem[len(block):o]) + block[len(DEPACKER):len(DEPACKER) + TAIL]
    note = None
    if len(data) != size:
        note = 'the packed data gives %d bytes, the depacker says %d' % (len(data), size)
    return data[:size] + bytes(max(0, size - len(data))), note


def describe(block):
    """What the depacker of a block does, as text."""
    start = word(block, FROM) - 0x1F & 0xFFFF
    text = 'for #%04X, unpacks to #%04X, depacker at #%04X' % (start, word(block, DEPACKTO), word(block, DADR))
    if word(block, SAVE_SP) == 0 and start + NEW_SP & 0xFFFF:
        text += ', SP #%04X' % word(block, NEW_SP)
    text += ', EI' if block[INTS] == 0xFB else ', DI'
    text += ', JP #%04X' % word(block, EXIT + 1) if block[EXIT] == 0xC3 else ', RET'
    return text


def check_layout(data, block, start, depackto, dadr):
    """What will go wrong when the block runs on the Spectrum, as a list of texts: the places past #FFFF or in the ROM,
    the depacker where the data goes, and the data overwriting the packed data before it is read."""
    size, packed = len(data), len(block) - HEADER + 2
    moved = depackto + size - packed  # the depacker moves the packed data to end where the data ends
    places = [('the block', start, len(block)), ('the unpacked data', depackto, size),
              ('the depacker', dadr, COPY), ('the packed data, moved before unpacking,', moved, packed)]
    out = []
    for what, at, n in places:
        if at + n > 0x10000:
            out.append('%s (#%04X, %d bytes) runs past #FFFF' % (what, at, n))
        elif at < 0x4000:
            out.append('%s (#%04X) is in the ROM' % (what, at & 0xFFFF))
    if out:
        return out

    def meets(a, n, b, m):
        return a < b + m and b < a + n

    low = min(moved, depackto)
    if meets(dadr, COPY, low, depackto + size - low):
        out.append('the depacker (#%04X-#%04X) is where the data unpacks to (#%04X-#%04X)' % (
            dadr, dadr + COPY - 1, low, depackto + size - 1))
    # The copy (LDIR from start+#1F) must not write over what is still to run or to read: its source ahead of it (when
    # copied up), the LDIR at start+#0F while it repeats (so all but the last byte), the code after it to the RET at
    # start+#1E (unless the copy starts there: the RET goes there anyway), and the packed data, moved after. What ran
    # before it may go.
    first = start + 0x1F
    if dadr not in (first, first - 1) and (
            first < dadr < first + COPY or meets(dadr, COPY - 1, start + 0x0F, 2)
            or meets(dadr, COPY, start + 0x11, 0x0E) or meets(dadr, COPY, start + HEADER - 2, packed)):
        out.append('the depacker copied to #%04X overwrites the block (#%04X-#%04X) while it runs' % (
            dadr, start, start + len(block) - 1))
    if not out and not unpacks_in_place(data, block[HEADER - 2:], depackto):
        out.append(OVERRUN)
    return out


OVERRUN = 'the data overwrites the packed data before it is read: the block will not unpack'


def unpacks_in_place(data, packed, depackto):
    """Whether the depacker gets the data right unpacking it in place: the packed data (#10 #10 with it) moved to end
    where the data ends, unpacked from depackto, both in one 64K memory, as on the Spectrum."""
    moved = depackto + len(data) - len(packed)
    if moved < 0 or depackto + len(data) > 0x10000:
        return False
    mem = bytearray(0x10000)
    mem[moved:moved + len(packed)] = packed
    try:
        o = depack(mem, moved + 2, depackto, moved + len(packed), 0x10000)
    except Error:
        return False
    return mem[depackto:o] == data[:-TAIL]


def padding(data, opt, depackto):
    """The zero bytes to add to data's end for a block that unpacks in place -> (their count, the packed data of
    data with them, #10 #10 first), or None if no count does it within 49033 bytes and below #10000. None are added if
    the block unpacks as it is; else the fewest with which the depacker never writes over packed data it has not read
    yet."""
    end = len(data) - TAIL
    most = min(MAX_INPUT, 0x10000 - depackto) - len(data)
    fixed = Packer(data + bytes(max(0, most)), opt)
    fixed.go(end, end - LONGEST)  # the places that pack the same whatever follows
    p = fixed.copy()
    p.go(end)
    packed = b'\x10\x10' + p.finish()
    if unpacks_in_place(data, packed, depackto):
        return 0, packed
    # Every count is tried from 1 on, but those that cannot do. With n zeros, the places more than 255 bytes before
    # the end pack the same for every greater count (fixed packs them as n grows), and their ahead stays: a count needs
    # room for their packed bytes, #10 #10 and the end's 0. The packed data does not only grow with the count (a
    # match's length may take fewer bits than a shorter one's), so the rest are tried one by one.
    n = 1
    while n <= most:
        fixed.go(end + n, end + n - LONGEST)
        least = fixed.ahead - len(data) + len(fixed.out) + 3
        if n < least:
            n = least
            continue
        p = fixed.copy()
        p.go(end + n)
        packed = b'\x10\x10' + p.finish()
        if p.ahead <= len(data) + n - len(packed):
            return n, packed
        n += 1
    return None


# The command.

def zeros(n):
    return '%d zero byte%s' % (n, '' if n == 1 else 's')


def pack_command(o, data, src, dest):
    """Packs as the options say -> (the block, the warnings, the report)."""
    pad, to, packed, found = o['pad'], o['depackto'], None, False  # found: padding()'s, False if not asked yet
    if pad == 'auto':
        found = padding(data, o['opt'], to) if MIN_INPUT <= len(data) <= MAX_INPUT else None
        pad, packed = found or (0, None)
    if len(data) + pad > MAX_INPUT:
        raise Error('%d bytes with the %d of -pad: too big, Hrum packs %d at most' % (len(data) + pad, pad, MAX_INPUT))
    padded = data + bytes(pad)
    out = pack(padded, o['start'], to, o['dadr'], o['sp'], o['jp'], o['ei'], o['opt'], packed)
    warnings = check_layout(padded, out, o['start'], to, o['dadr'])
    if OVERRUN in warnings:
        if found is False:
            found = padding(data, o['opt'], to)
        if found is None:
            fix = 'no -pad fixes it (within %d bytes and #FFFF)' % MAX_INPUT
        else:
            fix = '-pad %d (or -pad auto) fixes it: %s after the data, which unpack too (#%04X-#%04X)' % (
                found[0], zeros(found[0]), to + len(data), to + len(data) + found[0] - 1)
        warnings[warnings.index(OVERRUN)] = OVERRUN + '; ' + fix
    ratio = (1 - len(out) / len(padded)) * 100  # as hrum35 counts it
    report = '%s -> %s: %d%s -> %d bytes (%.2f%% %s)' % (
        src, dest, len(data), ' + ' + zeros(pad) if pad else '', len(out), abs(ratio),
        'saved' if ratio >= 0 else 'more')
    if pad:
        report += '; the zeros unpack to #%04X-#%04X' % (to + len(data), to + len(data) + pad - 1)
    return out, warnings, report


def number(text, what):
    """24576, #6000, $6000 or 0x6000."""
    t = text.strip()
    try:
        if t[:1] in ('#', '$'):
            n = int(t[1:], 16)
        elif t[:2].lower() == '0x':
            n = int(t[2:], 16)
        else:
            n = int(t, 10)
    except ValueError:
        raise Error('%s: not a number: %s' % (what, text))
    if not 0 <= n <= 0xFFFF:
        raise Error('%s: %s is out of 0..65535' % (what, text))
    return n


def default_output(name, unpacking):
    if name == '-':
        return '-'
    if not unpacking:
        return name + '.hrm'
    if name.lower().endswith('.hrm') and len(os.path.basename(name)) > 4:
        return name[:-4]
    return name + '.dpk'


def read_input(name):
    if name == '-':
        return sys.stdin.buffer.read()
    with open(name, 'rb') as f:
        return f.read()


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)' % 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


def parse(args):
    """argv -> a dict of the options and the names; raises ValueError for bad usage."""
    o = {'unpack': False, 'force': False, 'opt': False, 'ei': True, 'start': 0x6000, 'depackto': 0x6000,
         'dadr': 0x5B89, 'sp': None, 'jp': None, 'pad': 0, 'names': [], 'packing': []}
    addresses = ('-start', '-depackto', '-dadr', '-sp', '-jp')
    i = 0
    while i < len(args):
        a = args[i]
        i += 1
        if a == '--':
            o['names'] += args[i:]
            break
        if a == '-' or not a.startswith('-'):
            o['names'].append(a)
        elif a == '-d':
            o['unpack'] = True
        elif a == '-f':
            o['force'] = True
        elif a in ('-opt', '-ei', '-di'):
            o['packing'].append(a)
            if a == '-opt':
                o['opt'] = True
            else:
                o['ei'] = a == '-ei'
        elif a == '-pad':
            if i >= len(args):
                raise ValueError('-pad needs a count or auto')
            o['packing'].append(a)
            o['pad'] = 'auto' if args[i].strip().lower() == 'auto' else number(args[i], a)
            i += 1
        elif a in addresses:
            if i >= len(args):
                raise ValueError('%s needs an address' % a)
            o['packing'].append(a)
            value = args[i]
            i += 1
            if a in ('-sp', '-jp') and value.strip().startswith('-'):  # as hrum35: below 0 is none
                number(value.strip()[1:], a)
                o[a[1:]] = None
            else:
                o[a[1:]] = number(value, a)
        else:
            raise ValueError('unknown option %s' % a)
    if not 1 <= len(o['names']) <= 2:
        raise ValueError('one INPUT and maybe an OUTPUT, please')
    if o['unpack'] and o['packing']:
        raise ValueError('%s is for packing, not with -d' % o['packing'][0])
    return o


def main(argv):
    options = argv[1:]
    if '--' in options:  # after it, names
        options = options[:options.index('--')]
    if '-h' in options or '--help' in options:
        sys.stdout.write(USAGE)
        return 0
    try:
        o = parse(argv[1:])
    except (ValueError, Error) as e:
        sys.stderr.write('hrum: %s\n%s' % (e, USAGE.split('\n\n')[0] + '\n'))
        return 2
    src = o['names'][0]
    dest = o['names'][1] if len(o['names']) == 2 else default_output(src, o['unpack'])
    try:
        if dest != '-' and os.path.lexists(dest) and not o['force']:
            raise Error('%s exists (-f overwrites it)' % dest)
        data = read_input(src)
        try:
            if o['unpack']:
                out, note = unpack(data)
                warnings = [note] if note else []
                report = '%s -> %s: %d -> %d bytes (%s)' % (src, dest, len(data), len(out), describe(data))
            else:
                out, warnings, report = pack_command(o, data, src, dest)
        except Error as e:
            raise Error('%s: %s' % (src, e))
        for w in warnings:
            sys.stderr.write('hrum: warning: %s: %s\n' % (src, w))
        write_output(dest, out, o['force'])
    except OSError as e:
        sys.stderr.write('hrum: %s\n' % ('%s: %s' % (e.filename, e.strerror) if e.filename else e))
        return 1
    except Error as e:
        sys.stderr.write('hrum: %s\n' % e)
        return 1
    if dest != '-':
        sys.stderr.write(report + '\n')
    return 0


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