Files

1853 lines
86 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<number>} rawBytes - Байты команды
* @param {string} mnemonic - Мнемоника команды
* @param {Array<number>} 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 <command> [options]');
console.log('');
console.log('Commands:');
console.log(' --header <file> Show VGM/VGZ header information');
console.log(' --decompile <file> Decompile VGM/VGZ file to console');
console.log(' --unpack <file> 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 <n> 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 <file>');
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 <file> [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 <file>');
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);
}
}