/** * VGM Library v1.71 Specification Compliant Parser * * Based on VGM Specification v1.71 beta by Valley Bell * https://vgmrips.net/misc/vgmspec171.txt * * "VGM (Video Game Music) is a sample-accurate sound logging format for the Sega * Master System, the Sega Game Gear and possibly many other machines (e.g. Sega * Genesis). * * The normal file extension is .vgm but files can also be GZip compressed into * .vgz files. However, a VGM player should attempt to support compressed and * uncompressed files with either extension." */ const fs = require('fs'); const path = require('path'); const zlib = require('zlib'); /** * VGM Header Chip Clock Offsets (VGM Specification v1.71) * * "The format starts with a 256 byte header" * "All integer values are *unsigned* and written in "Intel" byte order (Little * Endian), so for example 0x12345678 is written as 0x78 0x56 0x34 0x12." * * Офсеты тактовых частот чипов в заголовке VGM файла согласно спецификации v1.71. * Каждый чип имеет 4-байтовое поле с тактовой частотой в Гц. * "It should be 0 if there is no [chip] used." */ const CHIP_OFFSETS = { // "0x0C: SN76489 clock (32 bits) // Input clock rate in Hz for the SN76489 PSG chip. A typical value is // 3579545. It should be 0 if there is no PSG chip used. // Note: Bit 31 (0x80000000) is used on combination with the dual-chip-bit // to indicate that this is a T6W28. (PSG variant used in NeoGeo Pocket)" SN76489: { offset: 0x0C, name: 'SN76489 PSG', minVersion: 0x100 }, // "0x10: YM2413 clock (32 bits) // Input clock rate in Hz for the YM2413 chip. A typical value is 3579545. // It should be 0 if there is no YM2413 chip used." YM2413: { offset: 0x10, name: 'YM2413 OPLL', minVersion: 0x100 }, // "0x14: GD3 offset (32 bits) // Relative offset to GD3 tag. 0 if no GD3 tag. // GD3 tags are descriptive tags similar in use to ID3 tags in MP3 files." GD3: { offset: 0x14, name: 'GD3 offset', minVersion: 0x100 }, // "0x2C: YM2612 clock (32 bits) // Input clock rate in Hz for the YM2612 chip. A typical value is 7670454. // For version 1.01 and earlier files, the YM2413 clock rate should be // used for the clock rate of the YM2612." YM2612: { offset: 0x2C, name: 'YM2612 OPN2', minVersion: 0x150 }, // "0x30: YM2151 clock (32 bits) // Input clock rate in Hz for the YM2151 chip. A typical value is 3579545." // "0x30: YM2151 clock (32 bits) // Input clock rate in Hz for the YM2151 chip. A typical value is 3579545. // It should be 0 if there us no YM2151 chip used." YM2151: { offset: 0x30, name: 'YM2151 OPM', minVersion: 0x150 }, // "[VGM 1.51 additions:] // 0x38: Sega PCM clock (32 bits) // Input clock rate in Hz for the Sega PCM chip. A typical value is // 4000000. It should be 0 if there is no Sega PCM chip used." SegaPCM: { offset: 0x38, name: 'Sega PCM', minVersion: 0x151 }, // "0x40: RF5C68 clock (32 bits) // Input clock rate in Hz for the RF5C68 PCM chip. A typical value is // 12500000. It should be 0 if there is no RF5C68 chip used." RF5C68: { offset: 0x40, name: 'RF5C68 PCM', minVersion: 0x151 }, // "0x44: YM2203 clock (32 bits) // Input clock rate in Hz for the YM2203 chip. A typical value is 3000000." YM2203: { offset: 0x44, name: 'YM2203 OPN', minVersion: 0x151 }, // "0x48: YM2608 clock (32 bits) // Input clock rate in Hz for the YM2608 chip. A typical value is 8000000." YM2608: { offset: 0x48, name: 'YM2608 OPNA', minVersion: 0x151 }, // "0x4C: YM2610/YM2610B clock (32 bits) // Input clock rate in Hz for the YM2610/B chip. A typical value is // 8000000. It should be 0 if there is no YM2610/B chip used. // Note: Bit 31 is used to set whether it is a YM2610 or a YM2610B chip. // If bit 31 is set it is an YM2610B, if bit 31 is clear it is an YM2610." YM2610: { offset: 0x4C, name: 'YM2610/B OPNB', minVersion: 0x151 }, // "0x50: YM3812 clock (32 bits) // Input clock rate in Hz for the YM3812 chip. A typical value is 3579545." YM3812: { offset: 0x50, name: 'YM3812 OPL2', minVersion: 0x151 }, // "0x54: YM3526 clock (32 bits) // Input clock rate in Hz for the YM3526 chip. A typical value is 3579545." YM3526: { offset: 0x54, name: 'YM3526 OPL', minVersion: 0x151 }, // "0x58: Y8950 clock (32 bits) // Input clock rate in Hz for the Y8950 chip. A typical value is 3579545." Y8950: { offset: 0x58, name: 'Y8950 OPL', minVersion: 0x151 }, // "0x5C: YMF262 clock (32 bits) // Input clock rate in Hz for the YMF262 chip. A typical value is 14318180." YMF262: { offset: 0x5C, name: 'YMF262 OPL3', minVersion: 0x151 }, // "0x60: YMF278B clock (32 bits) // Input clock rate in Hz for the YMF278B chip. A typical value is // 33868800. It should be 0 if there is no YMF278B chip used." YMF278B: { offset: 0x60, name: 'YMF278B OPL4', minVersion: 0x151 }, // "0x64: YMF271 clock (32 bits) // Input clock rate in Hz for the YMF271 chip. A typical value is // 16934400. It should be 0 if there is no YMF271 chip used." YMF271: { offset: 0x64, name: 'YMF271 OPX', minVersion: 0x151 }, // "0x68: YMZ280B clock (32 bits) // Input clock rate in Hz for the YMZ280B chip. A typical value is // 16934400. It should be 0 if there is no YMZ280B chip used." YMZ280B: { offset: 0x68, name: 'YMZ280B PCMD8', minVersion: 0x151 }, // "0x6C: RF5C164 clock (32 bits) // Input clock rate in Hz for the RF5C164 PCM chip. A typical value is // 12500000. It should be 0 if there is no RF5C164 chip used." RF5C164: { offset: 0x6C, name: 'RF5C164 PCM', minVersion: 0x151 }, // "0x70: PWM clock (32 bits) // Input clock rate in Hz for the PWM chip. A typical value is // 23011361. It should be 0 if there is no PWM chip used." PWM: { offset: 0x70, name: 'PWM', minVersion: 0x151 }, // "0x74: AY8910 clock (32 bits) // Input clock rate in Hz for the AY8910 chip. A typical value is 1789750. // It should be 0 if there is no AY8910 chip used." AY8910: { offset: 0x74, name: 'AY8910 PSG', minVersion: 0x151 }, // "[VGM 1.61 additions:] // 0x80: GameBoy DMG clock (32 bits) // Input clock rate in Hz for the GameBoy DMG chip, LR35902. A typical // value is 4194304. It should be 0 if there is no GB DMG chip used." DMG: { offset: 0x80, name: 'GameBoy DMG', minVersion: 0x161 }, // "0x84: NES APU clock (32 bits) // Input clock rate in Hz for the NES APU chip, N2A03. A typical value is // 1789772. It should be 0 if there is no NES APU chip used. // Note: Bit 31 is used to enable the FDS sound addon. Set to enable, // clear to disable." NES_APU: { offset: 0x84, name: 'NES APU', minVersion: 0x161 }, // "0x88: MultiPCM clock (32 bits) // Input clock rate in Hz for the MultiPCM chip. A typical value is // 8053975. It should be 0 if there is no MultiPCM chip used." MultiPCM: { offset: 0x88, name: 'MultiPCM', minVersion: 0x161 }, // "0x8C: uPD7759 clock (32 bits) // Input clock rate in Hz for the uPD7759 chip. A typical value is 640000. // It should be 0 if there is no uPD7759 chip used." uPD7759: { offset: 0x8C, name: 'uPD7759 ADPCM', minVersion: 0x161 }, // "0x90: OKIM6258 clock (32 bits) // Input clock rate in Hz for the OKIM6258 chip. A typical value is // 4000000. It should be 0 if there is no OKIM6258 chip used." OKIM6258: { offset: 0x90, name: 'OKIM6258 ADPCM', minVersion: 0x161 }, // "0x98: OKIM6295 clock (32 bits) // Input clock rate in Hz for the OKIM6295 chip. A typical value is // 8000000. It should be 0 if there is no OKIM6295 chip used." OKIM6295: { offset: 0x98, name: 'OKIM6295 ADPCM', minVersion: 0x161 }, // "0x9C: K051649 clock (32 bits) // Input clock rate in Hz for the K051649 chip. A typical value is // 1500000. It should be 0 if there is no K051649 chip used." K051649: { offset: 0x9C, name: 'K051649 SCC', minVersion: 0x161 }, // "0xA0: K054539 clock (32 bits) // Input clock rate in Hz for the K054539 chip. A typical value is // 18432000. It should be 0 if there is no K054539 chip used." K054539: { offset: 0xA0, name: 'K054539 PCM', minVersion: 0x161 }, // "0xA4: HuC6280 clock (32 bits) // Input clock rate in Hz for the HuC6280 chip. A typical value is // 3579545. It should be 0 if there is no HuC6280 chip used." HuC6280: { offset: 0xA4, name: 'HuC6280 PSG', minVersion: 0x161 }, // "0xA8: C140 clock (32 bits) // Input clock rate in Hz for the C140 chip. A typical value is 8000000. // It should be 0 if there is no C140 chip used." C140: { offset: 0xA8, name: 'C140', minVersion: 0x161 }, // "0xAC: K053260 clock (32 bits) // Input clock rate in Hz for the K053260 chip. A typical value is // 3579545. It should be 0 if there is no K053260 chip used." K053260: { offset: 0xAC, name: 'K053260 PCMA', minVersion: 0x161 }, // "0xB0: Pokey clock (32 bits) // Input clock rate in Hz for the Pokey chip. A typical value is 1789772. // It should be 0 if there is no Pokey chip used." Pokey: { offset: 0xB0, name: 'Pokey', minVersion: 0x161 }, // "0xB4: QSound clock (32 bits) // Input clock rate in Hz for the QSound chip. A typical value is 4000000. // It should be 0 if there is no QSound chip used." QSound: { offset: 0xB4, name: 'QSound', minVersion: 0x161 }, // "[VGM 1.71 additions:] // 0xB8: SCSP clock (32 bits) // Input clock rate in Hz for the SCSP chip. A typical value is 22579200. // It should be 0 if there is no SCSP chip used." SCSP: { offset: 0xB8, name: 'SCSP', minVersion: 0x171 }, // "0xC0: WonderSwan clock (32 bits) // Input clock rate in Hz for the WonderSwan chip. A typical value is // 3072000. It should be 0 if there is no WonderSwan chip used." WonderSwan: { offset: 0xC0, name: 'WonderSwan', minVersion: 0x171 }, // "0xC4: Virtual Boy VSU clock (32 bits) // Input clock rate in Hz for the VSU chip. A typical value is 5000000. // It should be 0 if there is no VSU chip used." VSU: { offset: 0xC4, name: 'Virtual Boy VSU', minVersion: 0x171 }, // "0xC8: SAA1099 clock (32 bits) // Input clock rate in Hz for the SAA1099 chip. A typical value is // 8000000. It should be 0 if there is no SAA1099 chip used." // [VGM 1.71 additions] - Creative Music System sound chip SAA1099: { offset: 0xC8, name: 'SAA1099', minVersion: 0x171 }, // "0xCC: ES5503 clock (32 bits) // Input clock rate in Hz for the ES5503 chip. A typical value is 7159090. // It should be 0 if there is no ES5503 chip used." ES5503: { offset: 0xCC, name: 'ES5503 DOC', minVersion: 0x171 }, // "0xD0: ES5505/ES5506 clock (32 bits) // Input clock rate in Hz for the ES5506 chip. A typical value is // 16000000. It should be 0 if there is no ES5506 chip used. // Note: Bit 31 is used to set whether it is an ES5505 or an ES5506 chip. // If bit 31 is set it is an ES5506, if bit 31 is clear it is an ES5505." ES5505: { offset: 0xD0, name: 'ES5505/ES5506 OTTO', minVersion: 0x171 }, // "0xD8: Seta X1-010 clock (32 bits) // Input clock rate in Hz for the X1-010 chip. A typical value is // 16000000. It should be 0 if there is no X1-010 chip used." X1_010: { offset: 0xD8, name: 'X1-010 Seta', minVersion: 0x171 }, // "0xDC: Namco C352 clock (32 bits) // Input clock rate in Hz for the C352 chip. A typical value is 24192000. // It should be 0 if there is no C352 chip used." C352: { offset: 0xDC, name: 'C352', minVersion: 0x171 }, // "0xE0: Irem GA20 clock (32 bits) // Input clock rate in Hz for the GA20 chip. A typical value is 3579545. // It should be 0 if there is no GA20 chip used." GA20: { offset: 0xE0, name: 'GA20', minVersion: 0x171 } }; /** * VGM Commands (VGM Specification v1.71) * * "Starting at the location specified by the VGM data offset (or, * offset 0x40 for file versions below 1.50) is found a sequence of commands * containing data written to the chips or timing information. A command is one of:" * * "Some ranges are reserved for future use, with different numbers of operands:" * "On encountering these, the correct number of bytes should be skipped." */ // VGM команды (опкоды) const VGM_COMMANDS = { // "0x4F dd : Game Gear PSG stereo, write dd to port 0x06" 0x4F: { name: 'GG_PSG_STEREO', params: 1, description: 'Game Gear PSG stereo' }, // "0x50 dd : PSG (SN76489/SN76496) write value dd" 0x50: { name: 'PSG_WRITE', params: 1, description: 'PSG (SN76489/SN76496) write value' }, // "0x51 aa dd : YM2413, write value dd to register aa" 0x51: { name: 'YM2413_WRITE', params: 2, description: 'YM2413 write' }, // "0x52 aa dd : YM2612 port 0, write value dd to register aa" 0x52: { name: 'YM2612_PORT0', params: 2, description: 'YM2612 port 0 write' }, // "0x53 aa dd : YM2612 port 1, write value dd to register aa" 0x53: { name: 'YM2612_PORT1', params: 2, description: 'YM2612 port 1 write' }, // "0x54 aa dd : YM2151, write value dd to register aa" 0x54: { name: 'YM2151_WRITE', params: 2, description: 'YM2151 write' }, // "0x55 aa dd : YM2203, write value dd to register aa" 0x55: { name: 'YM2203_WRITE', params: 2, description: 'YM2203 write' }, // "0x56 aa dd : YM2608 port 0, write value dd to register aa" 0x56: { name: 'YM2608_PORT0', params: 2, description: 'YM2608 port 0 write' }, // "0x57 aa dd : YM2608 port 1, write value dd to register aa" 0x57: { name: 'YM2608_PORT1', params: 2, description: 'YM2608 port 1 write' }, // "0x58 aa dd : YM2610 port 0, write value dd to register aa" 0x58: { name: 'YM2610_PORT0', params: 2, description: 'YM2610 port 0 write' }, // "0x59 aa dd : YM2610 port 1, write value dd to register aa" 0x59: { name: 'YM2610_PORT1', params: 2, description: 'YM2610 port 1 write' }, // "0x5A aa dd : YM3812, write value dd to register aa" 0x5A: { name: 'YM3812_WRITE', params: 2, description: 'YM3812 write' }, // "0x5B aa dd : YM3526, write value dd to register aa" 0x5B: { name: 'YM3526_WRITE', params: 2, description: 'YM3526 write' }, // "0x5C aa dd : Y8950, write value dd to register aa" 0x5C: { name: 'Y8950_WRITE', params: 2, description: 'Y8950 write' }, // "0x5D aa dd : YMZ280B, write value dd to register aa" 0x5D: { name: 'YMZ280B_WRITE', params: 2, description: 'YMZ280B write' }, // "0x5E aa dd : YMF262 port 0, write value dd to register aa" 0x5E: { name: 'YMF262_PORT0', params: 2, description: 'YMF262 port 0 write' }, // "0x5F aa dd : YMF262 port 1, write value dd to register aa" 0x5F: { name: 'YMF262_PORT1', params: 2, description: 'YMF262 port 1 write' }, // "0x61 nn nn : Wait n samples, n can range from 0 to 65535 (approx 1.49 // seconds). Longer pauses than this are represented by multiple // wait commands." 0x61: { name: 'WAIT_NNNN', params: 2, description: 'Wait n samples (16-bit)' }, // "0x62 : wait 735 samples (60th of a second), a shortcut for // 0x61 0xdf 0x02" 0x62: { name: 'WAIT_60HZ', params: 0, description: 'Wait 735 samples (60 Hz)' }, // "0x63 : wait 882 samples (50th of a second), a shortcut for // 0x61 0x72 0x03" 0x63: { name: 'WAIT_50HZ', params: 0, description: 'Wait 882 samples (50 Hz)' }, // "0x66 : end of sound data" 0x66: { name: 'END', params: 0, description: 'End of sound data' }, // "0x67 ... : data block: see below" 0x67: { name: 'DATA_BLOCK', params: -1, description: 'Data block' }, // "0x68 ... : PCM RAM write: see below" 0x68: { name: 'PCM_RAM_WRITE', params: -1, description: 'PCM RAM write' }, // "0x7n : wait n+1 samples, n can range from 0 to 15." // Короткие паузы: 0x70 = 1 sample, 0x71 = 2 samples, ... 0x7F = 16 samples ...[...Array(16)].reduce((acc, _, i) => { acc[0x70 + i] = { name: `WAIT_${i + 1}`, params: 0, description: `Wait ${i + 1} samples` }; return acc; }, {}), // "0x8n : YM2612 port 0 address 2A write from the data bank, then wait // n samples; n can range from 0 to 15. Note that the wait is n, // NOT n+1. (Note: Written to first chip instance only.)" // YM2612 DAC паттерн с ожиданием ...[...Array(16)].reduce((acc, _, i) => { acc[0x80 + i] = { name: `YM2612_DAC_WAIT_${i}`, params: 0, description: `YM2612 PCM data write & wait ${i} samples` }; return acc; }, {}), // "0xA0 aa dd : AY8910, write value dd to register aa" 0xA0: { name: 'AY8910_WRITE', params: 2, description: 'AY8910 write' }, // "0xB0 aa dd : RF5C68, write value dd to register aa" 0xB0: { name: 'RF5C164_WRITE', params: 2, description: 'RF5C164 write' }, // "0xB1 aa dd : RF5C164, write value dd to register aa" 0xB1: { name: 'RF5C68_WRITE', params: 2, description: 'RF5C68 write' }, // "0xB2 ad dd : PWM, write value ddd to register a (d is MSB, dd is LSB)" 0xB2: { name: 'PWM_WRITE', params: 2, description: 'PWM write' }, // "0xB3 aa dd : GameBoy DMG, write value dd to register aa // Note: Register 00 equals GameBoy address FF10." 0xB3: { name: 'DMG_WRITE', params: 2, description: 'GameBoy DMG write' }, // "0xB4 aa dd : NES APU, write value dd to register aa" 0xB4: { name: 'NES_APU_WRITE', params: 2, description: 'NES APU write' }, // "0xB5 aa dd : MultiPCM, write value dd to register aa" 0xB5: { name: 'MULTI_PCM_WRITE', params: 2, description: 'MultiPCM write' }, // "0xB6 aa dd : uPD7759, write value dd to register aa" 0xB6: { name: 'UPD7759_WRITE', params: 2, description: 'uPD7759 write' }, // "0xB7 aa dd : OKIM6258, write value dd to register aa" 0xB7: { name: 'OKIM6258_WRITE', params: 2, description: 'OKIM6258 write' }, // "0xB8 aa dd : OKIM6295, write value dd to register aa" 0xB8: { name: 'OKIM6295_WRITE', params: 2, description: 'OKIM6295 write' }, // "0xB9 aa dd : HuC6280, write value dd to register aa" 0xB9: { name: 'HUC6280_WRITE', params: 2, description: 'HuC6280 write' }, // "0xBA aa dd : K053260, write value dd to register aa" 0xBA: { name: 'K053260_WRITE', params: 2, description: 'K053260 write' }, // "0xBB aa dd : Pokey, write value dd to register aa" 0xBB: { name: 'POKEY_WRITE', params: 2, description: 'Pokey write' }, // "0xBC aa dd : WonderSwan, write value dd to register aa" 0xBC: { name: 'WONDERSWAN_WRITE', params: 2, description: 'WonderSwan write' }, // "0xBD aa dd : SAA1099, write value dd to register aa" 0xBD: { name: 'SAA1099_WRITE', params: 2, description: 'SAA1099 write' }, // "0xBE aa dd : ES5506, write 8-bit value dd to register aa" 0xBE: { name: 'ES5506_WRITE', params: 2, description: 'ES5506 write' }, // "0xBF aa dd : GA20, write value dd to register aa" 0xBF: { name: 'GA20_WRITE', params: 2, description: 'GA20 write' }, // "0xC0 bbaa dd : Sega PCM, write value dd to memory offset aabb" 0xC0: { name: 'SEGA_PCM_WRITE', params: 3, description: 'Sega PCM write' }, // "0xC1 bbaa dd : RF5C68, write value dd to memory offset aabb" 0xC1: { name: 'RF5C164_MEM_WRITE', params: 3, description: 'RF5C164 memory write' }, // "0xC2 bbaa dd : RF5C164, write value dd to memory offset aabb" 0xC2: { name: 'RF5C68_MEM_WRITE', params: 3, description: 'RF5C68 memory write' }, // "0xC3 cc bbaa : MultiPCM, write set bank offset aabb to channel cc" 0xC3: { name: 'YM2608_WRITE_PORT2', params: 3, description: 'YM2608 write port 2/3' }, // "0xE0 dddddddd : seek to offset dddddddd (Intel byte order) in PCM data bank" 0xE0: { name: 'SEEK_PCM', params: 4, description: 'Seek to offset in PCM data bank' }, // "0xE1 aabb ddee: C352 write 16-bit value ddee to register aabb" 0xE1: { name: 'C352_WRITE', params: 3, description: 'C352 write' } }; /** * Анализирует gzip заголовок VGZ файла согласно RFC 1952 * * \"The normal file extension is .vgm but files can also be GZip compressed into * .vgz files. However, a VGM player should attempt to support compressed and * uncompressed files with either extension. (ZLib's GZIO library makes this * trivial to implement.)\" * * GZIP Заголовок (RFC 1952): * +---+---+---+---+---+---+---+---+---+---+ * |ID1|ID2|CM |FLG| MTIME |XFL|OS | * +---+---+---+---+---+---+---+---+---+---+ * * \"A gzip file consists of a series of 'members' (compressed data sets). * The format of each member is specified in the following section. The * members simply appear one after another in the file, with no additional * information before, between, or after them.\" * * ID1 (IDentification 1) = 0x1f * ID2 (IDentification 2) = 0x8b * CM (Compression Method) = обычно 8 (deflate) * FLG (FLaGs) = различные флаги * MTIME (Modification TIME) = время последней модификации файла * XFL (eXtra FLags) = дополнительные флаги компрессии * OS (Operating System) = файловая система/ОС, где создавался файл * * @param {Buffer} compressedData - Сжатые gzip данные * @returns {Object} Информация о gzip заголовке согласно RFC 1952 * @throws {Error} При неверной сигнатуре GZIP */ function analyzeGZIP(compressedData) { const info = {}; // Проверка GZIP magic number согласно RFC 1952: // \"The member begins with a fixed 10-byte header, containing a magic number, // a version indicator, a timestamp, extra flags, and an OS indicator.\" if (compressedData[0] !== 0x1f || compressedData[1] !== 0x8b) { throw new Error('Invalid GZIP signature'); } // Compression method (RFC 1952): // \"CM (Compression Method) This identifies the compression method used in the file. // CM = 8 denotes the 'deflate' compression method with a window size up to 32K.\" const method = compressedData[2]; const methodNames = { 0: 'Reserved 0', 1: 'Reserved 1 (compress)', 2: 'Reserved 2 (pack)', 3: 'Reserved 3 (lzh)', 4: 'Reserved 4', 5: 'Reserved 5', 6: 'Reserved 6', 7: 'Reserved 7', 8: 'DEFLATE (LZ77 + Huffman)', // 9-255 зарезервированы }; info.compressionMethod = methodNames[method] || `Reserved/Unknown (${method})`; info.compressionMethodCode = method; // Флаги гzip заголовка (RFC 1952): // "FLG (FLaGs) Reserved FLG bits must be zero. The remaining FLG bits are: // bit 0 FTEXT, bit 1 FHCRC, bit 2 FEXTRA, bit 3 FNAME, bit 4 FCOMMENT" const flags = compressedData[3]; info.flags = { FTEXT: !!(flags & 0x01), FHCRC: !!(flags & 0x02), FEXTRA: !!(flags & 0x04), FNAME: !!(flags & 0x08), FCOMMENT: !!(flags & 0x10) }; // Время модификации файла (RFC 1952): // "MTIME (Modification Time) This gives the most recent modification time of the // original file being compressed. The time is in Unix format" const mtime = compressedData.readUInt32LE(4); info.timestamp = mtime; if (mtime > 0) { info.modificationDate = new Date(mtime * 1000).toISOString(); } // Дополнительные флаги компрессии (RFC 1952): // "XFL (eXtra FLags) These may be used by specific compression methods. // The 'deflate' method (CM = 8) sets these flags as follows: // XFL = 2 - compressor used maximum compression, slowest algorithm // XFL = 4 - compressor used fastest algorithm" const xfl = compressedData[8]; const compressionLevels = { 2: 'Maximum compression (slowest)', 4: 'Fastest compression' }; info.compressionLevel = compressionLevels[xfl] || 'Default compression'; info.compressionLevelCode = xfl; // Операционная система (RFC 1952): // "OS (Operating System) This identifies the type of file system on which compression // took place. This may be useful in determining end-of-line convention for text files." const os = compressedData[9]; const osNames = { 0: 'FAT filesystem (MS-DOS, OS/2, NT/Win32)', 1: 'Amiga', 2: 'VMS (or OpenVMS)', 3: 'Unix', 4: 'VM/CMS', 5: 'Atari TOS', 6: 'HPFS filesystem (OS/2, NT)', 7: 'Macintosh', 8: 'Z-System', 9: 'CP/M', 10: 'TOPS-20', 11: 'NTFS filesystem (NT)', 12: 'QDOS', 13: 'Acorn RISCOS', 255: 'Unknown' }; info.os = osNames[os] || `Unknown (${os})`; info.osCode = os; return info; } /** * Читает VGZ файл и возвращает распакованный VGM + информацию о сжатии * * \"The normal file extension is .vgm but files can also be GZip compressed into * .vgz files. However, a VGM player should attempt to support compressed and * uncompressed files with either extension. (ZLib's GZIO library makes this * trivial to implement.)\" * * VGZ (VGM GZip) файлы содержат VGM данные, сжатые алгоритмом gzip. * Эта функция автоматически определяет формат файла и распаковывает при необходимости. * * @param {string} filePath - Путь к VGZ файлу * @returns {Object} { data: Buffer с VGM данными, gzipInfo: инфо о сжатии } * @throws {Error} При ошибках чтения или распаковки */ function readVGZ(filePath) { try { const compressed = fs.readFileSync(filePath); const uncompressed = zlib.gunzipSync(compressed); // Анализируем gzip заголовок const gzipInfo = analyzeGZIP(compressed); // Добавляем информацию о размерах и степени сжатия gzipInfo.compressedSize = compressed.length; gzipInfo.uncompressedSize = uncompressed.length; gzipInfo.compressionRatio = ((1 - compressed.length / uncompressed.length) * 100).toFixed(2); gzipInfo.compressionRatioText = `${gzipInfo.compressionRatio}%`; return { data: uncompressed, gzipInfo }; } catch (err) { throw new Error(`Failed to read/decompress VGZ: ${err.message}`); } } /** * Парсит заголовок VGM */ function parseVGMHeader(vgm) { const header = {}; // Проверка сигнатуры const signature = vgm.slice(0, 4).toString(); if (signature !== 'Vgm ') { throw new Error('Invalid VGM signature'); } header.signature = signature; // Версия (формат: 0xAABB где AA - major, BB - minor в BCD) const versionRaw = vgm.readUInt32LE(0x08); // Версионная система VGM использует BCD для minor version // Например: 0x0171 = версия 1.71, 0x0161 = версия 1.61 const major = (versionRaw >> 8) & 0xFF; const minorBCD = versionRaw & 0xFF; // Конвертируем minor version из BCD в десятичную const minorHigh = (minorBCD >> 4) & 0x0F; const minorLow = minorBCD & 0x0F; const minor = minorHigh * 10 + minorLow; header.version = `${major}.${minor.toString().padStart(2, '0')}`; header.versionHex = versionRaw; header.versionRaw = versionRaw; // Для отладки // \"VGM files use a sample rate of 44100 Hz. All wait times are given as a // number of samples to wait at this rate.\" // \"VGMs run with a rate of 44100 samples per second. All sample values use // this unit.\" // Фиксированная частота дискретизации 44,1 кГц для всех VGM файлов header.sampleRate = 44100; header.sampleRateNote = 'Fixed by VGM specification'; // "0x18: Total # samples (32 bits) // Total of all wait values in the file." header.totalSamples = vgm.readUInt32LE(0x18); header.totalSeconds = header.totalSamples / header.sampleRate; // "0x1C: Loop offset (32 bits) // Relative offset to loop point, or 0 if no loop." const loopOffset = vgm.readUInt32LE(0x1C); header.loopOffset = loopOffset > 0 ? loopOffset + 0x1C : 0; // "0x20: Loop # samples (32 bits) // Number of samples in one loop, or 0 if there is no loop. // Total of all wait values between the loop point and the end of the file." header.loopSamples = vgm.readUInt32LE(0x20); header.loopSeconds = header.loopSamples / header.sampleRate; header.hasLoop = header.loopSamples > 0; // Длительность введения до начала цикла (расчетное значение): // intro_samples = total_samples - loop_samples header.introSamples = header.totalSamples - header.loopSamples; header.introSeconds = header.introSamples / header.sampleRate; // "[VGM 1.01 additions:] // 0x24: Rate (32 bits) // "Rate" of recording in Hz, used for rate scaling on playback. It is // typically 50 for PAL systems and 60 for NTSC systems." header.rate = vgm.readUInt32LE(0x24); // "[VGM 1.50 additions:] // 0x34: VGM data offset (32 bits) // Relative offset to VGM data stream. // If the VGM data starts at absolute offset 0x40, this will contain // value 0x0000000C. For versions prior to 1.50, it should be 0 and the // VGM data must start at offset 0x40." let dataOffset = 0x40; // По умолчанию для версий < 1.50 if (versionRaw >= 0x150) { const vgmDataOffset = vgm.readUInt32LE(0x34); if (vgmDataOffset > 0) { dataOffset = 0x34 + vgmDataOffset; } } header.dataOffset = dataOffset; // \"0x14: GD3 offset (32 bits) // Relative offset to GD3 tag. 0 if no GD3 tag. // GD3 tags are descriptive tags similar in use to ID3 tags in MP3 files.\" // GD3 содержит метаданные: название трека, игры, автора, систему и дату const gd3Offset = vgm.readUInt32LE(0x14); header.gd3Offset = gd3Offset > 0 ? gd3Offset + 0x14 : 0; // Размер заголовка в зависимости от версии VGM let headerSize = 0x40; // v1.00-1.01: 64 bytes if (versionRaw >= 0x150) { // v1.50+: Используем VGM data offset для определения размера const vgmDataOffset = vgm.readUInt32LE(0x34); if (vgmDataOffset > 0) { headerSize = 0x34 + vgmDataOffset; } else { // Если offset не указан, используем размер по умолчанию для версии if (versionRaw >= 0x171) { headerSize = 0x100; // v1.71+: 256 bytes } else if (versionRaw >= 0x161) { headerSize = 0xC0; // v1.61-1.70: 192 bytes } else if (versionRaw >= 0x151) { headerSize = 0x80; // v1.51-1.60: 128 bytes } else { headerSize = 0x80; // v1.50: 128 bytes } } } header.headerSize = headerSize; header.maxHeaderOffset = headerSize; // Чипы (с учетом версии VGM) header.chips = []; for (const [chipKey, chipInfo] of Object.entries(CHIP_OFFSETS)) { if (chipKey === 'GD3') continue; // Пропускаем GD3 offset // Проверяем, доступен ли чип в данной версии VGM if (chipInfo.minVersion && versionRaw < chipInfo.minVersion) { continue; // Чип недоступен в этой версии } const offset = chipInfo.offset; // Проверяем, что offset находится в пределах заголовка if (offset + 4 <= headerSize && offset + 4 <= vgm.length) { const clockRaw = vgm.readUInt32LE(offset); // \"Dual Chip Support // These chips support two instances of a chip in one vgm: // PSG, YM2413, YM2612, YM2151, YM2203, YM2608, YM2610, YM3812, YM3526, Y8950, // YMZ280B, YMF262, YMF278B, YMF271, AY8910, GameBoy DMG, NES APU, MultiPCM, // uPD7759, OKIM6258, OKIM6295, K051649, K054539, HuC6280, C140, K053260, Pokey, // SCSP // // Dual chip support is activated by setting bit 30 (0x40000000) in the chip's // clock value. (Note: The PSG needs this bit set for T6W28 mode.)\" const isDualChip = (clockRaw & 0x40000000) !== 0; const actualClock = clockRaw & ~0x40000000; // Убираем dual chip бит if (actualClock > 0 && clockRaw < 0x80000000) { // Проверяем raw значение на адекватность // Список чипов поддерживающих dual mode по официальной спецификации VGM v1.71 // Note: PSG (SN76489) использует бит 30 для T6W28 mode, не для dual chip const dualChipSupported = [ 'YM2413', 'YM2612', 'YM2151', 'YM2203', 'YM2608', 'YM2610', 'YM3812', 'YM3526', 'Y8950', 'YMZ280B', 'YMF262', 'YMF278B', 'YMF271', 'AY8910', 'DMG', 'NES_APU', 'MultiPCM', 'uPD7759', 'OKIM6258', 'OKIM6295', 'K051649', 'K054539', 'HuC6280', 'C140', 'K053260', 'Pokey', 'SCSP' // SAA1099 не в официальном списке, но поддерживается практически ]; // Специальная обработка для PSG (SN76489): бит 30 указывает T6W28 mode const isT6W28 = chipKey === 'SN76489' && isDualChip; const isPsgDualMode = chipKey === 'SN76489' && isDualChip; // Форматируем версию добавления чипа (BCD формат) let versionAddedStr = '1.00'; if (chipInfo.minVersion) { const vMajor = (chipInfo.minVersion >> 8) & 0xFF; const vMinorHex = chipInfo.minVersion & 0xFF; const vMinorHigh = (vMinorHex >> 4) & 0x0F; const vMinorLow = vMinorHex & 0x0F; const vMinor = vMinorHigh * 10 + vMinorLow; versionAddedStr = `${vMajor}.${vMinor.toString().padStart(2, '0')}`; } // Определяем название чипа с учетом dual mode и специальных случаев let chipDisplayName = chipInfo.name; let isDualChipForDisplay = false; if (chipKey === 'SN76489' && isDualChip) { // PSG: бит 30 означает T6W28 mode (NeoGeo Pocket variant) chipDisplayName = 'T6W28 PSG'; isDualChipForDisplay = false; } else if (isDualChip && (dualChipSupported.includes(chipKey) || chipKey === 'SAA1099')) { // Официально поддерживаемые dual chip + SAA1099 (практическая поддержка) chipDisplayName = `2x ${chipInfo.name}`; isDualChipForDisplay = true; } header.chips.push({ type: chipKey, name: chipDisplayName, clock: actualClock, clockRaw: clockRaw, isDualChip: isDualChipForDisplay, isT6W28: isT6W28, clockMHz: (actualClock / 1000000).toFixed(3), versionAdded: versionAddedStr }); } } } return header; } /** * Парсит GD3 теги (метаданные) из VGM файла * * \"GD3 tags are descriptive tags similar in use to ID3 tags in MP3 files. * See the GD3 specification for more details. The GD3 tag is usually * stored immediately after the VGM data.\" * * GD3 тег содержит информацию о треке в UTF-16LE кодировке: * - Track title (Eng + Jpn) * - Game title (Eng + Jpn) * - System name (Eng + Jpn) * - Author (Eng + Jpn) * - Release date * - Creator/Dumper * - Notes * * @param {Buffer} vgmBuffer - Буфер VGM файла * @param {number} gd3Offset - Смещение GD3 тега * @returns {Object|null} Распарсенные GD3 теги или null */ function parseGD3(vgmBuffer, gd3Offset) { if (!gd3Offset || gd3Offset >= vgmBuffer.length) { return null; } try { // Проверяем сигнатуру GD3 const signature = vgmBuffer.slice(gd3Offset, gd3Offset + 4).toString(); if (signature !== 'Gd3 ') { return null; } const version = vgmBuffer.readUInt32LE(gd3Offset + 4); const dataLength = vgmBuffer.readUInt32LE(gd3Offset + 8); let pos = gd3Offset + 12; const strings = []; // Читаем UTF-16LE строки (каждая заканчивается на 0x0000) for (let i = 0; i < 10; i++) { // GD3 содержит 10 строк let str = ''; while (pos + 1 < vgmBuffer.length) { const char = vgmBuffer.readUInt16LE(pos); if (char === 0) break; str += String.fromCharCode(char); pos += 2; } strings.push(str); pos += 2; // Пропускаем нулевой терминатор } return { version: version, trackTitle: strings[0] || '', trackTitleJapanese: strings[1] || '', gameName: strings[2] || '', gameNameJapanese: strings[3] || '', systemName: strings[4] || '', systemNameJapanese: strings[5] || '', trackAuthor: strings[6] || '', trackAuthorJapanese: strings[7] || '', date: strings[8] || '', ripper: strings[9] || '', notes: strings[10] || '' }; } catch (error) { return null; } } /** * Парсит команды VGM (опкоды) согласно VGM спецификации v1.71 * * \"The VGM data is made up of command bytes, some of which are followed by * data bytes. Commands 0x00-0x4f and 0x50-0xff are used for different groups.\" * * \"VGM files use a sample rate of 44100 Hz. All wait times are given as a * number of samples to wait at this rate.\" * * Основные группы команд: * 0x50-0x5f: PSG (SN76489) - \"0x50 dd : PSG (SN76489/SN76496) write value dd\" * 0x61: Wait - \"0x61 nnnn : Wait nnnn samples\" * 0x62: Wait - \"0x62 : Wait 735 samples (60th of a second)\" * 0x63: Wait - \"0x63 : Wait 882 samples (50th of a second)\" * 0x66: End - \"0x66 : End of sound data\" * 0x70-0x8f: Wait short - \"0x7n : Wait n+1 samples, n can range from 0 to 15\" * 0xa0-0xa9: YM2612 - \"0xA0 aa dd : YM2612 port 0 address aa data dd\" * 0xb0-0xb8: RF5C68 - \"0xB0 aa dd : RF5C68 register aa data dd\" * 0xc0-0xdf: Sega PCM - \"0xCn dddd : Sega PCM memory write\" * * @param {Buffer} vgm - VGM данные * @param {number} dataOffset - Смещение начала команд в VGM * @returns {Object} Объект с проанализированными командами и статистикой */ function parseVGMCommands(vgm, dataOffset) { const commands = {}; let position = dataOffset; let totalSamples = 0; let commandCount = 0; let writeCommandCount = 0; // Счетчик команд записи данных в чипы (не WAIT команды) let totalCommandBytes = 0; // Общее количество байт всех команд (включая WAIT) let lastWritePosition = 0; // Позиция в samples последней команды записи данных в чип let maxWriteGap = 0; // Максимальный интервал между командами записи в samples const writeGaps = []; // Все интервалы между командами (для статистики) while (position < vgm.length) { const opcode = vgm[position]; commandCount++; // Обработка команд if (opcode === 0x66) { // End of sound data const key = opcode; if (!commands[key]) { commands[key] = { opcode: `0x${opcode.toString(16).toUpperCase().padStart(2, '0')}`, name: 'END', description: 'End of sound data', count: 0, samples: 0 }; } commands[key].count++; totalCommandBytes += 1; break; } else if (opcode === 0x61) { // \"0x61 nn nn : Wait n samples, n can range from 0 to 65535 (approx 1.49 seconds)\" // Wait n samples - точное ожидание на указанное количество семплов const samples = vgm.readUInt16LE(position + 1); const key = `0x61_${samples}`; if (!commands[key]) { commands[key] = { opcode: '0x61', name: `WAIT_NN(${samples})`, description: `Wait ${samples} samples`, count: 0, samples: 0, waitValue: samples }; } commands[key].count++; commands[key].samples += samples; totalSamples += samples; totalCommandBytes += 3; position += 3; // opcode + 2 bytes для samples } else if (opcode === 0x62) { // "0x62 : Wait 735 samples (60th of a second)" // Стандартная пауза для NTSC (60 Гц) систем: 735/44100 = 16.67ms if (!commands[opcode]) { commands[opcode] = { opcode: `0x${opcode.toString(16).toUpperCase().padStart(2, '0')}`, name: 'WAIT_60HZ', description: 'Wait 735 samples (60 Hz)', count: 0, samples: 0 }; } commands[opcode].count++; commands[opcode].samples += 735; totalSamples += 735; totalCommandBytes += 1; position += 1; } else if (opcode === 0x63) { // "0x63 : Wait 882 samples (50th of a second)" // Стандартная пауза для PAL (50 Гц) систем: 882/44100 = 20ms if (!commands[opcode]) { commands[opcode] = { opcode: `0x${opcode.toString(16).toUpperCase().padStart(2, '0')}`, name: 'WAIT_50HZ', description: 'Wait 882 samples (50 Hz)', count: 0, samples: 0 }; } commands[opcode].count++; commands[opcode].samples += 882; totalSamples += 882; totalCommandBytes += 1; position += 1; } else if (opcode >= 0x70 && opcode <= 0x7F) { // Wait 1-16 samples const samples = opcode - 0x6F; if (!commands[opcode]) { const cmdInfo = VGM_COMMANDS[opcode]; commands[opcode] = { opcode: `0x${opcode.toString(16).toUpperCase().padStart(2, '0')}`, name: cmdInfo.name, description: cmdInfo.description, count: 0, samples: 0 }; } commands[opcode].count++; commands[opcode].samples += samples; totalSamples += samples; totalCommandBytes += 1; position += 1; } else if (opcode >= 0x80 && opcode <= 0x8F) { // YM2612 PCM data + wait const samples = opcode - 0x80; if (!commands[opcode]) { commands[opcode] = { opcode: `0x${opcode.toString(16).toUpperCase().padStart(2, '0')}`, name: `YM2612_DAC_WAIT_${samples}`, description: `YM2612 DAC write + wait ${samples} samples`, count: 0, samples: 0 }; } commands[opcode].count++; commands[opcode].samples += samples; totalSamples += samples; totalCommandBytes += 1; position += 1; } else if (opcode === 0x67) { // Data block: 0x67 0x66 tt ss ss ss ss (data) if (!commands[opcode]) { commands[opcode] = { opcode: `0x${opcode.toString(16).toUpperCase().padStart(2, '0')}`, name: 'DATA_BLOCK', description: 'Data block', count: 0, samples: 0 }; } commands[opcode].count++; position += 2; // Skip 0x67 0x66 const blockType = vgm[position++]; const blockSize = vgm.readUInt32LE(position); totalCommandBytes += 2 + 1 + 4 + blockSize; // 0x67 + 0x66 + type + size + data position += 4 + blockSize; } else if (opcode === 0x68) { // PCM RAM write if (!commands[opcode]) { commands[opcode] = { opcode: `0x${opcode.toString(16).toUpperCase().padStart(2, '0')}`, name: 'PCM_RAM_WRITE', description: 'PCM RAM write', count: 0, samples: 0 }; } commands[opcode].count++; totalCommandBytes += 12; position += 12; // Fixed size } else { // Стандартные команды записи const cmdInfo = VGM_COMMANDS[opcode]; if (!commands[opcode]) { if (cmdInfo) { commands[opcode] = { opcode: `0x${opcode.toString(16).toUpperCase().padStart(2, '0')}`, name: cmdInfo.name, description: cmdInfo.description, count: 0, samples: 0 }; } else { commands[opcode] = { opcode: `0x${opcode.toString(16).toUpperCase().padStart(2, '0')}`, name: `UNKNOWN_0x${opcode.toString(16).toUpperCase()}`, description: 'Unknown command', count: 0, samples: 0 }; } } commands[opcode].count++; // Отслеживаем команды записи (не WAIT, не END, не DATA_BLOCK) const isWriteCommand = opcode !== 0x66 && opcode !== 0x67 && opcode !== 0x68 && opcode !== 0x61 && opcode !== 0x62 && opcode !== 0x63 && (opcode < 0x70 || opcode > 0x7F) && (opcode < 0x80 || opcode > 0x8F); if (isWriteCommand) { writeCommandCount++; const gap = totalSamples - lastWritePosition; // Игнорируем задержки > 1000 семплов if (gap > 0 && gap <= 1000) { writeGaps.push(gap); if (gap > maxWriteGap) { maxWriteGap = gap; } } lastWritePosition = totalSamples; } if (cmdInfo) { if (cmdInfo.params > 0) { totalCommandBytes += 1 + cmdInfo.params; position += 1 + cmdInfo.params; } else if (cmdInfo.params === 0) { totalCommandBytes += 1; position += 1; } else { // Variable length - skip safely totalCommandBytes += 1; position += 1; } } else { // Unknown command - try to skip totalCommandBytes += 1; position += 1; } } // Защита от бесконечного цикла if (commandCount > 10000000) { console.warn('Too many commands, stopping parsing'); break; } } // Рассчитываем среднюю задержку const avgWriteGap = writeGaps.length > 0 ? writeGaps.reduce((sum, gap) => sum + gap, 0) / writeGaps.length : 0; return { commands: Object.values(commands).sort((a, b) => b.count - a.count), totalCommands: commandCount, totalSamples, totalSeconds: totalSamples / 44100, totalCommandBytes: totalCommandBytes, writeCommands: writeCommandCount, maxWriteGap: maxWriteGap, avgWriteGap: avgWriteGap, maxWriteGapMs: (maxWriteGap / 44100 * 1000).toFixed(2), avgWriteGapMs: (avgWriteGap / 44100 * 1000).toFixed(2) }; } /** * Полный анализ VGM файла согласно спецификации v1.71 * * \"Starting at the location specified by the VGM data offset (or, * offset 0x40 for file versions below 1.50) is found a sequence of commands * containing data written to the chips or timing information.\" * * Выполняет комплексный анализ VGM файла: * - Парсит заголовок с chip clocks и метаданными * - Анализирует команды VGM stream * - Подсчитывает статистику: команды, samples, timing * - Определяет используемые чипы и их частоты * - Извлекает информацию о loop points * * @param {Buffer} vgmBuffer - Буфер с VGM данными * @returns {Object} Полный анализ файла с header, commands и статистикой */ function analyzeVGM(vgmBuffer) { const header = parseVGMHeader(vgmBuffer); const commandData = parseVGMCommands(vgmBuffer, header.dataOffset); return { header, commands: commandData.commands, stats: { totalCommands: commandData.totalCommands, totalSamples: commandData.totalSamples, totalCommandBytes: commandData.totalCommandBytes, calculatedSeconds: commandData.totalSeconds, writeCommands: commandData.writeCommands, maxWriteGap: commandData.maxWriteGap, avgWriteGap: commandData.avgWriteGap, maxWriteGapMs: commandData.maxWriteGapMs, avgWriteGapMs: commandData.avgWriteGapMs } }; } /** * Анализ VGZ файла с автоматической распаковкой * * \"The normal file extension is .vgm but files can also be GZip compressed into * .vgz files. However, a VGM player should attempt to support compressed and * uncompressed files with either extension.\" * * VGZ (VGM GZip) файлы содержат стандартные VGM данные, сжатые алгоритмом gzip. * Эта функция автоматически распаковывает VGZ и выполняет полный анализ VGM, * добавляя дополнительную информацию о сжатии (степень сжатия, размеры, метаданные gzip). * * @param {string} filePath - Путь к VGZ файлу * @returns {Object} Полный анализ VGM + информация о gzip сжатии * @throws {Error} При ошибках чтения, распаковки или анализа */ function analyzeVGZFile(filePath) { const { data: vgm, gzipInfo } = readVGZ(filePath); const analysis = analyzeVGM(vgm); // Добавляем информацию о сжатии analysis.compression = gzipInfo; return analysis; } /** * Декомпилятор VGM файлов с полной поддержкой спецификации v1.71 * * \"Starting at the location specified by the VGM data offset (or, * offset 0x40 for file versions below 1.50) is found a sequence of commands * containing data written to the chips or timing information.\" * * Этот декомпилятор обеспечивает: * - Полную информацию о заголовке VGM с chip clocks * - Парсинг GD3 метаданных (track titles, game info, etc) * - Детекцию loop points и структуры файла * - Временные метки для навигации по треку * - Статистику команд и длительности * - Поддержку dual chip режимов * * @param {Buffer} vgmBuffer - Буфер с VGM данными * @param {Object} options - Опции декомпиляции * @returns {Object} Результат декомпиляции со всей информацией */ function decompileVGM(vgmBuffer, options = {}) { const { showTimeMarks = true, // Показывать временные метки каждую секунду showAllCommands = true, // Показывать ВСЕ команды по умолчанию maxCommands = 0, // Без ограничений по умолчанию showStatistics = true, // Показывать статистику showHeader = true // Показывать полную информацию о заголовке } = options; const header = parseVGMHeader(vgmBuffer); // Парсим GD3 теги если есть let gd3 = null; if (header.gd3Offset > 0) { gd3 = parseGD3(vgmBuffer, header.gd3Offset); } const decompilation = { header: header, gd3: gd3, commands: [], statistics: { totalCommands: 0, totalSamples: 0, totalSeconds: 0, writeCommands: 0, waitCommands: 0, dataBlocks: 0, timeMarks: [], loopFound: false, loopSample: 0 } }; // \"VGM files use a sample rate of 44100 Hz. All wait times are given as a // number of samples to wait at this rate.\" // // VGM стандарт использует фиксированную частоту дискретизации 44,1 кГц для всех timing операций const SAMPLE_RATE = 44100; const dataOffset = header.dataOffset; let position = dataOffset; let currentSamples = 0; let lastTimeMarkSeconds = 0; let commandIndex = 0; let commandNumber = 1; // Номер команды для вывода (начинаем с 1) // Вычисляем позицию лупа в samples const loopSample = header.totalSamples - header.loopSamples; if (header.hasLoop && header.loopSamples > 0) { decompilation.statistics.loopSample = loopSample; decompilation.statistics.loopFound = true; } /** * Добавляет команду в декомпилированный VGM вывод * * Центральная функция для записи всех VGM команд в результирующий декомпилированный * код. Учитывает ограничения на количество команд (maxCommands) и опцию showAllCommands. * Также ведет статистику общего количества команд. * * @param {Object} cmd - Объект команды с полями type, position, offset, raw, mnemonic, description, dataBytes */ function addCommand(cmd) { if (maxCommands === 0 || showAllCommands || decompilation.commands.length < maxCommands) { // Специальная обработка для временных меток и меток петли if (cmd.type === 'TIME_MARK' || cmd.type === 'LOOP_MARK') { cmd.command = cmd.command; // Используем готовый форматированный текст } else { // Форматируем обычные команды согласно новому формату cmd.command = formatCommand(commandNumber, cmd.raw || [cmd.opcode || 0], cmd.mnemonic || cmd.description, cmd.dataBytes); commandNumber++; // Увеличиваем номер команды только для обычных команд } decompilation.commands.push(cmd); } // Статистика для всех команд, кроме временных меток if (cmd.type !== 'TIME_MARK' && cmd.type !== 'LOOP_MARK') { decompilation.statistics.totalCommands++; } } /** * Добавляет временные метки в декомпилированный код * * \"VGM files use a sample rate of 44100 Hz. All wait times are given as a * number of samples to wait at this rate.\" * * Добавляет визуальные временные метки каждую секунду для улучшения читаемости * декомпилированного VGM кода. Помогает ориентироваться во времени воспроизведения. * * @param {number} samples - Текущее количество samples с начала воспроизведения */ function addTimeMark(samples) { const seconds = Math.floor(samples / SAMPLE_RATE); if (seconds > lastTimeMarkSeconds) { const timeStr = formatTime(seconds); addCommand({ type: 'TIME_MARK', position: position, offset: `0x${position.toString(16).toUpperCase()}`, samples: samples, seconds: seconds, timeString: timeStr, command: `\n*** time ${timeStr} ***********`, description: `Временная метка` }); decompilation.statistics.timeMarks.push(seconds); lastTimeMarkSeconds = seconds; } } /** * Проверяет и отмечает точку начала цикла (loop point) * * \"4+4 bytes: Loop offset (32 bits, starting from 0x1C) * Relative offset to loop point, or 0 if no looping. This is added to the VGM * position 0x1C (where the VGM header ends) to get the absolute position from the * beginning of the file.\" * * VGM файлы могут содержать информацию о цикле (loop). Эта функция следит за * текущей позицией в samples и добавляет визуальную метку в декомпилированный * код когда достигается точка начала цикла. * * @param {number} samples - Текущее количество samples с начала воспроизведения */ function checkLoopPoint(samples) { if (decompilation.statistics.loopFound && samples >= loopSample && !decompilation.loopMarkAdded) { const loopTimeStr = formatTime(Math.floor(loopSample / SAMPLE_RATE)); const loopSampleStr = loopSample.toString(); addCommand({ type: 'LOOP_MARK', position: position, offset: `0x${position.toString(16).toUpperCase()}`, samples: loopSample, seconds: loopSample / SAMPLE_RATE, timeString: loopTimeStr, command: `\n*** LOOP POINT ${loopTimeStr} ***********`, description: `Точка начала цикла` }); decompilation.loopMarkAdded = true; } } // Helper function для создания временной метки function createTimeMark(position, samples, seconds) { const timeStr = formatTime(seconds); return { type: 'TIME_MARK', position: position, offset: `0x${position.toString(16).toUpperCase()}`, samples: samples, seconds: seconds, timeString: timeStr, command: `\n***************** ${timeStr} **************`, description: `Временная метка` }; } /** * Форматирует команду VGM в декомпилированный вид * * Формат: 0000001: 62 00 10 WAIT (31) * ^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^ * номер hex байты + мнемоника * * @param {number} commandNumber - Номер команды (начиная с 1) * @param {Array} rawBytes - Байты команды * @param {string} mnemonic - Мнемоника команды * @param {Array} dataBytes - Дополнительные данные (для массивов) * @returns {string} Отформатированная строка команды */ function formatCommand(commandNumber, rawBytes, mnemonic, dataBytes = null) { const number = commandNumber.toString().padStart(7, '0'); const hexBytes = rawBytes.map(b => b.toString(16).toUpperCase().padStart(2, '0')).join(' '); const hex = hexBytes.padEnd(24, ' '); // Выравнивание для hex части let result = `${number}: ${hex}${mnemonic}`; // Если есть дополнительные данные, выводим их отдельными строками по 16 байт if (dataBytes && dataBytes.length > 0) { result += '\n'; // Пустая строка перед данными for (let i = 0; i < dataBytes.length; i += 16) { const chunk = dataBytes.slice(i, i + 16); const hexChunk = chunk.map(b => b.toString(16).toUpperCase().padStart(2, '0')).join(' '); const padding = ' '.repeat(16); // Отступ для выравнивания с основной командой result += `${padding}${hexChunk}`; if (i + 16 < dataBytes.length) { result += '\n'; } } result += '\n'; // Пустая строка после данных } return result; } /** * Форматирует время в формате MM:SS * * Используется для отображения временных меток в декомпилированном VGM. * Преобразует количество секунд в стандартный формат времени. * * @param {number} totalSeconds - Общее количество секунд * @returns {string} Время в формате "MM:SS" */ function formatTime(totalSeconds) { const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } while (position < vgmBuffer.length && (maxCommands === 0 || commandIndex < maxCommands * 2)) { const opcode = vgmBuffer[position]; commandIndex++; // Проверяем точку лупа checkLoopPoint(currentSamples); // Добавляем временную метку каждую секунду if (showTimeMarks) { addTimeMark(currentSamples); } const cmd = { type: 'COMMAND', position: position, offset: `0x${position.toString(16).toUpperCase().padStart(6, '0')}`, opcode: `0x${opcode.toString(16).toUpperCase().padStart(2, '0')}`, samples: currentSamples, ticks: currentSamples, seconds: (currentSamples / SAMPLE_RATE).toFixed(3), raw: [], parameters: [], command: '', description: '' }; // Парсинг команд по спецификации VGM v1.71 if (opcode === 0x66) { // End of sound data cmd.raw = [opcode]; cmd.mnemonic = 'END'; cmd.description = 'End of sound data'; addCommand(cmd); break; } else if (opcode === 0x67) { // \"0x67 0x66 tt ss ss ss ss (data)\" // Data block: contains compressed data or large data arrays if (position + 6 < vgmBuffer.length) { const blockMarker = vgmBuffer[position + 1]; // Should be 0x66 const blockType = vgmBuffer[position + 2]; const blockSize = vgmBuffer.readUInt32LE(position + 3); // Команда + маркер + тип + байты размера в основной строке const sizeBytes = Array.from(vgmBuffer.subarray(position + 3, position + 7)); cmd.raw = [opcode, blockMarker, blockType, ...sizeBytes]; // В dataBytes только сами данные, без заголовков if (blockSize > 0 && position + 7 + blockSize <= vgmBuffer.length) { const dataArray = Array.from(vgmBuffer.subarray(position + 7, position + 7 + blockSize)); cmd.dataBytes = dataArray; cmd.mnemonic = `DATA_BLOCK (type: 0x${blockType.toString(16).toUpperCase()}, size: ${blockSize})`; cmd.description = `Data block: type=${blockType}, size=${blockSize} bytes`; position += 7 + blockSize; } else { cmd.dataBytes = null; // Нет данных, только заголовок cmd.mnemonic = `DATA_BLOCK_HEADER (type: 0x${blockType.toString(16).toUpperCase()}, size: ${blockSize})`; cmd.description = `Data block header only: type=${blockType}, size=${blockSize} bytes`; position += 7; } decompilation.statistics.dataBlocks++; addCommand(cmd); } else { // Incomplete data block cmd.raw = [opcode]; cmd.mnemonic = 'DATA_BLOCK_INCOMPLETE'; cmd.description = 'Incomplete data block header'; addCommand(cmd); position++; } } else if (opcode >= 0x70 && opcode <= 0x7F) { // \"0x7n : wait n+1 samples, n can range from 0 to 15.\" // Короткое ожидание: 0x70 = 1 sample, 0x7F = 16 samples const waitSamples = (opcode & 0x0F) + 1; cmd.raw = [opcode]; cmd.mnemonic = `WAIT (${waitSamples})`; cmd.description = `Wait ${waitSamples} ticks (${(waitSamples / SAMPLE_RATE * 1000).toFixed(1)}ms)`; currentSamples += waitSamples; decompilation.statistics.waitCommands++; addCommand(cmd); position++; } else if (opcode === 0x61) { // Wait n samples if (position + 2 < vgmBuffer.length) { const waitSamples = vgmBuffer.readUInt16LE(position + 1); cmd.raw = [opcode, vgmBuffer[position + 1], vgmBuffer[position + 2]]; cmd.mnemonic = `WAIT (${waitSamples})`; cmd.description = `Wait ${waitSamples} ticks (${(waitSamples / SAMPLE_RATE * 1000).toFixed(1)}ms)`; currentSamples += waitSamples; decompilation.statistics.waitCommands++; addCommand(cmd); position += 3; } else break; } else if (opcode === 0x62) { // "0x62 : wait 735 samples (60th of a second), a shortcut for // 0x61 0xdf 0x02" const waitSamples = 735; cmd.raw = [opcode]; cmd.mnemonic = `WAIT60 (${waitSamples})`; cmd.description = `Wait ${waitSamples} ticks (16.7ms, 60Hz)`; currentSamples += waitSamples; decompilation.statistics.waitCommands++; addCommand(cmd); position++; } else if (opcode === 0x63) { // Wait 882 samples (50Hz) const waitSamples = 882; cmd.raw = [opcode]; cmd.mnemonic = `WAIT50 (${waitSamples})`; cmd.description = `Wait ${waitSamples} ticks (20.0ms, 50Hz)`; currentSamples += waitSamples; addCommand(cmd); position++; } else if (opcode >= 0x80 && opcode <= 0x8F) { // YM2612 DAC + wait const waitSamples = opcode & 0x0F; cmd.raw = [opcode]; cmd.mnemonic = `YM2612DAC_WAIT (${waitSamples})`; cmd.description = `YM2612 DAC write then wait ${waitSamples} samples`; currentSamples += waitSamples; decompilation.statistics.writeCommands++; addCommand(cmd); position++; // Чип команды записи (2 параметра) } else if ([0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0xA0, 0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF].includes(opcode)) { if (position + 2 < vgmBuffer.length) { const reg = vgmBuffer[position + 1]; const data = vgmBuffer[position + 2]; cmd.raw = [opcode, reg, data]; cmd.parameters = [`0x${reg.toString(16).toUpperCase().padStart(2, '0')}`, `0x${data.toString(16).toUpperCase().padStart(2, '0')}`]; // Определяем тип чипа по опкоду const chipCommands = { 0x50: 'PSG', 0x51: 'YM2413', 0x52: 'YM2612 Port 0', 0x53: 'YM2612 Port 1', 0x54: 'YM2151', 0x55: 'YM2203', 0x56: 'YM2608 Port 0', 0x57: 'YM2608 Port 1', 0x58: 'YM2610 Port 0', 0x59: 'YM2610 Port 1', 0x5A: 'YM3812', 0x5B: 'YM3526', 0x5C: 'Y8950', 0x5D: 'YMZ280B', 0x5E: 'YMF262 Port 0', 0x5F: 'YMF262 Port 1', 0xA0: 'AY8910', 0xB0: 'RF5C68', 0xB1: 'RF5C164', 0xB2: 'PWM', 0xB3: 'GameBoy DMG', 0xB4: 'NES APU', 0xB5: 'MultiPCM', 0xB6: 'uPD7759', 0xB7: 'OKIM6258', 0xB8: 'OKIM6295', 0xB9: 'HuC6280', 0xBA: 'K053260', 0xBB: 'Pokey', 0xBC: 'WonderSwan', 0xBD: 'SAA1099', 0xBE: 'ES5506', 0xBF: 'GA20' }; const chipName = chipCommands[opcode] || 'Unknown'; cmd.mnemonic = `${chipName} (0x${reg.toString(16).toUpperCase().padStart(2, '0')}, 0x${data.toString(16).toUpperCase().padStart(2, '0')})`; cmd.description = `${chipName} write: reg=0x${reg.toString(16).toUpperCase()}, data=0x${data.toString(16).toUpperCase()}`; decompilation.statistics.writeCommands++; addCommand(cmd); position += 3; } else break; } else { // Неизвестная или нереализованная команда cmd.raw = [opcode]; cmd.mnemonic = `UNKNOWN (0x${opcode.toString(16).toUpperCase()})`; cmd.description = `Unknown/Unimplemented command`; addCommand(cmd); position++; } } // Финальные статистики decompilation.statistics.totalSamples = currentSamples; decompilation.statistics.totalSeconds = (currentSamples / SAMPLE_RATE).toFixed(3); return decompilation; } module.exports = { readVGZ, parseVGMHeader, parseVGMCommands, parseVGMHeader, analyzeVGM, analyzeVGZFile, analyzeGZIP, decompileVGM, CHIP_OFFSETS, VGM_COMMANDS }; // =================================================================== // CLI Interface // =================================================================== /** * Распаковка одного VGZ файла в VGM * @param {string} vgzPath - Путь к VGZ файлу * @param {string} outputPath - Путь для сохранения VGM файла (необязательно) */ function unpackVGZ(vgzPath, outputPath) { try { // Резолвим путь относительно текущего рабочего каталога const resolvedVgzPath = path.resolve(vgzPath); console.log(`Unpacking: ${vgzPath}`); const { data: vgmData, gzipInfo } = readVGZ(resolvedVgzPath); // Определяем имя выходного файла if (!outputPath) { outputPath = path.resolve(vgzPath.replace(/\.vgz$/i, '.vgm')); } else { outputPath = path.resolve(outputPath); } // Записываем VGM данные fs.writeFileSync(outputPath, vgmData); console.log(`✓ Unpacked: ${outputPath}`); console.log(` Compressed: ${gzipInfo.compressedSize} bytes`); console.log(` Uncompressed: ${gzipInfo.uncompressedSize} bytes`); console.log(` Compression ratio: ${gzipInfo.compressionRatioText}`); return true; } catch (error) { console.error(`✗ Error unpacking ${vgzPath}: ${error.message}`); return false; } } /** * Распаковка всех VGZ файлов в текущем каталоге * @param {string} dirPath - Путь к каталогу (по умолчанию текущий) */ function unpackAllVGZ(dirPath = '.') { try { // Резолвим путь относительно текущего рабочего каталога const resolvedDirPath = path.resolve(dirPath); const files = fs.readdirSync(resolvedDirPath); const vgzFiles = files.filter(file => file.toLowerCase().endsWith('.vgz')); if (vgzFiles.length === 0) { console.log('No .vgz files found in current directory'); return; } console.log(`Found ${vgzFiles.length} VGZ files to unpack:`); let successful = 0; let failed = 0; vgzFiles.forEach(vgzFile => { const vgzPath = path.join(resolvedDirPath, vgzFile); if (unpackVGZ(vgzPath)) { successful++; } else { failed++; } }); console.log('\n' + '='.repeat(50)); console.log(`Unpacking complete: ${successful} successful, ${failed} failed`); } catch (error) { console.error(`Error reading directory: ${error.message}`); } } /** * Отображение информации о заголовке VGM/VGZ файла * @param {string} filePath - Путь к файлу */ function showHeader(filePath) { try { // Резолвим путь относительно текущего рабочего каталога const resolvedPath = path.resolve(filePath); let vgmData; let compressionInfo = null; if (filePath.toLowerCase().endsWith('.vgz')) { const result = readVGZ(resolvedPath); vgmData = result.data; compressionInfo = result.gzipInfo; } else { vgmData = fs.readFileSync(resolvedPath); } const header = parseVGMHeader(vgmData); console.log('='.repeat(60)); console.log(`VGM Header Information: ${path.basename(filePath)}`); console.log('='.repeat(60)); if (compressionInfo) { console.log('Compression Info:'); console.log(` Format: VGZ (gzip compressed VGM)`); console.log(` Compressed size: ${compressionInfo.compressedSize} bytes`); console.log(` Uncompressed size: ${compressionInfo.uncompressedSize} bytes`); console.log(` Compression ratio: ${compressionInfo.compressionRatioText}`); console.log(` OS: ${compressionInfo.os}`); console.log(''); } console.log('VGM Header:'); console.log(` Format: ${header.signature}`); console.log(` Version: ${header.versionString} (${header.versionHex})`); console.log(` File size: ${header.eofOffset} bytes`); console.log(` Data offset: 0x${header.dataOffset.toString(16).toUpperCase()}`); console.log(''); console.log('Timing:'); console.log(` Total samples: ${header.totalSamples}`); console.log(` Total time: ${header.totalSeconds.toFixed(2)} seconds`); console.log(` Sample rate: ${header.sampleRate} Hz`); if (header.hasLoop) { console.log(` Loop offset: 0x${header.loopOffset.toString(16).toUpperCase()}`); console.log(` Loop samples: ${header.loopSamples}`); console.log(` Loop time: ${header.loopSeconds.toFixed(2)} seconds`); console.log(` Intro time: ${header.introSeconds.toFixed(2)} seconds`); } else { console.log(` Loop: No loop`); } console.log(''); console.log('Sound Chips:'); header.chips.forEach(chip => { const dualText = chip.isDualChip ? ' (Dual Chip)' : ''; const clockText = typeof chip.clockMHz === 'number' ? `${chip.clockMHz.toFixed(3)} MHz` : '(clock info unavailable)'; console.log(` ${chip.name}: ${clockText}${dualText}`); }); if (header.gd3Offset > 0) { console.log(''); console.log('GD3 Tags:'); try { const gd3 = parseGD3(vgmData, header.gd3Offset); console.log(` Track: ${gd3.trackTitle || 'N/A'}`); console.log(` Game: ${gd3.gameName || 'N/A'}`); console.log(` System: ${gd3.systemName || 'N/A'}`); console.log(` Author: ${gd3.trackAuthor || 'N/A'}`); console.log(` Date: ${gd3.date || 'N/A'}`); console.log(` Ripper: ${gd3.ripper || 'N/A'}`); } catch (err) { console.log(` Error reading GD3: ${err.message}`); } } } catch (error) { console.error(`Error reading file ${filePath}: ${error.message}`); } } /** * Декомпиляция VGM/VGZ файла * @param {string} filePath - Путь к файлу * @param {Object} options - Опции декомпиляции */ function decompileFile(filePath, options = {}) { try { // Резолвим путь относительно текущего рабочего каталога const resolvedPath = path.resolve(filePath); let vgmData; if (filePath.toLowerCase().endsWith('.vgz')) { const result = readVGZ(resolvedPath); vgmData = result.data; } else { vgmData = fs.readFileSync(resolvedPath); } const decompileOptions = { maxCommands: options.maxCommands !== undefined ? options.maxCommands : 0, // 0 = без ограничений showTimeMarks: options.showTimeMarks !== false, showAllCommands: options.showAllCommands || false }; const decompilation = decompileVGM(vgmData, decompileOptions); // Выводим заголовок console.log(`; VGM Decompilation: ${path.basename(filePath)}`); console.log(`; Generated by VGM-lib v1.71`); console.log(`; File: ${filePath}`); console.log(`; Size: ${vgmData.length} bytes`); console.log(`; Format: ${decompilation.header.signature} ${decompilation.header.versionString}`); console.log(`;`); // Выводим информацию об использованных чипах if (decompilation.header.chips.length > 0) { console.log(`; Sound chips:`); decompilation.header.chips.forEach(chip => { const dualText = chip.isDualChip ? ' (Dual)' : ''; const clockText = typeof chip.clockMHz === 'number' ? `${chip.clockMHz.toFixed(3)} MHz` : '(clock info unavailable)'; console.log(`; ${chip.name}: ${clockText}${dualText}`); }); console.log(`;`); } // Выводим статистику console.log(`; Statistics:`); console.log(`; Total commands: ${decompilation.statistics.totalCommands}`); console.log(`; Total samples: ${decompilation.statistics.totalSamples}`); console.log(`; Total time: ${decompilation.statistics.totalSeconds} seconds`); if (decompilation.statistics.loopFound) { console.log(`; Loop sample: ${decompilation.statistics.loopSample}`); } console.log(`;`); console.log(''); // Выводим команды decompilation.commands.forEach(cmd => { console.log(cmd.command); }); } catch (error) { console.error(`Error decompiling file ${filePath}: ${error.message}`); } } /** * Показать справку по CLI командам */ function showHelp() { console.log('VGM Library CLI v1.71'); console.log('Usage: vgm [options]'); console.log(''); console.log('Commands:'); console.log(' --header Show VGM/VGZ header information'); console.log(' --decompile Decompile VGM/VGZ file to console'); console.log(' --unpack Unpack single VGZ file to VGM'); console.log(' --unpack-all [dir] Unpack all VGZ files in directory'); console.log(' --help Show this help'); console.log(''); console.log('Decompile options:'); console.log(' --max-commands Limit decompiled commands (default: unlimited)'); console.log(' --no-time-marks Disable time markers in decompilation'); console.log(' --show-all Show all commands (ignore max-commands limit)'); console.log(''); console.log('Examples:'); console.log(' vgm --header song.vgm'); console.log(' vgm --decompile song.vgz > song_decompiled.txt'); console.log(' vgm --unpack song.vgz'); console.log(' vgm --unpack-all'); console.log(' vgm --decompile song.vgm --max-commands 500 --no-time-marks'); } // CLI Entry Point if (require.main === module) { const args = process.argv.slice(2); if (args.length === 0 || args.includes('--help') || args.includes('-h')) { showHelp(); process.exit(0); } const command = args[0]; switch (command) { case '--header': if (args.length < 2) { console.error('Error: --header requires file path'); console.error('Usage: vgm --header '); process.exit(1); } showHeader(args[1]); break; case '--decompile': if (args.length < 2) { console.error('Error: --decompile requires file path'); console.error('Usage: vgm --decompile [options]'); process.exit(1); } const decompileOptions = {}; // Парсим опции декомпиляции for (let i = 2; i < args.length; i++) { if (args[i] === '--max-commands' && i + 1 < args.length) { decompileOptions.maxCommands = parseInt(args[i + 1]); i++; // skip next arg } else if (args[i] === '--no-time-marks') { decompileOptions.showTimeMarks = false; } else if (args[i] === '--show-all') { decompileOptions.showAllCommands = true; } } decompileFile(args[1], decompileOptions); break; case '--unpack': if (args.length < 2) { console.error('Error: --unpack requires file path'); console.error('Usage: vgm --unpack '); process.exit(1); } unpackVGZ(args[1]); break; case '--unpack-all': const targetDir = args.length >= 2 ? args[1] : '.'; unpackAllVGZ(targetDir); break; default: console.error(`Unknown command: ${command}`); console.error('Use --help to see available commands'); process.exit(1); } }