joeylib2/tools/vgm2song.py

577 lines
22 KiB
Python
Executable file

#!/usr/bin/env python3
# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
# See LICENSE.md for license information.
# SPDX-License-Identifier: MIT
"""vgm2song.py - a VGM chip log as a JoeyLib .song, for tools/songbake.
JoeyLib's chip tracker (jlMusicPlay) plays three square tone voices, each a
frequency and a 4-bit attenuation (2 dB steps), and a noise voice, a pitch
0..31 (the YM2149's noise period on the Atari ST's 2 MHz chip) and an
attenuation. A VGM file is a chip's register writes and the waits between
them, which a tracker exports as its song plays out: Furnace (the
recommended tracker, docs/Assets.adoc) does, as do most others. Write the
song for one of these and export VGM:
TI SN76489 (Sega Master System, BBC Micro, TI-99/4A): three tones and a
noise channel, as JoeyLib has; the attenuation carries over as it is.
Its noise runs at one of three rates or tone 2's, taken to the noise
pitch nearest that rate. Its "periodic" noise is a pitched pulse (the
noise rate over the shift register's width: the Master System's bass
trick), so it plays on a tone voice that is free, at that pitch; with
all three busy it plays as noise, with a warning. A chip without the
/8 prescaler (the VGM's flag says so, or its clock is under
SN_DIRECT_CLOCK_MAX: the TI-99/4A's 447 kHz SN94624, whose files
Furnace leaves unflagged) divides its clock by 4 where the SN76489
divides by 32.
AY-3-8910 / YM2149 (the Atari ST's chip): three tones; its noise mixes into
the tone channels, so the noise voice sounds while any channel that has
it enabled sounds, at the loudest of them. Volume v is attenuation
15 - v (exact on the ST). The volume envelope is followed tick by tick
(its period and shape, restarted by each write to its shape); one that
repeats faster than two rows is a timbre (the "buzzer"), not a volume:
it plays at full volume, and a channel whose tone is off sounds at the
envelope's own frequency, as the buzzer does, with a warning.
The song is the chip's state at the end of each tick: one .song row a tick
(its tickms), sampled from the VGM's own timing, so the length and the loop
point are kept to within a tick and nothing drifts. The default tick is the
VGM's update interval rounded down to whole milliseconds (16 for 60 Hz, 20
for 50 Hz), short enough that every update keeps a row of its own: its
header's rate, or the shortest gap between writes that comes more than once
when that is shorter (a song whose engine outruns the system's rate). A row
holds what changed since the one before it; the loop row restates every
voice, so a loop starts from the right state. Runs of empty rows become
repeat blocks. A tone too high to hear (above MAX_AUDIBLE_HZ: the chips' PCM
trick) is silence.
vgm2song.py [--chip sn|ay] [--tick MS] IN.vgm OUT.song
IN may be gzip-compressed (.vgz). --chip picks the chip when the VGM drives
both; only the first of a pair of chips is converted, and any other chip
(the Master System's FM unit, say) is left out, with a warning. The JYM1
stream songbake makes must stay under 64 KB: without --tick, a song that
would not fit gets the shortest tick, a whole multiple of the default, that
does (merging changes faster than it: vibrato, arpeggios), with a warning;
--tick is taken as it is.
"""
import argparse
import gzip
import math
import struct
import sys
VGM_IDENT = b"Vgm "
VGM_RATE = 44100
VGM_HEADER_MIN = 0x40
VGM_DATA_OFFSET_FIELD = 0x34
VGM_DATA_OFFSET_V150 = 0x150
VGM_AY_CLOCK_FIELD = 0x74
VGM_AY_CLOCK_V151 = 0x151
VGM_RATE_FIELD = 0x24
VGM_CLOCK_MASK = 0x3FFFFFFF
VGM_DUAL_CHIP = 0x40000000
GZIP_MAGIC = b"\x1f\x8b"
GD3_IDENT = b"Gd3 "
GD3_TITLE = 0
GD3_AUTHOR = 6
CMD_SN = 0x50
CMD_SN2 = 0x30
# Writes to other chips: two-byte register writes (YM2413 through YM2612,
# YMF262...) and the others' ranges.
OTHER_CHIP_RANGES = ((0x51, 0x5F), (0xA1, 0xBF), (0xC0, 0xDF), (0xE1, 0xFF))
CMD_AY = 0xA0
CMD_WAIT = 0x61
CMD_WAIT_NTSC = 0x62
CMD_WAIT_PAL = 0x63
CMD_END = 0x66
CMD_DATA_BLOCK = 0x67
WAIT_NTSC = 735
WAIT_PAL = 882
AY_SECOND_CHIP = 0x80
# Operand bytes of every command but the waits, end and data blocks, by
# range (VGM 1.71).
COMMAND_LENGTHS = (
(0x30, 0x3F, 1), (0x40, 0x4E, 2), (0x4F, 0x50, 1), (0x51, 0x5F, 2),
(0x64, 0x64, 3), (0x68, 0x68, 11), (0x70, 0x8F, 0), (0x90, 0x91, 4),
(0x92, 0x92, 5), (0x93, 0x93, 10), (0x94, 0x94, 1), (0x95, 0x95, 4),
(0xA0, 0xBF, 2), (0xC0, 0xDF, 3), (0xE0, 0xFF, 4),
)
SHORT_WAIT_FIRST = 0x70
SHORT_WAIT_LAST = 0x7F
DAC_WAIT_FIRST = 0x80
DAC_WAIT_LAST = 0x8F
# JoeyLib's voices (tools/songbake/songbake.py, src/core/music.c).
TONE_VOICES = 3
ATTEN_OFF = 15
ATTEN_MAX = 14
NOISE_PITCH_MAX = 31
FREQ_MAX = 0xFFFF
MAX_AUDIBLE_HZ = 20000
# The noise pitch is the YM2149's noise period on the ST's 2 MHz chip,
# whose noise generator runs at 2 MHz / 16 / period.
YM_NOISE_HZ = 2000000 / 16
JYM_LIMIT = 0xFFFF
TICK_MS_MAX = 1000
JYM_NOTE_BYTES = 4
JYM_HEADER_BYTES = 8
JYM_WAIT_MAX = 127
REPEAT_MIN = 4
REPEAT_MAX = 256
# The .song's columns: the widest cell is "@65535:14".
CELL_WIDTH = 10
EMPTY_ROW = ("---",) * (TONE_VOICES + 1)
# The SN76489: tone = clock / (32 * period); noise rates clock / 512, 1024,
# 2048, or tone 2's. Without the /8 prescaler, an eighth of those dividers.
SN_TONE_DIVIDER = 32
SN_NOISE_DIVIDERS = (512, 1024, 2048)
SN_PRESCALER = 8
SN_DIRECT_CLOCK_MAX = 1000000
SN_NOISE_TONE2 = 3
SN_NOISE_WHITE = 0x04
SN_PERIOD_ZERO = 1024
SN_SHIFT_WIDTH_DEFAULT = 16
VGM_SN_SHIFT_WIDTH = 0x2A
VGM_SN_FLAGS = 0x2B
SN_FLAG_ZERO_IS_400 = 0x01
SN_FLAG_NO_PRESCALER = 0x08
# The AY: tone = clock / (16 * period), noise clock / (16 * period); the
# envelope steps 16 levels a cycle of 256 * period clocks. A YM2149 with
# pin 26 low halves its clock (the VGM's AY flags say so).
AY_DIVIDER = 16
AY_REG_MIXER = 7
AY_REG_NOISE = 6
AY_REG_VOLUME = 8
AY_REG_ENVELOPE_LO = 11
AY_REG_ENVELOPE_HI = 12
AY_REG_SHAPE = 13
AY_ENVELOPE = 0x10
AY_LEVEL_MAX = 15
AY_ENVELOPE_STEPS = 16
AY_ENVELOPE_CYCLE = 256
AY_SHAPE_CONTINUE = 0x08
AY_SHAPE_ATTACK = 0x04
AY_SHAPE_ALTERNATE = 0x02
AY_SHAPE_HOLD = 0x01
VGM_AY_FLAGS = 0x79
AY_FLAG_HALF_CLOCK = 0x10
# An envelope that repeats faster than this many rows is a timbre; its
# frequency is a cycle's, or half that when it alternates (a triangle).
AY_ENVELOPE_ROWS_MIN = 2
AY_TRIANGLE_CYCLES = 2
def u32(data, at):
return struct.unpack_from("<I", data, at)[0] if at + 4 <= len(data) else 0
def commandLength(op):
for first, last, length in COMMAND_LENGTHS:
if first <= op <= last:
return length
return None
def gd3Strings(data, at):
"""The GD3 tag's strings at at (UTF-16LE, each ended by a 0), ASCII."""
if data[at:at + 4] != GD3_IDENT:
return []
size = u32(data, at + 8)
text = data[at + 12:at + 12 + size].decode("utf-16-le", "replace")
return ["".join(c if 32 <= ord(c) < 127 else "?" for c in s) for s in text.split("\0")]
class Sn76489:
"""The SN76489's registers, written byte by byte."""
def __init__(self, clock, flags, shiftWidth):
self.clock = clock
self.shiftWidth = shiftWidth or SN_SHIFT_WIDTH_DEFAULT
scale = SN_PRESCALER if flags & SN_FLAG_NO_PRESCALER or clock < SN_DIRECT_CLOCK_MAX else 1
self.toneDivider = SN_TONE_DIVIDER / scale
self.noiseDividers = tuple(d / scale for d in SN_NOISE_DIVIDERS)
self.periodZero = SN_PERIOD_ZERO if flags & SN_FLAG_ZERO_IS_400 else 1
self.reset()
def reset(self):
"""Power-on state: every channel silent."""
self.periodicAsNoise = 0
self.period = [0, 0, 0]
self.atten = [ATTEN_OFF] * 4
self.noise = 0
self.latch = (0, True)
def write(self, value, time):
if value & 0x80:
channel = (value >> 5) & 3
isVolume = bool(value & 0x10)
self.latch = (channel, isVolume)
low = value & 0x0F
if isVolume:
self.atten[channel] = low
elif channel < TONE_VOICES:
self.period[channel] = (self.period[channel] & 0x3F0) | low
else:
self.noise = low & 7
return
channel, isVolume = self.latch
if isVolume:
self.atten[channel] = value & 0x0F
elif channel < TONE_VOICES:
self.period[channel] = (self.period[channel] & 0x0F) | ((value & 0x3F) << 4)
else:
self.noise = value & 7
def toneHz(self, channel):
period = self.period[channel] or self.periodZero
return self.clock / (self.toneDivider * period)
def voices(self, time, rowSamples):
out = []
for channel in range(TONE_VOICES):
out.append(toneVoice(self.toneHz(channel), self.atten[channel]))
if self.atten[3] >= ATTEN_OFF:
out.append(None)
return tuple(out)
rate = self.toneHz(2) if (self.noise & 3) == SN_NOISE_TONE2 else self.clock / self.noiseDividers[self.noise & 3]
if not self.noise & SN_NOISE_WHITE:
# Periodic noise is a pulse at rate / width: a free tone voice
# plays it, tone 2's first when tone 2 is its clock.
order = (2, 0, 1) if (self.noise & 3) == SN_NOISE_TONE2 else (0, 1, 2)
for voice in order:
if out[voice] is None:
out[voice] = toneVoice(rate / self.shiftWidth, self.atten[3])
out.append(None)
return tuple(out)
self.periodicAsNoise += 1
out.append((noisePitch(rate), self.atten[3]))
return tuple(out)
class Ay8910:
"""The AY-3-8910 / YM2149's registers, and its envelope's progress."""
def __init__(self, clock):
self.clock = clock
self.reset()
def reset(self):
"""Power-on state: tone and noise off on every channel."""
self.regs = [0] * 16
self.regs[AY_REG_MIXER] = 0xFF
self.envelopeStart = 0
self.buzzRows = 0
def write(self, value, time):
register, data = value
self.regs[register & 0x0F] = data
if (register & 0x0F) == AY_REG_SHAPE:
self.envelopeStart = time
def envelopeLevel(self, time, rowSamples):
"""The envelope's level (0..15) at time; None when it repeats too
fast for rows to follow."""
period = max(1, self.regs[AY_REG_ENVELOPE_LO] | (self.regs[AY_REG_ENVELOPE_HI] << 8))
shape = self.regs[AY_REG_SHAPE] & 0x0F
cycleSamples = AY_ENVELOPE_CYCLE * period * VGM_RATE / self.clock
repeats = shape & AY_SHAPE_CONTINUE and not shape & AY_SHAPE_HOLD
if repeats and cycleSamples < AY_ENVELOPE_ROWS_MIN * rowSamples:
return None
step = int((time - self.envelopeStart) * AY_ENVELOPE_STEPS / cycleSamples)
cycle, position = divmod(step, AY_ENVELOPE_STEPS)
attack = bool(shape & AY_SHAPE_ATTACK)
if cycle > 0:
if not shape & AY_SHAPE_CONTINUE:
return 0
if shape & AY_SHAPE_HOLD:
end = AY_LEVEL_MAX if attack else 0
return AY_LEVEL_MAX - end if shape & AY_SHAPE_ALTERNATE else end
if shape & AY_SHAPE_ALTERNATE and cycle % 2:
attack = not attack
return position if attack else AY_LEVEL_MAX - position
def envelopeHz(self):
"""The frequency of a repeating envelope: a cycle a period, two when
it alternates."""
period = max(1, self.regs[AY_REG_ENVELOPE_LO] | (self.regs[AY_REG_ENVELOPE_HI] << 8))
cycles = AY_TRIANGLE_CYCLES if self.regs[AY_REG_SHAPE] & AY_SHAPE_ALTERNATE else 1
return self.clock / (AY_ENVELOPE_CYCLE * period * cycles)
def level(self, channel, time, rowSamples):
volume = self.regs[AY_REG_VOLUME + channel]
if not volume & AY_ENVELOPE:
return volume & 0x0F
level = self.envelopeLevel(time, rowSamples)
return AY_LEVEL_MAX if level is None else level
def voices(self, time, rowSamples):
out = []
mixer = self.regs[AY_REG_MIXER]
noiseLevel = 0
if any(self.regs[AY_REG_VOLUME + channel] & AY_ENVELOPE for channel in range(TONE_VOICES)) and self.envelopeLevel(time, rowSamples) is None:
self.buzzRows += 1
for channel in range(TONE_VOICES):
level = self.level(channel, time, rowSamples)
period = self.regs[channel * 2] | ((self.regs[channel * 2 + 1] & 0x0F) << 8)
buzzing = self.regs[AY_REG_VOLUME + channel] & AY_ENVELOPE and self.envelopeLevel(time, rowSamples) is None
if level == 0:
out.append(None)
elif not (mixer >> channel) & 1:
out.append(toneVoice(self.clock / (AY_DIVIDER * max(period, 1)), AY_LEVEL_MAX - level))
elif buzzing:
out.append(toneVoice(self.envelopeHz(), 0))
else:
out.append(None)
if not (mixer >> (TONE_VOICES + channel)) & 1:
noiseLevel = max(noiseLevel, level)
if noiseLevel == 0:
out.append(None)
else:
period = max(self.regs[AY_REG_NOISE] & 0x1F, 1)
out.append((noisePitch(self.clock / (AY_DIVIDER * period)), AY_LEVEL_MAX - noiseLevel))
return tuple(out)
def toneVoice(hz, atten):
"""A tone voice's (Hz, attenuation), or None when silent or too high to
hear."""
if atten >= ATTEN_OFF or hz > MAX_AUDIBLE_HZ:
return None
return (max(1, min(FREQ_MAX, round(hz))), min(atten, ATTEN_MAX))
def noisePitch(rate):
"""The noise pitch (0..31) whose rate on the ST is nearest rate."""
return max(0, min(NOISE_PITCH_MAX, round(YM_NOISE_HZ / rate)))
def readVgm(path, chip):
"""(clock name, the chip model, [(time, write)], loop time or None, end
time, header rate, title strings, warnings) of the VGM at path."""
data = open(path, "rb").read()
if data[:2] == GZIP_MAGIC:
data = gzip.decompress(data)
if data[:4] != VGM_IDENT or len(data) < VGM_HEADER_MIN:
raise SystemExit("vgm2song: %s is not a VGM file" % path)
version = u32(data, 0x08)
start = VGM_HEADER_MIN
if version >= VGM_DATA_OFFSET_V150 and u32(data, VGM_DATA_OFFSET_FIELD):
start = VGM_DATA_OFFSET_FIELD + u32(data, VGM_DATA_OFFSET_FIELD)
snClock = u32(data, 0x0C)
ayClock = u32(data, VGM_AY_CLOCK_FIELD) if version >= VGM_AY_CLOCK_V151 and start > VGM_AY_CLOCK_FIELD else 0
loopAt = 0x1C + u32(data, 0x1C) if u32(data, 0x1C) else None
rate = u32(data, VGM_RATE_FIELD)
title = gd3Strings(data, 0x14 + u32(data, 0x14)) if u32(data, 0x14) else []
warnings = []
writes = {"sn": [], "ay": []}
secondChip = 0
otherChip = 0
time = 0
loopTime = None
at = start
while at < len(data):
if at == loopAt:
loopTime = time
op = data[at]
if op == CMD_END:
break
if op == CMD_WAIT:
time += struct.unpack_from("<H", data, at + 1)[0]
at += 3
elif op == CMD_WAIT_NTSC:
time += WAIT_NTSC
at += 1
elif op == CMD_WAIT_PAL:
time += WAIT_PAL
at += 1
elif SHORT_WAIT_FIRST <= op <= SHORT_WAIT_LAST:
time += op - SHORT_WAIT_FIRST + 1
at += 1
elif DAC_WAIT_FIRST <= op <= DAC_WAIT_LAST:
time += op - DAC_WAIT_FIRST
at += 1
elif op == CMD_DATA_BLOCK:
at += 7 + u32(data, at + 3)
elif op == CMD_SN:
writes["sn"].append((time, data[at + 1]))
at += 2
elif op == CMD_SN2:
secondChip += 1
at += 2
elif op == CMD_AY:
if data[at + 1] & AY_SECOND_CHIP:
secondChip += 1
else:
writes["ay"].append((time, (data[at + 1], data[at + 2])))
at += 3
else:
length = commandLength(op)
if length is None:
raise SystemExit("vgm2song: unknown VGM command 0x%02X at 0x%X" % (op, at))
if any(first <= op <= last for first, last in OTHER_CHIP_RANGES):
otherChip += 1
at += 1 + length
if secondChip:
warnings.append("%d writes to a second chip left out" % secondChip)
if otherChip:
warnings.append("%d writes to other chips (FM, PCM) left out: their parts do not play" % otherChip)
found = [name for name in ("sn", "ay") if writes[name]]
if chip is None:
if len(found) != 1:
raise SystemExit("vgm2song: the VGM drives %s; pick one with --chip" % (" and ".join(found) or "neither the SN76489 nor the AY-3-8910"))
chip = found[0]
if not writes[chip]:
raise SystemExit("vgm2song: the VGM has no %s writes" % ("SN76489" if chip == "sn" else "AY-3-8910"))
if chip == "sn":
model = Sn76489(snClock & VGM_CLOCK_MASK, data[VGM_SN_FLAGS], data[VGM_SN_SHIFT_WIDTH])
name = "TI SN76489 at %d Hz" % model.clock
else:
clock = ayClock & VGM_CLOCK_MASK
if len(data) > VGM_AY_FLAGS and data[VGM_AY_FLAGS] & AY_FLAG_HALF_CLOCK:
clock //= 2
model = Ay8910(clock)
name = "AY-3-8910/YM2149 at %d Hz" % model.clock
if model.clock == 0:
raise SystemExit("vgm2song: the VGM gives the chip no clock")
return name, model, writes[chip], loopTime, time, rate, title, warnings
def defaultTick(writes, rate):
"""The update interval, rounded down to whole milliseconds: the header's
rate, or the shortest gap between writes that comes more than once when
that is shorter."""
times = sorted(set(t for t, _ in writes))
gaps = {}
for a, b in zip(times, times[1:]):
gaps[b - a] = gaps.get(b - a, 0) + 1
frame = VGM_RATE / rate if rate else WAIT_NTSC
repeated = [g for g, count in gaps.items() if count > 1]
if repeated:
frame = min(frame, min(repeated))
return max(1, math.floor(frame * 1000 / VGM_RATE))
def rows(model, writes, tickMs, endTime):
"""The chip's voices at the end of every row, the first to the last the
song lasts."""
rowSamples = tickMs * VGM_RATE / 1000
count = max(1, math.ceil(endTime / rowSamples))
out = []
at = 0
for row in range(count):
rowEnd = (row + 1) * rowSamples
while at < len(writes) and writes[at][0] < rowEnd:
model.write(writes[at][1], writes[at][0])
at += 1
out.append(model.voices(rowEnd, rowSamples))
return out
def cell(voice, state):
if state is None:
return "off"
if voice < TONE_VOICES:
return "@%d:%d" % state
return "N%d:%d" % state
def rowText(cells):
return " ".join(c.ljust(CELL_WIDTH) for c in cells).rstrip()
def songLines(states, loopRow):
"""The .song rows: each voice that changed, every voice on the loop row;
runs of empty rows as repeat blocks. Also the JYM1 bytes they bake to."""
lines = []
events = 0
empty = 0
waitRows = 0
last = (None,) * (TONE_VOICES + 1)
def flushEmpty():
nonlocal empty
while empty:
run = min(empty, REPEAT_MAX)
if run >= REPEAT_MIN:
lines.extend(["repeat %d" % run, rowText(EMPTY_ROW), "endrepeat"])
else:
lines.extend([rowText(EMPTY_ROW)] * run)
empty -= run
for row, state in enumerate(states):
cells = []
for voice in range(TONE_VOICES + 1):
if row == loopRow or state[voice] != last[voice]:
cells.append(cell(voice, state[voice]))
events += JYM_NOTE_BYTES if state[voice] is not None else 1
else:
cells.append("---")
last = state
if row == loopRow:
flushEmpty()
lines.append("loop")
if all(c == "---" for c in cells):
empty += 1
else:
flushEmpty()
lines.append(rowText(cells))
waitRows += 1
flushEmpty()
# A wait byte at most every row that has events, plus the long runs.
size = JYM_HEADER_BYTES + events + waitRows + len(states) // JYM_WAIT_MAX + 1
return lines, size
def main(argv):
parser = argparse.ArgumentParser(description="A VGM chip log as a JoeyLib .song (see the module's docstring).")
parser.add_argument("--chip", choices=("sn", "ay"), help="the chip to convert when the VGM drives both")
parser.add_argument("--tick", type=int, help="milliseconds a row (default: the VGM's update interval, rounded down)")
parser.add_argument("vgm")
parser.add_argument("song")
args = parser.parse_args(argv[1:])
name, model, writes, loopTime, endTime, rate, title, warnings = readVgm(args.vgm, args.chip)
baseTick = args.tick if args.tick else defaultTick(writes, rate)
if not 1 <= baseTick <= TICK_MS_MAX:
raise SystemExit("vgm2song: --tick is 1..%d ms" % TICK_MS_MAX)
tickMs = baseTick
while True:
model.reset()
states = rows(model, writes, tickMs, endTime)
rowSamples = tickMs * VGM_RATE / 1000
loopRow = int(loopTime // rowSamples) if loopTime is not None and loopTime < endTime else None
lines, size = songLines(states, loopRow)
if args.tick or size < JYM_LIMIT or tickMs + baseTick > TICK_MS_MAX:
break
tickMs += baseTick
if tickMs != baseTick:
warnings.append("%d ms rows would bake past JYM1's 64 KB; using %d ms rows" % (baseTick, tickMs))
if isinstance(model, Sn76489) and model.periodicAsNoise:
warnings.append("%d rows have periodic noise with all three tone voices busy, played as noise" % model.periodicAsNoise)
if isinstance(model, Ay8910) and model.buzzRows:
warnings.append("%d rows have an envelope repeating faster than two rows (a timbre), played at full volume" % model.buzzRows)
header = ["; Converted by tools/vgm2song.py from %s" % args.vgm.rsplit("/", 1)[-1],
"; %s, %d rows of %d ms%s" % (name, len(states), tickMs, ", looping at row %d" % loopRow if loopRow is not None else "")]
if len(title) > GD3_AUTHOR and title[GD3_TITLE]:
header.append("; title: %s" % title[GD3_TITLE])
if len(title) > GD3_AUTHOR and title[GD3_AUTHOR]:
header.append("; author: %s" % title[GD3_AUTHOR])
with open(args.song, "w", encoding="ascii") as out:
out.write("\n".join(header + ["", "tickms %d" % tickMs] + lines) + "\n")
for warning in warnings:
sys.stderr.write("vgm2song: warning: %s\n" % warning)
if size >= JYM_LIMIT:
sys.stderr.write("vgm2song: warning: this bakes to about %d bytes, over JYM1's 64 KB; try a longer --tick\n" % size)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))