Last active
August 1, 2026 17:04
-
-
Save pathawks/dd1485b8047892b091ae0122884c15e4 to your computer and use it in GitHub Desktop.
Extract Fire Hawk
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Extract Fire Hawk's Tandy/PCjr and Sound Blaster soundtracks as VGM files. | |
| The DOS release stores each song as an LSB-first Sierra LZW resource named | |
| SSnnn. Every unpacked sound resource contains several hardware-specific | |
| tracks. This converter selects track 0x13 for the three-voice PCjr/Tandy | |
| versions and track 0x08 for the nine-voice Sound Blaster OPL2 versions. It | |
| uses the matching PATCH.101 and PATCH.003 data and models the respective | |
| Sierra music-driver register writes. | |
| No third-party Python modules are required. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import math | |
| import struct | |
| import wave | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Iterable | |
| VGM_SAMPLE_RATE = 44_100 | |
| TICKS_PER_SECOND = 60 | |
| SAMPLES_PER_TICK = VGM_SAMPLE_RATE // TICKS_PER_SECOND | |
| TANDY_CLOCK_HZ = 3_579_545 | |
| SOUND_BLASTER_CLOCK_HZ = 3_579_545 | |
| TANDY_TRACK_TYPE = 0x13 | |
| SOUND_BLASTER_TRACK_TYPE = 0x08 | |
| ADLIB_TRACK_TYPE = 0x00 | |
| PC_SPEAKER_TRACK_TYPE = 0x12 | |
| TANDY_DRIVER_PERIOD_TABLE = 0x21 | |
| TANDY_DRIVER_VELOCITY_TABLE = 0x273 | |
| RESOURCE_PREFIX_SIZE = 2 | |
| SONGS = ( | |
| ("SS000", "SS000-opening-theme-tandy.vgm", "Opening Theme"), | |
| ("SS010", "SS010-mission-1-tandy.vgm", "Mission 1"), | |
| ("SS011", "SS011-boss-1-tandy.vgm", "Boss 1"), | |
| ("SS020", "SS020-mission-2-tandy.vgm", "Mission 2"), | |
| ("SS021", "SS021-boss-2-tandy.vgm", "Boss 2"), | |
| ("SS030", "SS030-mission-3-tandy.vgm", "Mission 3"), | |
| ("SS031", "SS031-boss-3-tandy.vgm", "Boss 3"), | |
| ("SS040", "SS040-mission-4-tandy.vgm", "Mission 4"), | |
| ("SS041", "SS041-boss-4-tandy.vgm", "Boss 4"), | |
| ("SS050", "SS050-mission-5-tandy.vgm", "Mission 5"), | |
| ("SS051", "SS051-boss-5-tandy.vgm", "Boss 5"), | |
| ("SS060", "SS060-mission-6-tandy.vgm", "Mission 6"), | |
| ("SS061", "SS061-boss-6-tandy.vgm", "Boss 6"), | |
| ("SS070", "SS070-mission-7-tandy.vgm", "Mission 7"), | |
| ("SS071", "SS071-boss-7-tandy.vgm", "Boss 7"), | |
| ("SS080", "SS080-mission-8-tandy.vgm", "Mission 8"), | |
| ("SS081", "SS081-boss-8-tandy.vgm", "Boss 8"), | |
| ("SS090", "SS090-mission-9-tandy.vgm", "Mission 9"), | |
| ("SS091", "SS091-boss-9-tandy.vgm", "Boss 9"), | |
| ("SS092", "SS092-ending-tandy.vgm", "Ending"), | |
| ("SS120", "SS120-moonlight-sonata-tandy.vgm", "Moonlight Sonata"), | |
| ) | |
| class ConversionError(Exception): | |
| """Raised when an input resource does not match the expected format.""" | |
| def read_u16(data: bytes, offset: int) -> int: | |
| return int.from_bytes(data[offset : offset + 2], "little") | |
| def write_u32(data: bytearray, offset: int, value: int) -> None: | |
| data[offset : offset + 4] = struct.pack("<I", value) | |
| def decompress_sierra_lzw(packed: bytes, source_name: str) -> bytes: | |
| """Decode the SCI-era LSB-first LZW wrapper used by the loose resources.""" | |
| if len(packed) < 6: | |
| raise ConversionError(f"{source_name}: truncated LZW resource") | |
| expected_size = int.from_bytes(packed[:4], "little") | |
| source = packed[4:] | |
| source_pos = 0 | |
| bit_buffer = 0 | |
| bit_count = 0 | |
| code_width = 9 | |
| next_code = 258 | |
| code_limit = 512 | |
| dictionary = {value: bytes((value,)) for value in range(256)} | |
| previous: bytes | None = None | |
| output = bytearray() | |
| def get_code() -> int: | |
| nonlocal source_pos, bit_buffer, bit_count | |
| while bit_count < code_width: | |
| if source_pos >= len(source): | |
| raise ConversionError(f"{source_name}: truncated LZW bitstream") | |
| bit_buffer |= source[source_pos] << bit_count | |
| source_pos += 1 | |
| bit_count += 8 | |
| code = bit_buffer & ((1 << code_width) - 1) | |
| bit_buffer >>= code_width | |
| bit_count -= code_width | |
| return code | |
| while len(output) < expected_size: | |
| code = get_code() | |
| if code == 257: | |
| break | |
| if code == 256: | |
| dictionary = {value: bytes((value,)) for value in range(256)} | |
| code_width = 9 | |
| next_code = 258 | |
| code_limit = 512 | |
| previous = None | |
| continue | |
| if code in dictionary: | |
| current = dictionary[code] | |
| elif code == next_code and previous is not None: | |
| current = previous + previous[:1] | |
| else: | |
| raise ConversionError( | |
| f"{source_name}: invalid LZW code {code:#x} " | |
| f"(next code is {next_code:#x})" | |
| ) | |
| output.extend(current) | |
| if previous is not None and next_code < 4096: | |
| dictionary[next_code] = previous + current[:1] | |
| next_code += 1 | |
| if next_code == code_limit and code_width < 12: | |
| code_width += 1 | |
| code_limit <<= 1 | |
| previous = current | |
| if len(output) != expected_size: | |
| raise ConversionError( | |
| f"{source_name}: LZW header says {expected_size} bytes, " | |
| f"decoded {len(output)}" | |
| ) | |
| return bytes(output) | |
| @dataclass(frozen=True) | |
| class MidiEvent: | |
| tick: int | |
| stream_index: int | |
| sequence: int | |
| status: int | |
| operands: tuple[int, ...] | |
| @dataclass(frozen=True) | |
| class TandyStream: | |
| part: int | |
| polyphony: int | |
| priority: int | |
| events: tuple[MidiEvent, ...] | |
| end_tick: int | |
| def find_track_descriptors( | |
| sound: bytes, source_name: str, track_type_to_find: int, track_name: str | |
| ) -> list[tuple[int, int]]: | |
| """Return the offset/size pairs from a requested SCI sound track. | |
| Fire Hawk's loose resource still includes its two-byte Sierra resource | |
| prefix. Directory offsets are relative to the bytes after that prefix. | |
| """ | |
| data = sound[RESOURCE_PREFIX_SIZE:] | |
| pos = 0 | |
| while pos < len(data): | |
| track_type = data[pos] | |
| pos += 1 | |
| if track_type == 0xFF: | |
| break | |
| descriptors: list[tuple[int, int]] = [] | |
| while pos < len(data) and data[pos] != 0xFF: | |
| if pos + 6 > len(data): | |
| raise ConversionError(f"{source_name}: truncated sound-track directory") | |
| _unknown = read_u16(data, pos) | |
| offset = read_u16(data, pos + 2) | |
| size = read_u16(data, pos + 4) | |
| descriptors.append((offset, size)) | |
| pos += 6 | |
| if pos >= len(data): | |
| raise ConversionError(f"{source_name}: unterminated sound-track directory") | |
| pos += 1 | |
| if track_type == track_type_to_find: | |
| return descriptors | |
| raise ConversionError(f"{source_name}: no {track_name} track {track_type_to_find:#04x}") | |
| def find_tandy_descriptors(sound: bytes, source_name: str) -> list[tuple[int, int]]: | |
| descriptors = find_track_descriptors(sound, source_name, TANDY_TRACK_TYPE, "Tandy/PCjr") | |
| if len(descriptors) != 3: | |
| raise ConversionError( | |
| f"{source_name}: Tandy track has {len(descriptors)} channels, expected 3" | |
| ) | |
| return descriptors | |
| def find_sound_blaster_descriptors(sound: bytes, source_name: str) -> list[tuple[int, int]]: | |
| descriptors = find_track_descriptors( | |
| sound, source_name, SOUND_BLASTER_TRACK_TYPE, "Sound Blaster" | |
| ) | |
| if not 1 <= len(descriptors) <= 9: | |
| raise ConversionError( | |
| f"{source_name}: Sound Blaster track has {len(descriptors)} channels" | |
| ) | |
| return descriptors | |
| def find_adlib_descriptors(sound: bytes, source_name: str) -> list[tuple[int, int]]: | |
| descriptors = find_track_descriptors(sound, source_name, ADLIB_TRACK_TYPE, "AdLib") | |
| if not 1 <= len(descriptors) <= 9: | |
| raise ConversionError(f"{source_name}: AdLib track has {len(descriptors)} channels") | |
| return descriptors | |
| def find_pc_speaker_descriptors(sound: bytes, source_name: str) -> list[tuple[int, int]]: | |
| descriptors = find_track_descriptors( | |
| sound, source_name, PC_SPEAKER_TRACK_TYPE, "PC speaker" | |
| ) | |
| if not 1 <= len(descriptors) <= 2: | |
| raise ConversionError( | |
| f"{source_name}: PC speaker track has {len(descriptors)} channels" | |
| ) | |
| return descriptors | |
| MIDI_OPERAND_COUNTS = { | |
| 0x80: 2, | |
| 0x90: 2, | |
| 0xA0: 2, | |
| 0xB0: 2, | |
| 0xC0: 1, | |
| 0xD0: 1, | |
| 0xE0: 2, | |
| } | |
| def parse_stream( | |
| sound: bytes, | |
| descriptor: tuple[int, int], | |
| stream_index: int, | |
| source_name: str, | |
| ) -> TandyStream: | |
| offset, size = descriptor | |
| start = RESOURCE_PREFIX_SIZE + offset | |
| end = start + size | |
| if end > len(sound) or size < 3: | |
| raise ConversionError( | |
| f"{source_name}: channel {stream_index + 1} points outside the resource" | |
| ) | |
| channel = sound[start:end] | |
| part = channel[0] & 0x0F | |
| polyphony = channel[1] & 0x0F | |
| priority = channel[1] >> 4 | |
| pos = 2 | |
| tick = 0 | |
| sequence = 0 | |
| running_status: int | None = None | |
| events: list[MidiEvent] = [] | |
| while pos < len(channel): | |
| if channel[pos] == 0xFC: | |
| return TandyStream(part, polyphony, priority, tuple(events), tick) | |
| while channel[pos] == 0xF8: | |
| tick += 240 | |
| pos += 1 | |
| if pos >= len(channel): | |
| raise ConversionError(f"{source_name}: truncated delay in Tandy channel") | |
| tick += channel[pos] | |
| pos += 1 | |
| if pos >= len(channel): | |
| raise ConversionError(f"{source_name}: missing event after channel delay") | |
| command = channel[pos] | |
| pos += 1 | |
| if command == 0xFC: | |
| return TandyStream(part, polyphony, priority, tuple(events), tick) | |
| if command == 0xF0: | |
| while pos < len(channel) and channel[pos] != 0xF7: | |
| pos += 1 | |
| if pos >= len(channel): | |
| raise ConversionError(f"{source_name}: unterminated system-exclusive event") | |
| pos += 1 | |
| sequence += 1 | |
| continue | |
| first_operand: int | None = None | |
| if command & 0x80: | |
| running_status = command | |
| else: | |
| if running_status is None: | |
| raise ConversionError( | |
| f"{source_name}: running status before a MIDI status byte" | |
| ) | |
| first_operand = command | |
| command = running_status | |
| operand_count = MIDI_OPERAND_COUNTS.get(command & 0xF0) | |
| if operand_count is None: | |
| raise ConversionError( | |
| f"{source_name}: unsupported MIDI status {command:#04x}" | |
| ) | |
| operands = [] if first_operand is None else [first_operand] | |
| needed = operand_count - len(operands) | |
| if pos + needed > len(channel): | |
| raise ConversionError(f"{source_name}: truncated MIDI event {command:#04x}") | |
| operands.extend(channel[pos : pos + needed]) | |
| pos += needed | |
| events.append( | |
| MidiEvent(tick, stream_index, sequence, command, tuple(operands)) | |
| ) | |
| sequence += 1 | |
| raise ConversionError(f"{source_name}: Tandy channel has no 0xFC terminator") | |
| def parse_tandy_streams(sound: bytes, source_name: str) -> tuple[TandyStream, ...]: | |
| descriptors = find_tandy_descriptors(sound, source_name) | |
| streams = tuple( | |
| parse_stream(sound, descriptor, index, source_name) | |
| for index, descriptor in enumerate(descriptors) | |
| ) | |
| if len({stream.part for stream in streams}) != 3: | |
| raise ConversionError(f"{source_name}: Tandy channels do not use three distinct parts") | |
| return streams | |
| def parse_sound_blaster_streams(sound: bytes, source_name: str) -> tuple[TandyStream, ...]: | |
| descriptors = find_sound_blaster_descriptors(sound, source_name) | |
| streams = tuple( | |
| parse_stream(sound, descriptor, index, source_name) | |
| for index, descriptor in enumerate(descriptors) | |
| ) | |
| if len({stream.part for stream in streams}) != len(streams): | |
| raise ConversionError(f"{source_name}: Sound Blaster channels reuse a MIDI part") | |
| if sum(stream.polyphony for stream in streams) > 9: | |
| raise ConversionError(f"{source_name}: Sound Blaster track requests over nine OPL voices") | |
| return streams | |
| def parse_adlib_streams(sound: bytes, source_name: str) -> tuple[TandyStream, ...]: | |
| descriptors = find_adlib_descriptors(sound, source_name) | |
| streams = tuple( | |
| parse_stream(sound, descriptor, index, source_name) | |
| for index, descriptor in enumerate(descriptors) | |
| ) | |
| if sum(stream.polyphony for stream in streams) > 9: | |
| raise ConversionError(f"{source_name}: AdLib track requests over nine OPL voices") | |
| return streams | |
| def parse_pc_speaker_streams(sound: bytes, source_name: str) -> tuple[TandyStream, ...]: | |
| descriptors = find_pc_speaker_descriptors(sound, source_name) | |
| return tuple( | |
| parse_stream(sound, descriptor, index, source_name) | |
| for index, descriptor in enumerate(descriptors) | |
| ) | |
| @dataclass | |
| class HardwareChannel: | |
| part: int | |
| note: int | None = None | |
| velocity: int = 0 | |
| base_amplitude: int = 0 | |
| envelope: bytes = b"" | |
| envelope_pos: int = 0 | |
| envelope_count: int = 0 | |
| envelope_volume: int = 0 | |
| release: bool = False | |
| sustain: bool = False | |
| duration: int = 0 | |
| attenuation: int = 15 | |
| class TandyDriver: | |
| """Register-level model of Fire Hawk's TANDY3V.DRV music path.""" | |
| def __init__( | |
| self, | |
| parts: Iterable[int], | |
| driver_data: bytes, | |
| patch_data: bytes, | |
| output: bytearray, | |
| ) -> None: | |
| if len(driver_data) < 0x393 or driver_data[0x20] != 0x73: | |
| raise ConversionError("TANDY3V.DRV does not match the expected Sierra driver") | |
| self.periods = tuple( | |
| read_u16(driver_data, TANDY_DRIVER_PERIOD_TABLE + index * 2) | |
| for index in range(432) | |
| ) | |
| self.velocity_table = driver_data[ | |
| TANDY_DRIVER_VELOCITY_TABLE : TANDY_DRIVER_VELOCITY_TABLE + 16 | |
| ] | |
| self.instrument_offsets, self.instrument_data = parse_patch_101(patch_data) | |
| self.output = output | |
| self.channels = [HardwareChannel(part) for part in parts] | |
| self.programs = [0] * 16 | |
| self.controller_volumes = [15] * 16 | |
| self.pitch_bends = [0] * 16 | |
| self.sustain = [False] * 16 | |
| self.master_volume = 15 | |
| for hardware_channel in range(4): | |
| self.write_psg(0x9F | (hardware_channel << 5)) | |
| def write_psg(self, value: int) -> None: | |
| self.output.extend((0x50, value & 0xFF)) | |
| def write_volume(self, index: int, attenuation: int) -> None: | |
| channel = self.channels[index] | |
| attenuation = max(0, min(15, attenuation)) | |
| if channel.attenuation != attenuation: | |
| channel.attenuation = attenuation | |
| self.write_psg(0x90 | (index << 5) | attenuation) | |
| def mute(self, index: int) -> None: | |
| channel = self.channels[index] | |
| channel.note = None | |
| channel.base_amplitude = 0 | |
| channel.release = False | |
| channel.sustain = False | |
| channel.envelope_count = 0 | |
| self.write_volume(index, 15) | |
| def period_for(self, part: int, note: int) -> int: | |
| folded = note | |
| while folded < 0x2D: | |
| folded += 12 | |
| while folded > 0x77: | |
| folded -= 12 | |
| index = (folded - 0x2D) * 4 + self.pitch_bends[part] | |
| index = max(0, min(431, index)) | |
| return self.periods[index] | |
| def write_period(self, index: int) -> None: | |
| channel = self.channels[index] | |
| if channel.note is None: | |
| return | |
| period = self.period_for(channel.part, channel.note) | |
| self.write_psg(0x80 | (index << 5) | (period & 0x0F)) | |
| self.write_psg((period >> 4) & 0x3F) | |
| @staticmethod | |
| def multiply_level(left: int, right: int) -> int: | |
| # This is the high nibble of TANDY3V.DRV's 17x16 lookup table. | |
| return ((left + 1) * right) >> 4 | |
| def current_amplitude(self, channel: HardwareChannel) -> int: | |
| level = self.multiply_level(channel.envelope_volume, channel.base_amplitude) | |
| return self.multiply_level(level, self.master_volume) | |
| def update_envelope_volume(self, index: int, value: int) -> None: | |
| channel = self.channels[index] | |
| channel.envelope_volume = value | |
| self.write_volume(index, 15 - self.current_amplitude(channel)) | |
| def note_on(self, part: int, note: int, velocity: int) -> None: | |
| if note < 0x15 or note > 0x74: | |
| return | |
| index = self.channel_for_part(part) | |
| if index is None: | |
| return | |
| if self.channels[index].note is not None: | |
| self.mute(index) | |
| channel = self.channels[index] | |
| channel.note = note | |
| channel.velocity = velocity | |
| channel.release = False | |
| channel.sustain = False | |
| channel.duration = 0 | |
| channel.envelope_pos = 0 | |
| channel.envelope_count = 0 | |
| instrument_offset = self.instrument_offsets[self.programs[part]] | |
| channel.envelope = self.instrument_data[instrument_offset:] | |
| velocity_level = self.velocity_table[min(15, velocity >> 3)] | |
| channel.base_amplitude = self.multiply_level( | |
| velocity_level, self.controller_volumes[part] | |
| ) | |
| self.write_period(index) | |
| def note_off(self, part: int, note: int) -> None: | |
| index = self.channel_for_part(part) | |
| if index is None: | |
| return | |
| channel = self.channels[index] | |
| if channel.note != note: | |
| return | |
| if self.sustain[part]: | |
| channel.sustain = True | |
| else: | |
| channel.release = True | |
| def channel_for_part(self, part: int) -> int | None: | |
| for index, channel in enumerate(self.channels): | |
| if channel.part == part: | |
| return index | |
| return None | |
| def control_change(self, part: int, controller: int, value: int) -> None: | |
| if controller == 7: | |
| self.controller_volumes[part] = value >> 3 | |
| elif controller == 64: | |
| enabled = value != 0 | |
| self.sustain[part] = enabled | |
| if not enabled: | |
| # TANDY3V.DRV releases every held hardware voice here; its | |
| # sustain-release loop is not restricted to the MIDI part. | |
| for channel in self.channels: | |
| if channel.sustain: | |
| channel.sustain = False | |
| channel.release = True | |
| elif controller in (75,): | |
| # SCI_MIDI_SET_POLYPHONY. The three one-voice mappings are already | |
| # established from the selected track's channel directory. | |
| return | |
| elif controller in (120, 123): | |
| index = self.channel_for_part(part) | |
| if index is not None: | |
| self.mute(index) | |
| def pitch_bend(self, part: int, value: int) -> None: | |
| if value < 0x2000: | |
| bend = -((0x2000 - value) // 170) | |
| else: | |
| bend = (value - 0x2000) // 170 | |
| self.pitch_bends[part] = bend | |
| index = self.channel_for_part(part) | |
| if index is not None: | |
| self.write_period(index) | |
| def dispatch(self, event: MidiEvent) -> None: | |
| command = event.status & 0xF0 | |
| part = event.status & 0x0F | |
| operands = event.operands | |
| if command == 0x80: | |
| self.note_off(part, operands[0]) | |
| elif command == 0x90: | |
| if operands[1] == 0: | |
| self.note_off(part, operands[0]) | |
| else: | |
| self.note_on(part, operands[0], operands[1]) | |
| elif command == 0xB0: | |
| self.control_change(part, operands[0], operands[1]) | |
| elif command == 0xC0: | |
| self.programs[part] = operands[0] | |
| elif command == 0xE0: | |
| self.pitch_bend(part, operands[0] | (operands[1] << 7)) | |
| # Polyphonic pressure and channel pressure have no Tandy driver action. | |
| def process_envelopes(self) -> None: | |
| for index, channel in enumerate(self.channels): | |
| if channel.note is None: | |
| continue | |
| channel.duration += 1 | |
| if channel.envelope_count == 0xFE: | |
| if not channel.release: | |
| continue | |
| channel.envelope_count = 0 | |
| elif channel.envelope_count: | |
| channel.envelope_count -= 1 | |
| continue | |
| if channel.envelope_pos + 1 >= len(channel.envelope): | |
| raise ConversionError("PATCH.101 instrument envelope is truncated") | |
| volume = channel.envelope[channel.envelope_pos] | |
| count = channel.envelope[channel.envelope_pos + 1] | |
| if volume == 0xFF: | |
| self.mute(index) | |
| continue | |
| channel.envelope_pos += 2 | |
| channel.envelope_count = count | |
| if volume != channel.envelope_volume: | |
| self.update_envelope_volume(index, volume) | |
| def stop(self) -> None: | |
| for index in range(3): | |
| self.mute(index) | |
| def parse_patch_101(resource: bytes) -> tuple[tuple[int, ...], bytes]: | |
| data = resource[RESOURCE_PREFIX_SIZE:] | |
| if len(data) < 258: | |
| raise ConversionError("PATCH.101 is truncated") | |
| offsets = tuple(read_u16(data, index * 2) - 0x100 for index in range(128)) | |
| if min(offsets) < 0: | |
| raise ConversionError("PATCH.101 has an invalid instrument offset") | |
| instrument_data = data[256:] | |
| if max(offsets) >= len(instrument_data): | |
| raise ConversionError("PATCH.101 instrument offset is outside the patch") | |
| return offsets, instrument_data | |
| OPL_REGISTER_OFFSETS = (0x00, 0x01, 0x02, 0x08, 0x09, 0x0A, 0x10, 0x11, 0x12) | |
| OPL_VELOCITY_MAP_1 = ( | |
| 0, 12, 13, 14, 15, 17, 18, 19, 20, 22, 23, 24, 26, 27, 28, 29, | |
| 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 45, 45, 46, | |
| 47, 48, 49, 50, 50, 51, 52, 52, 53, 54, 54, 55, 56, 56, 57, 58, | |
| 59, 59, 59, 60, 60, 60, 61, 61, 61, 62, 62, 62, 62, 63, 63, 63, | |
| ) | |
| OPL_VELOCITY_MAP_2 = ( | |
| 0, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 33, | |
| 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 47, 48, | |
| 49, 50, 50, 51, 52, 52, 53, 54, 54, 55, 56, 57, 57, 58, 58, 58, | |
| 59, 59, 59, 60, 60, 60, 61, 61, 61, 62, 62, 62, 62, 63, 63, 63, | |
| ) | |
| OPL_FREQUENCIES = ( | |
| 0x157, 0x15C, 0x161, 0x166, 0x16B, 0x171, 0x176, 0x17B, | |
| 0x181, 0x186, 0x18C, 0x192, 0x198, 0x19E, 0x1A4, 0x1AA, | |
| 0x1B0, 0x1B6, 0x1BD, 0x1C3, 0x1CA, 0x1D0, 0x1D7, 0x1DE, | |
| 0x1E5, 0x1EC, 0x1F3, 0x1FA, 0x202, 0x209, 0x211, 0x218, | |
| 0x220, 0x228, 0x230, 0x238, 0x241, 0x249, 0x252, 0x25A, | |
| 0x263, 0x26C, 0x275, 0x27E, 0x287, 0x290, 0x29A, 0x2A4, | |
| ) | |
| @dataclass | |
| class OplVoice: | |
| mapped_part: int = -1 | |
| part: int = -1 | |
| note: int | None = None | |
| patch: int = -1 | |
| velocity: int = 0 | |
| age: int = 0 | |
| sustained: bool = False | |
| class SoundBlasterDriver: | |
| """SCI1 nine-voice OPL2 path used by Fire Hawk's SNDBLAST.DRV.""" | |
| def __init__(self, streams: Iterable[TandyStream], patch_resource: bytes, output: bytearray) -> None: | |
| self.output = output | |
| self.patches = parse_patch_003(patch_resource) | |
| self.voices = [OplVoice() for _ in range(9)] | |
| self.programs = [0] * 16 | |
| self.volumes = [63] * 16 | |
| self.pitch_bends = [0x2000] * 16 | |
| self.sustain = [False] * 16 | |
| self.last_voice = [-1] * 16 | |
| self.master_volume = 15 | |
| voice = 0 | |
| for stream in streams: | |
| for _ in range(stream.polyphony): | |
| if voice == len(self.voices): | |
| raise ConversionError("Sound Blaster track exceeds OPL2 voice count") | |
| self.voices[voice].mapped_part = stream.part | |
| voice += 1 | |
| self.write(0xBD, 0x00) | |
| self.write(0x08, 0x00) | |
| self.write(0x01, 0x20) | |
| def write(self, register: int, value: int) -> None: | |
| self.output.extend((0x5A, register & 0xFF, value & 0xFF)) | |
| def set_patch(self, voice_index: int, patch_index: int) -> None: | |
| if not 0 <= patch_index < len(self.patches): | |
| patch_index = 0 | |
| voice = self.voices[voice_index] | |
| voice.patch = patch_index | |
| patch = self.patches[patch_index] | |
| for operator, offset in ((patch[:13], OPL_REGISTER_OFFSETS[voice_index]), (patch[13:26], OPL_REGISTER_OFFSETS[voice_index] + 3)): | |
| self.write(0x40 + offset, ((operator[0] & 3) << 6) | (operator[8] & 0x3F)) | |
| self.write(0x60 + offset, ((operator[3] & 0x0F) << 4) | (operator[6] & 0x0F)) | |
| self.write(0x80 + offset, ((operator[4] & 0x0F) << 4) | (operator[7] & 0x0F)) | |
| self.write(0x20 + offset, ((operator[9] & 1) << 7) | ((operator[10] & 1) << 6) | ((operator[5] & 1) << 5) | ((operator[11] & 1) << 4) | (operator[1] & 0x0F)) | |
| self.write(0xE0 + OPL_REGISTER_OFFSETS[voice_index], patch[26] & 3) | |
| self.write(0xE0 + OPL_REGISTER_OFFSETS[voice_index] + 3, patch[27] & 3) | |
| self.write(0xC0 + voice_index, ((patch[2] & 7) << 1) | int(not patch[12])) | |
| def set_velocity(self, voice_index: int) -> None: | |
| voice = self.voices[voice_index] | |
| patch = self.patches[voice.patch] | |
| for op_index, offset in ((1, OPL_REGISTER_OFFSETS[voice_index] + 3), (0, OPL_REGISTER_OFFSETS[voice_index])): | |
| if op_index == 0 and patch[12]: | |
| continue | |
| operator = patch[op_index * 13 : op_index * 13 + 13] | |
| velocity = self.volumes[voice.part] + 1 | |
| velocity = velocity * (OPL_VELOCITY_MAP_1[voice.velocity] + 1) // 64 | |
| velocity = velocity * (self.master_volume + 1) // 16 | |
| velocity = max(0, velocity - 1) | |
| velocity = OPL_VELOCITY_MAP_2[velocity] * (63 - (operator[8] & 0x3F)) // 63 | |
| self.write(0x40 + offset, ((operator[0] & 3) << 6) | (63 - velocity)) | |
| def set_note(self, voice_index: int, note: int, key: bool) -> None: | |
| voice = self.voices[voice_index] | |
| voice.note = note | |
| index = note << 2 | |
| bend = abs(self.pitch_bends[voice.part] - 0x2000) // 171 | |
| index += bend if self.pitch_bends[voice.part] > 0x2000 else -bend | |
| index = max(0, min(0x1FC, index)) | |
| frequency = OPL_FREQUENCIES[index % 48] | |
| octave = min(7, max(0, index // 48 - 1)) | |
| self.write(0xA0 + voice_index, frequency) | |
| self.write(0xB0 + voice_index, (int(key) << 5) | (octave << 2) | (frequency >> 8)) | |
| if key: | |
| self.set_velocity(voice_index) | |
| def voice_off(self, voice_index: int) -> None: | |
| voice = self.voices[voice_index] | |
| if voice.note is None: | |
| return | |
| self.set_note(voice_index, voice.note, False) | |
| voice.note = None | |
| voice.age = 0 | |
| voice.sustained = False | |
| def find_voice(self, part: int) -> int | None: | |
| oldest: int | None = None | |
| oldest_age = 0 | |
| for offset in range(1, 10): | |
| index = (self.last_voice[part] + offset) % 9 | |
| voice = self.voices[index] | |
| if voice.mapped_part != part: | |
| continue | |
| if voice.note is None: | |
| self.last_voice[part] = index | |
| voice.part = part | |
| return index | |
| if voice.age >= oldest_age: | |
| oldest, oldest_age = index, voice.age | |
| if oldest is not None and oldest_age: | |
| self.voice_off(oldest) | |
| self.last_voice[part] = oldest | |
| self.voices[oldest].part = part | |
| return oldest | |
| return None | |
| def set_voice_mapping(self, part: int, count: int) -> None: | |
| mapped = [ | |
| index for index, voice in enumerate(self.voices) if voice.mapped_part == part | |
| ] | |
| if count > len(mapped): | |
| for index, voice in enumerate(self.voices): | |
| if voice.mapped_part == -1: | |
| voice.mapped_part = part | |
| mapped.append(index) | |
| if len(mapped) == count: | |
| return | |
| raise ConversionError(f"Sound Blaster track cannot allocate {count} voices to part {part}") | |
| if count < len(mapped): | |
| for index in reversed(mapped[count:]): | |
| self.voice_off(index) | |
| self.voices[index].mapped_part = -1 | |
| def note_on(self, part: int, note: int, velocity: int) -> None: | |
| if velocity == 0: | |
| self.note_off(part, note) | |
| return | |
| if not 12 <= note <= 107: | |
| return | |
| for index, voice in enumerate(self.voices): | |
| if voice.part == part and voice.note == note: | |
| self.voice_off(index) | |
| voice.part = part | |
| self.voice_on(index, note, velocity >> 1) | |
| return | |
| index = self.find_voice(part) | |
| if index is not None: | |
| self.voice_on(index, note, velocity >> 1) | |
| def voice_on(self, voice_index: int, note: int, velocity: int) -> None: | |
| voice = self.voices[voice_index] | |
| patch = self.programs[voice.part] | |
| if patch != voice.patch: | |
| self.set_patch(voice_index, patch) | |
| voice.age = 0 | |
| voice.velocity = velocity | |
| self.set_note(voice_index, note, True) | |
| def note_off(self, part: int, note: int) -> None: | |
| for index, voice in enumerate(self.voices): | |
| if voice.part == part and voice.note == note: | |
| if self.sustain[part]: | |
| voice.sustained = True | |
| else: | |
| self.voice_off(index) | |
| return | |
| def dispatch(self, event: MidiEvent) -> None: | |
| command, part, operands = event.status & 0xF0, event.status & 0x0F, event.operands | |
| if command == 0x80: | |
| self.note_off(part, operands[0]) | |
| elif command == 0x90: | |
| self.note_on(part, operands[0], operands[1]) | |
| elif command == 0xB0: | |
| controller, value = operands | |
| if controller == 7: | |
| self.volumes[part] = value >> 1 | |
| for index, voice in enumerate(self.voices): | |
| if voice.part == part and voice.note is not None: | |
| self.set_note(index, voice.note, True) | |
| elif controller == 64: | |
| self.sustain[part] = value != 0 | |
| if value == 0: | |
| for index, voice in enumerate(self.voices): | |
| if voice.sustained: | |
| self.voice_off(index) | |
| elif controller == 75: | |
| self.set_voice_mapping(part, value) | |
| elif controller in (120, 123): | |
| for index, voice in enumerate(self.voices): | |
| if voice.part == part: | |
| self.voice_off(index) | |
| elif command == 0xC0: | |
| self.programs[part] = operands[0] | |
| elif command == 0xE0: | |
| self.pitch_bends[part] = operands[0] | (operands[1] << 7) | |
| for index, voice in enumerate(self.voices): | |
| if voice.part == part and voice.note is not None: | |
| self.set_note(index, voice.note, True) | |
| def tick(self) -> None: | |
| for voice in self.voices: | |
| if voice.note is not None: | |
| voice.age += 1 | |
| def stop(self) -> None: | |
| for index in range(len(self.voices)): | |
| self.voice_off(index) | |
| def parse_patch_003(resource: bytes) -> tuple[bytes, ...]: | |
| data = resource[RESOURCE_PREFIX_SIZE:] | |
| if not data.startswith(b"AdLib - THEX2 "): | |
| raise ConversionError("PATCH.003 does not have the expected Fire Hawk AdLib header") | |
| patches = data[14:] | |
| if len(patches) != 2690: | |
| raise ConversionError(f"PATCH.003 has {len(patches)} patch bytes, expected 2690") | |
| return tuple(patches[index * 28 : index * 28 + 28] for index in range(48)) + tuple( | |
| patches[2 + index * 28 : 2 + index * 28 + 28] for index in range(48, 96) | |
| ) | |
| def encode_tandy_gd3(title: str, source_resource: str) -> bytes: | |
| fields = ( | |
| title, | |
| "", | |
| "Fire Hawk: Thexder - The Second Contact", | |
| "", | |
| "IBM PCjr / Tandy 1000", | |
| "", | |
| "", | |
| "", | |
| "1990", | |
| "extract_tandy_vgm.py", | |
| f"Tandy track 0x13 extracted from {source_resource}; " | |
| "PATCH.101 envelope and TANDY3V.DRV lookup tables applied.", | |
| ) | |
| body = "\0".join(fields).encode("utf-16le") + b"\0\0" | |
| return b"Gd3 " + struct.pack("<II", 0x00000100, len(body)) + body | |
| def encode_opl_gd3(title: str, source_resource: str, hardware: str, track_type: int) -> bytes: | |
| fields = ( | |
| title, | |
| "", | |
| "Fire Hawk: Thexder - The Second Contact", | |
| "", | |
| f"IBM PC / {hardware}", | |
| "", | |
| "", | |
| "", | |
| "1990", | |
| "extract_tandy_vgm.py", | |
| f"{hardware} track {track_type:#04x} extracted from {source_resource}; " | |
| f"PATCH.003 and the {hardware} OPL2 music path applied.", | |
| ) | |
| body = "\0".join(fields).encode("utf-16le") + b"\0\0" | |
| return b"Gd3 " + struct.pack("<II", 0x00000100, len(body)) + body | |
| def build_vgm( | |
| streams: tuple[TandyStream, ...], | |
| driver_data: bytes, | |
| patch_data: bytes, | |
| title: str, | |
| source_resource: str, | |
| ) -> tuple[bytes, int, int]: | |
| header_size = 0x100 | |
| output = bytearray(header_size) | |
| output[:4] = b"Vgm " | |
| write_u32(output, 0x08, 0x00000171) | |
| write_u32(output, 0x0C, TANDY_CLOCK_HZ) | |
| output[0x28:0x2A] = struct.pack("<H", 0x000C) | |
| output[0x2A] = 17 | |
| write_u32(output, 0x34, header_size - 0x34) | |
| emulated_driver = TandyDriver( | |
| (stream.part for stream in streams), driver_data, patch_data, output | |
| ) | |
| events = sorted( | |
| (event for stream in streams for event in stream.events), | |
| key=lambda event: (event.tick, event.stream_index, event.sequence), | |
| ) | |
| events_by_tick: dict[int, list[MidiEvent]] = {} | |
| for event in events: | |
| events_by_tick.setdefault(event.tick, []).append(event) | |
| final_tick = max(stream.end_tick for stream in streams) | |
| event_count = 0 | |
| # The real driver's 60 Hz callback first advances the sequence and then | |
| # advances each active instrument envelope. | |
| for tick in range(final_tick + 1): | |
| for event in events_by_tick.get(tick, ()): | |
| emulated_driver.dispatch(event) | |
| event_count += 1 | |
| emulated_driver.process_envelopes() | |
| if tick != final_tick: | |
| output.append(0x62) # exactly 735 samples at the VGM 44.1 kHz rate | |
| emulated_driver.stop() | |
| output.append(0x66) | |
| total_samples = final_tick * SAMPLES_PER_TICK | |
| write_u32(output, 0x18, total_samples) | |
| gd3_offset = len(output) | |
| output.extend(encode_tandy_gd3(title, source_resource)) | |
| write_u32(output, 0x14, gd3_offset - 0x14) | |
| write_u32(output, 0x04, len(output) - 0x04) | |
| return bytes(output), final_tick, event_count | |
| def build_sound_blaster_vgm( | |
| streams: tuple[TandyStream, ...], | |
| patch_data: bytes, | |
| title: str, | |
| source_resource: str, | |
| hardware: str = "Sound Blaster", | |
| track_type: int = SOUND_BLASTER_TRACK_TYPE, | |
| ) -> tuple[bytes, int, int]: | |
| header_size = 0x100 | |
| output = bytearray(header_size) | |
| output[:4] = b"Vgm " | |
| write_u32(output, 0x08, 0x00000171) | |
| write_u32(output, 0x50, SOUND_BLASTER_CLOCK_HZ) | |
| write_u32(output, 0x34, header_size - 0x34) | |
| emulated_driver = SoundBlasterDriver(streams, patch_data, output) | |
| events = sorted( | |
| (event for stream in streams for event in stream.events), | |
| key=lambda event: (event.tick, event.stream_index, event.sequence), | |
| ) | |
| events_by_tick: dict[int, list[MidiEvent]] = {} | |
| for event in events: | |
| events_by_tick.setdefault(event.tick, []).append(event) | |
| final_tick = max(stream.end_tick for stream in streams) | |
| event_count = 0 | |
| for tick in range(final_tick + 1): | |
| for event in events_by_tick.get(tick, ()): | |
| emulated_driver.dispatch(event) | |
| event_count += 1 | |
| emulated_driver.tick() | |
| if tick != final_tick: | |
| output.append(0x62) | |
| emulated_driver.stop() | |
| output.append(0x66) | |
| total_samples = final_tick * SAMPLES_PER_TICK | |
| write_u32(output, 0x18, total_samples) | |
| gd3_offset = len(output) | |
| output.extend(encode_opl_gd3(title, source_resource, hardware, track_type)) | |
| write_u32(output, 0x14, gd3_offset - 0x14) | |
| write_u32(output, 0x04, len(output) - 0x04) | |
| return bytes(output), final_tick, event_count | |
| def build_pc_speaker_wav( | |
| streams: tuple[TandyStream, ...], title: str, destination: Path | |
| ) -> tuple[int, int]: | |
| """Render the one-bit PC-speaker arrangement as mono 16-bit PCM.""" | |
| events = sorted( | |
| (event for stream in streams for event in stream.events), | |
| key=lambda event: (event.tick, event.stream_index, event.sequence), | |
| ) | |
| events_by_tick: dict[int, list[MidiEvent]] = {} | |
| for event in events: | |
| events_by_tick.setdefault(event.tick, []).append(event) | |
| current_note: int | None = None | |
| pitch_bend = 0 | |
| final_tick = max(stream.end_tick for stream in streams) | |
| frames = bytearray() | |
| for tick in range(final_tick): | |
| for event in events_by_tick.get(tick, ()): | |
| command = event.status & 0xF0 | |
| if command == 0x90 and event.operands[1]: | |
| # STD.DRV has one hardware channel and cuts off the previous | |
| # tone whenever a new note arrives. | |
| current_note = event.operands[0] | |
| elif command == 0x80 or (command == 0x90 and not event.operands[1]): | |
| if current_note == event.operands[0]: | |
| current_note = None | |
| elif command == 0xE0: | |
| pitch_bend = ((event.operands[0] | (event.operands[1] << 7)) - 0x2000) // 171 | |
| if current_note is None or not 24 <= current_note <= 119: | |
| frames.extend(b"\x00\x00" * SAMPLES_PER_TICK) | |
| continue | |
| halftone_delta = (current_note - 129) * 4 + pitch_bend | |
| octave_delta = ((halftone_delta + 10 * 48) // 48) - 10 | |
| halftone_index = (halftone_delta + 48 * 100) % 48 | |
| table_frequency = int(2.0 ** ((288 + halftone_index) / 48.0) * 440.0) | |
| frequency = table_frequency / (1 << max(0, -octave_delta)) | |
| for sample in range(SAMPLES_PER_TICK): | |
| phase = ((tick * SAMPLES_PER_TICK + sample) * frequency / VGM_SAMPLE_RATE) % 1.0 | |
| frames.extend(struct.pack("<h", 12000 if phase < 0.5 else -12000)) | |
| with wave.open(str(destination), "wb") as wav: | |
| wav.setnchannels(1) | |
| wav.setsampwidth(2) | |
| wav.setframerate(VGM_SAMPLE_RATE) | |
| wav.writeframes(frames) | |
| return final_tick, len(events) | |
| def validate_vgm(data: bytes, expected_samples: int, output_name: str) -> None: | |
| if data[:4] != b"Vgm ": | |
| raise ConversionError(f"{output_name}: missing VGM signature") | |
| if int.from_bytes(data[0x18:0x1C], "little") != expected_samples: | |
| raise ConversionError(f"{output_name}: incorrect total-sample field") | |
| data_start = 0x34 + int.from_bytes(data[0x34:0x38], "little") | |
| pos = data_start | |
| samples = 0 | |
| found_end = False | |
| while pos < len(data): | |
| command = data[pos] | |
| pos += 1 | |
| if command == 0x50: | |
| pos += 1 | |
| elif command == 0x5A: | |
| pos += 2 | |
| elif command == 0x61: | |
| samples += read_u16(data, pos) | |
| pos += 2 | |
| elif command == 0x62: | |
| samples += 735 | |
| elif command == 0x63: | |
| samples += 882 | |
| elif 0x70 <= command <= 0x7F: | |
| samples += (command & 0x0F) + 1 | |
| elif command == 0x66: | |
| found_end = True | |
| break | |
| else: | |
| raise ConversionError(f"{output_name}: unexpected VGM command {command:#04x}") | |
| if not found_end or samples != expected_samples: | |
| raise ConversionError( | |
| f"{output_name}: command stream has {samples} samples, " | |
| f"expected {expected_samples}" | |
| ) | |
| def locate_resource(name: str, disk1: Path, disk2: Path) -> Path: | |
| for directory in (disk1, disk2): | |
| candidate = directory / name | |
| if candidate.is_file(): | |
| return candidate | |
| raise ConversionError(f"could not find {name} in {disk1} or {disk2}") | |
| def convert_all(disk1: Path, disk2: Path, output_dir: Path) -> None: | |
| tandy_patch_path = locate_resource("PATCH.101", disk1, disk2) | |
| sound_blaster_patch_path = locate_resource("PATCH.003", disk1, disk2) | |
| driver_path = locate_resource("TANDY3V.DRV", disk1, disk2) | |
| tandy_patch_data = decompress_sierra_lzw( | |
| tandy_patch_path.read_bytes(), tandy_patch_path.name | |
| ) | |
| sound_blaster_patch_data = decompress_sierra_lzw( | |
| sound_blaster_patch_path.read_bytes(), sound_blaster_patch_path.name | |
| ) | |
| driver_data = driver_path.read_bytes() | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| for resource_name, output_name, title in SONGS: | |
| source_path = locate_resource(resource_name, disk1, disk2) | |
| sound = decompress_sierra_lzw(source_path.read_bytes(), resource_name) | |
| tandy_streams = parse_tandy_streams(sound, resource_name) | |
| tandy_vgm, tandy_ticks, tandy_event_count = build_vgm( | |
| tandy_streams, driver_data, tandy_patch_data, title, resource_name | |
| ) | |
| validate_vgm(tandy_vgm, tandy_ticks * SAMPLES_PER_TICK, output_name) | |
| tandy_destination = output_dir / output_name | |
| tandy_destination.write_bytes(tandy_vgm) | |
| print( | |
| f"{tandy_destination.name}: {tandy_ticks / TICKS_PER_SECOND:.2f}s, " | |
| f"{tandy_event_count} MIDI events, {len(tandy_vgm)} bytes" | |
| ) | |
| sound_blaster_streams = parse_sound_blaster_streams(sound, resource_name) | |
| sound_blaster_vgm, sound_blaster_ticks, sound_blaster_event_count = ( | |
| build_sound_blaster_vgm( | |
| sound_blaster_streams, | |
| sound_blaster_patch_data, | |
| title, | |
| resource_name, | |
| ) | |
| ) | |
| sound_blaster_name = output_name.removesuffix("-tandy.vgm") + "-sound-blaster.vgm" | |
| validate_vgm( | |
| sound_blaster_vgm, | |
| sound_blaster_ticks * SAMPLES_PER_TICK, | |
| sound_blaster_name, | |
| ) | |
| sound_blaster_destination = output_dir / sound_blaster_name | |
| sound_blaster_destination.write_bytes(sound_blaster_vgm) | |
| print( | |
| f"{sound_blaster_destination.name}: " | |
| f"{sound_blaster_ticks / TICKS_PER_SECOND:.2f}s, " | |
| f"{sound_blaster_event_count} MIDI events, " | |
| f"{len(sound_blaster_vgm)} bytes" | |
| ) | |
| adlib_streams = parse_adlib_streams(sound, resource_name) | |
| adlib_vgm, adlib_ticks, adlib_event_count = build_sound_blaster_vgm( | |
| adlib_streams, | |
| sound_blaster_patch_data, | |
| title, | |
| resource_name, | |
| hardware="AdLib", | |
| track_type=ADLIB_TRACK_TYPE, | |
| ) | |
| adlib_name = output_name.removesuffix("-tandy.vgm") + "-adlib.vgm" | |
| validate_vgm(adlib_vgm, adlib_ticks * SAMPLES_PER_TICK, adlib_name) | |
| adlib_destination = output_dir / adlib_name | |
| adlib_destination.write_bytes(adlib_vgm) | |
| print( | |
| f"{adlib_destination.name}: {adlib_ticks / TICKS_PER_SECOND:.2f}s, " | |
| f"{adlib_event_count} MIDI events, {len(adlib_vgm)} bytes" | |
| ) | |
| pc_speaker_streams = parse_pc_speaker_streams(sound, resource_name) | |
| pc_speaker_name = output_name.removesuffix("-tandy.vgm") + "-pc-speaker.wav" | |
| pc_speaker_destination = output_dir / pc_speaker_name | |
| pc_ticks, pc_event_count = build_pc_speaker_wav( | |
| pc_speaker_streams, title, pc_speaker_destination | |
| ) | |
| print( | |
| f"{pc_speaker_destination.name}: {pc_ticks / TICKS_PER_SECOND:.2f}s, " | |
| f"{pc_event_count} MIDI events, {pc_speaker_destination.stat().st_size} bytes" | |
| ) | |
| def main() -> int: | |
| script_dir = Path(__file__).resolve().parent | |
| firehawk_dir = script_dir.parent | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--disk1", type=Path, default=firehawk_dir / "disk1") | |
| parser.add_argument("--disk2", type=Path, default=firehawk_dir / "disk2") | |
| parser.add_argument("--output-dir", type=Path, default=script_dir) | |
| args = parser.parse_args() | |
| try: | |
| convert_all(args.disk1, args.disk2, args.output_dir) | |
| except (ConversionError, OSError) as error: | |
| parser.exit(1, f"error: {error}\n") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment