/** * gen_freq_tables.js — Генерация LUT таблиц масштабирования частот PSG * * Использование: * node scripts/gen_freq_tables.js [--binary ] [--asm ] [--info] * * Каждая таблица: 4096 entries × uint16_t LE = 8192 байт (8 КБ). * * Покрывает: * - AY/YM2149: 12-bit tone period (regs 0x00–0x05), 0..4095 * - Noise: 5-bit (reg 0x06), 0..31 — первые 32 записи * * Формула масштабирования PSG: * F = CLK / (16 × period) → scaled = period × (target / source) * * FM F-Number масштабирование (YM2203) требует ОБРАТНОГО ratio (source/target) * и выполняется runtime через дробные коэффициенты (fm_frac_t в C-хедере). * * Layout: 2 ratios per 16 КБ page: * 0x0000–0x1FFF : PSG таблица A (8 КБ) * 0x2000–0x3FFF : PSG таблица B (8 КБ) * * Итого: 3 pages × 16 КБ = 48 КБ (3 доп. страницы) */ 'use strict'; const fs = require('fs'); const path = require('path'); /* ── Целевые частоты нашего железа (TSConfig MultiSound) ─────────────── */ const HW_CLK_AY = 1750000; /* AY/YM2149: 1.75 МГц */ const HW_CLK_YM2203 = 3500000; /* YM2203 OPN: 3.5 МГц */ /* ── Порог округления при сравнении частот ──────────────────────────── * Если VGM clock отличается от канонического ≤ MATCH_TOLERANCE_PCT, * считаем что это тот же коэффициент. Отдельно: если ratio ≈ 1.0 * (±BYPASS_PCT), масштабирование не нужно вообще. */ const MATCH_TOLERANCE_PCT = 5.0; /* % — порог совпадения таблиц */ const BYPASS_PCT = 2.0; /* % — порог bypass (≈1:1) */ /* ── Предопределённые варианты VGM-клоков ───────────────────────────── * Для YM2203: PSG (SSG) = fclk/2, FM = fclk * Для AY8910: PSG = fclk * * PSG ratio = HW_CLK_AY / srcPsgHz (target/source → period scaling) * FM ratio = srcYM2203 / HW_CLK_YM2203 (source/target → fnum scaling, INVERSE!) * * PSG ratio и FM ratio НЕ равны друг другу — нужны ОТДЕЛЬНЫЕ таблицы! * PSG: scaled_period = period × (HW_CLK_AY / srcPsgHz) * FM: scaled_fnum = fnum × (srcYM2203 / HW_CLK_YM2203) * * FM масштабирование: все используемые FM ratios сводятся к N/7, * поэтому fm_scale хранит множитель N (0=bypass, 3, 5, 6, 8). * В ASM на Z80: fnum × N через shifts+adds, затем /7 binary division. * NTSC (45/44 ≈ 1.023) — bypass, погрешность 2.3% в рамках 5% допуска. * * Близкие частоты (≤ MATCH_TOLERANCE_PCT) объединяются: * 1996800 Hz ≈ 2000000 Hz (0.16%) → используют одну таблицу '4MHz_2000' * 3993600 Hz ≈ 4000000 Hz (0.16%) → то же самое */ const RATIOS = [ { name: 'NTSC_1790', srcPsgHz: 1789773, /* AY8910 @ 1789773 или YM2203 @ 3579545 (SSG=1789773) */ srcYM2203: 3579545, fm_scale: 0, /* 45/44 ≈ 1.023 — bypass (2.3% в рамках 5% допуска) */ desc: 'NTSC-derived (NES, many JP computers)', }, { name: '3MHz_1500', srcPsgHz: 1500000, /* AY8910 @ 1.5M или YM2203 @ 3M */ srcYM2203: 3000000, fm_scale: 6, /* 6/7 — fnum × 6 / 7 */ desc: 'PC-88, MSX (3 MHz YM2203)', }, { name: '4MHz_2000', srcPsgHz: 2000000, /* AY8910 @ 2M или YM2203 @ 4M (≈1996800/3993600) */ srcYM2203: 4000000, fm_scale: 8, /* 8/7 — fnum × 8 / 7 */ desc: 'Arcade boards (4 MHz YM2203, covers 3.9936 MHz)', }, { name: 'CPC_1000', srcPsgHz: 1000000, /* Amstrad CPC AY @ 1 MHz */ srcYM2203: 0, /* нет FM */ desc: 'Amstrad CPC (1 MHz AY)', }, { name: '1250', srcPsgHz: 1250000, srcYM2203: 2500000, fm_scale: 5, /* 5/7 — fnum × 5 / 7 */ desc: 'Some boards (1.25 MHz AY / 2.5 MHz YM2203)', }, { name: '1500_750', srcPsgHz: 750000, /* YM2203 @ 1.5M → SSG = 750 kHz */ srcYM2203: 1500000, fm_scale: 3, /* 3/7 — fnum × 3 / 7 */ desc: 'Arcade/custom boards (1.5 MHz YM2203, SSG=750 kHz)', }, ]; /** * matchTable(psgClkHz) → { index, ratio, bypass } * * Ищет ближайшую таблицу для заданного PSG clock. * Если расхождение ratio от 1.0 ≤ BYPASS_PCT → bypass (масштабирование не нужно). * Если ближайшая таблица ≤ MATCH_TOLERANCE_PCT → возвращает её индекс. * Иначе → index = -1 (нужна runtime-генерация или таблицы нет). */ function matchTable(psgClkHz) { const ratio = psgClkHz / HW_CLK_AY; /* Bypass: частоты почти совпадают с HW */ if (Math.abs(ratio - 1.0) * 100 <= BYPASS_PCT) { return { index: -1, ratio, bypass: true, name: 'BYPASS' }; } let bestIdx = -1; let bestDiffPct = Infinity; for (let i = 0; i < RATIOS.length; i++) { const diffPct = Math.abs(psgClkHz - RATIOS[i].srcPsgHz) / RATIOS[i].srcPsgHz * 100; if (diffPct < bestDiffPct) { bestDiffPct = diffPct; bestIdx = i; } } if (bestDiffPct <= MATCH_TOLERANCE_PCT) { return { index: bestIdx, ratio: RATIOS[bestIdx].srcPsgHz / HW_CLK_AY, bypass: false, name: RATIOS[bestIdx].name, diffPct: bestDiffPct, }; } /* Нет подходящей таблицы — runtime generation needed */ return { index: -1, ratio, bypass: false, name: 'RUNTIME' }; } const TABLE_ENTRIES = 4096; /* 12-bit PSG range */ const TABLE_BYTES = TABLE_ENTRIES * 2; /* uint16_t LE = 8 КБ */ const PAGE_SIZE = 16384; /* 16 КБ */ /* ── Генерация PSG таблицы ─────────────────────────────────────────── * ratio = target / source (period scales up/down to preserve frequency) * PSG: F = CLK / (16 × period) → scaled_period = period × (target/source) */ function generateTable(srcPsgHz, targetHz) { const buf = Buffer.alloc(TABLE_BYTES); const ratio = targetHz / srcPsgHz; for (let i = 0; i < TABLE_ENTRIES; i++) { let scaled = Math.round(i * ratio); if (scaled > 0xFFFF) scaled = 0xFFFF; /* clamp uint16 */ buf.writeUInt16LE(scaled, i * 2); } return buf; } /* ── Вывод информации о таблицах ─────────────────────────────────────── */ function printInfo() { console.log('=== Frequency Scale LUT Tables ==='); console.log(`Target PSG: ${HW_CLK_AY} Hz`); console.log(`PSG table: ${TABLE_ENTRIES} entries × 2 bytes = ${TABLE_BYTES} bytes`); console.log(`Layout: 2 ratios per 16 КБ page`); console.log(`Total: ${Math.ceil(RATIOS.length / 2)} pages × 16 КБ = ${Math.ceil(RATIOS.length / 2) * PAGE_SIZE} bytes`); console.log(''); for (let r = 0; r < RATIOS.length; r++) { const rt = RATIOS[r]; const psgRatio = HW_CLK_AY / rt.srcPsgHz; const page = Math.floor(r / 2) + PAGE_BASE; const slot = r % 2; const offset = slot * TABLE_BYTES; console.log(`[${r}] ${rt.name}: PSG ${rt.srcPsgHz} Hz → ratio = ${psgRatio.toFixed(6)}`); console.log(` ${rt.desc}`); console.log(` Page ${page}, slot ${slot} (offset 0x${offset.toString(16).toUpperCase()})`); if (rt.srcYM2203) { const fmRatio = rt.srcYM2203 / HW_CLK_YM2203; const fmDesc = rt.fm_scale ? `×${rt.fm_scale}/7` : 'bypass'; console.log(` FM: YM2203 @ ${rt.srcYM2203} Hz → fm_ratio = ${fmRatio.toFixed(6)} (${fmDesc})`); } /* Проверка нескольких ключевых значений */ const checks = [ { name: 'PSG tone 440 Hz equiv', val: Math.round(rt.srcPsgHz / (16 * 440)) }, { name: 'PSG max 12-bit', val: 4095 }, { name: 'Noise max 5-bit', val: 31 }, ]; for (const c of checks) { if (c.val >= TABLE_ENTRIES) continue; const scaled = Math.round(c.val * psgRatio); console.log(` PSG ${c.name}: ${c.val} → ${scaled}`); } console.log(''); } /* ── Тест: сопоставление «диких» частот из реальных VGM ────────── */ console.log('--- Clock matching test (bypass ' + BYPASS_PCT + '%, tolerance ' + MATCH_TOLERANCE_PCT + '%) ---'); const testClocks = [ /* PSG clocks (AY8910 fclk, или YM2203 SSG = fclk/2) */ { name: 'TSconf HW (bypass)', hz: 1750000 }, { name: 'Close to HW (bypass)', hz: 1773400 }, { name: 'NTSC exact', hz: 1789773 }, { name: 'NTSC rounded', hz: 1789750 }, { name: 'YM2203 3.58MHz SSG', hz: 1789772 }, { name: 'PAL-derived', hz: 1773448 }, { name: 'MSX 3MHz SSG', hz: 1500000 }, { name: 'MSX variant', hz: 1536000 }, { name: '4MHz SSG exact', hz: 2000000 }, { name: '3.9936MHz SSG', hz: 1996800 }, { name: '4.028MHz SSG', hz: 2014000 }, { name: 'CPC exact', hz: 1000000 }, { name: 'CPC-ish', hz: 1023000 }, { name: '2.5MHz SSG', hz: 1250000 }, { name: 'Weird 1.3MHz', hz: 1300000 }, { name: 'Weird 1.6MHz', hz: 1600000 }, { name: 'Weird 900kHz', hz: 900000 }, { name: 'YM2203 1.5MHz SSG', hz: 750000 }, { name: 'ZX 128 (1.7734 MHz)', hz: 1773400 }, ]; for (const t of testClocks) { const m = matchTable(t.hz); const tag = m.bypass ? 'BYPASS' : m.index >= 0 ? `table[${m.index}]=${m.name} (${m.diffPct.toFixed(2)}%)` : 'NO MATCH → runtime'; console.log(` ${t.hz.toString().padStart(7)} Hz ${t.name.padEnd(26)} → ${tag}`); } /* Подсказка по Z80 маппингу */ console.log(''); console.log('--- Z80 matching: compare clock_khz ±5% ---'); for (let r = 0; r < RATIOS.length; r++) { const khz = Math.round(RATIOS[r].srcPsgHz / 1000); const tol = Math.round(khz * MATCH_TOLERANCE_PCT / 100); const page = Math.floor(r / 2) + PAGE_BASE; const slot = r % 2; console.log(` [${r}] ${RATIOS[r].name.padEnd(12)} ${khz} kHz ± ${tol} kHz → page ${page}, slot ${slot}`); } } /* ── Генерация бинарного файла ───────────────────────────────────────── * * Layout: 2 ratios per 16 КБ page. * * Page N+0: PSG[0] (0x0000-0x1FFF) + PSG[1] (0x2000-0x3FFF) * Page N+1: PSG[2] (0x0000-0x1FFF) + PSG[3] (0x2000-0x3FFF) * ... * * Итого: ceil(RATIOS.length / 2) × 16 КБ */ function generateBinary(outPath) { const numPages = Math.ceil(RATIOS.length / 2); const totalSize = numPages * PAGE_SIZE; const out = Buffer.alloc(totalSize, 0x00); for (let r = 0; r < RATIOS.length; r++) { const page = Math.floor(r / 2); const slot = r % 2; const offset = page * PAGE_SIZE + slot * TABLE_BYTES; const table = generateTable(RATIOS[r].srcPsgHz, HW_CLK_AY); table.copy(out, offset); } fs.writeFileSync(outPath, out); console.log(`Written: ${outPath} (${out.length} bytes, ${numPages} pages)`); /* Вывод маппинга для кода */ console.log('\nC mapping (for vgm.c):'); console.log('// After determining VGM PSG clock, select table:'); for (let r = 0; r < RATIOS.length; r++) { const page = Math.floor(r / 2) + PAGE_BASE; const offset = (r % 2) * TABLE_BYTES; console.log(`// ${RATIOS[r].srcPsgHz} Hz → wc_mng0_pl(${page}), offset = 0x${offset.toString(16)}`); } } /* ── Генерация ASM include (для верификации) ─────────────────────────── */ function generateAsm(outPath) { let asm = '; Auto-generated by gen_freq_tables.js\n'; asm += '; Frequency scale LUT tables for PSG/FM\n'; asm += `; Target: PSG=${HW_CLK_AY} Hz, FM=${HW_CLK_YM2203} Hz\n`; asm += `; ${TABLE_ENTRIES} entries × 2 bytes = ${TABLE_BYTES} bytes per table\n\n`; for (let r = 0; r < RATIOS.length; r++) { const rt = RATIOS[r]; const ratio = rt.srcPsgHz / HW_CLK_AY; asm += `; Table ${r}: ${rt.name} (${rt.srcPsgHz} Hz, ratio=${ratio.toFixed(6)})\n`; asm += `_freq_lut_${r}::\n`; const table = generateTable(rt.srcPsgHz, HW_CLK_AY); for (let i = 0; i < TABLE_ENTRIES; i += 8) { const values = []; for (let j = 0; j < 8 && (i + j) < TABLE_ENTRIES; j++) { values.push('0x' + table.readUInt16LE((i + j) * 2).toString(16).padStart(4, '0')); } asm += ` .dw ${values.join(', ')}\n`; } asm += '\n'; } fs.writeFileSync(outPath, asm); console.log(`Written: ${outPath}`); } /* ── Генерация C-хедера для Z80 маппинга ───────────────────────────── * Содержит таблицу {clk_khz, tol_khz, page, offset} для линейного * поиска ближайшей LUT при парсинге VGM заголовка. * * Layout: 2 ratios per page — PSG_A@0x0000 (8 КБ) + PSG_B@0x2000 (8 КБ). * FM масштабирование — runtime через fm_frac_t (дробные коэффициенты). * * Алгоритм на Z80 (в vgm.c): * 1. bypass: |psg_khz - 1750| ≤ 35 → нет масштабирования * 2. Линейный поиск: |psg_khz - entry.clk_khz| ≤ entry.tol_khz → match * 3. Нет match → native (без масштабирования) * * Все сравнения — 16 бит (uint16_t kHz), дёшево на Z80. */ function generateCHeader(outPath) { let h = ''; h += '/* Auto-generated by gen_freq_tables.js — DO NOT EDIT */\n'; h += '#ifndef FREQ_LUT_MAP_H\n'; h += '#define FREQ_LUT_MAP_H\n\n'; h += '#include "types.h"\n\n'; /* Count YM2203 entries (those with srcYM2203 > 0) */ const ymEntries = RATIOS.filter(r => r.srcYM2203 > 0); h += `/* AY/YM2149: bypass |psg_khz - ${Math.round(HW_CLK_AY/1000)}| <= ${Math.round(HW_CLK_AY/1000 * BYPASS_PCT / 100)} */\n`; h += `#define FREQ_LUT_HW_KHZ ${Math.round(HW_CLK_AY/1000)}u\n`; h += `#define FREQ_LUT_BYPASS_TOL ${Math.round(HW_CLK_AY/1000 * BYPASS_PCT / 100)}u /* ${BYPASS_PCT}% */\n`; h += `/* YM2203: bypass |ym_khz - ${Math.round(HW_CLK_YM2203/1000)}| <= ${Math.round(HW_CLK_YM2203/1000 * BYPASS_PCT / 100)} */\n`; h += `#define FREQ_LUT_HW_YM_KHZ ${Math.round(HW_CLK_YM2203/1000)}u\n`; h += `#define FREQ_LUT_BYPASS_TOL_YM ${Math.round(HW_CLK_YM2203/1000 * BYPASS_PCT / 100)}u /* ${BYPASS_PCT}% */\n\n`; h += `#define FREQ_LUT_AY_COUNT ${RATIOS.length}u\n`; h += `#define FREQ_LUT_YM_COUNT ${ymEntries.length}u\n`; h += `#define FREQ_LUT_PSG_ENTRIES ${TABLE_ENTRIES}u /* 12-bit PSG */\n`; h += `#define FREQ_LUT_PSG_BYTES ${TABLE_BYTES}u /* per PSG table */\n\n`; h += 'typedef struct {\n'; h += ' uint16_t clk_khz; /* canonical clock in kHz (PSG or YM master) */\n'; h += ' uint16_t tol_khz; /* \u00b1tolerance in kHz (5%) */\n'; h += ' uint8_t page; /* plugin page (PAGE_BASE-based) */\n'; h += ' uint16_t offset; /* PSG table byte offset within page */\n'; h += '} freq_lut_entry_t;\n\n'; /* AY map — PSG only */ h += `/* AY/YM2149 PSG table map (tolerance = ${MATCH_TOLERANCE_PCT}% of canonical PSG clock)\n`; h += ` * Layout: 2 ratios per page, PSG_A@0x0000, PSG_B@0x${TABLE_BYTES.toString(16).toUpperCase()} */\n`; h += 'static const freq_lut_entry_t freq_lut_map_ay[FREQ_LUT_AY_COUNT] = {\n'; for (let r = 0; r < RATIOS.length; r++) { const khz = Math.round(RATIOS[r].srcPsgHz / 1000); const tol = Math.round(khz * MATCH_TOLERANCE_PCT / 100); const page = Math.floor(r / 2) + PAGE_BASE; const offset = (r % 2) * TABLE_BYTES; const comma = (r < RATIOS.length - 1) ? ',' : ' '; h += ` { ${khz.toString().padStart(4)}u, ${tol.toString().padStart(3)}u, ${page}, 0x${offset.toString(16).toUpperCase().padStart(4, '0')} }${comma} /* ${RATIOS[r].name} */\n`; } h += '};\n\n'; /* YM2203 map — uses same PSG table as AY, plus FM frac for runtime */ h += '/* YM2203 table map (full master clock, PSG table = same as AY entry) */\n'; h += 'static const freq_lut_entry_t freq_lut_map_ym[FREQ_LUT_YM_COUNT] = {\n'; let ymIdx = 0; for (let r = 0; r < RATIOS.length; r++) { if (!RATIOS[r].srcYM2203) continue; const khz = Math.round(RATIOS[r].srcYM2203 / 1000); const tol = Math.round(khz * MATCH_TOLERANCE_PCT / 100); const page = Math.floor(r / 2) + PAGE_BASE; const offset = (r % 2) * TABLE_BYTES; ymIdx++; const comma = (ymIdx < ymEntries.length) ? ',' : ' '; h += ` { ${khz.toString().padStart(4)}u, ${tol.toString().padStart(3)}u, ${page}, 0x${offset.toString(16).toUpperCase().padStart(4, '0')} }${comma} /* ${RATIOS[r].name} (YM2203@${khz}kHz) */\n`; } h += '};\n\n'; /* FM multiplier table — indexed same as freq_lut_map_ym[] */ h += '/* FM F-Number \u043c\u043d\u043e\u0436\u0438\u0442\u0435\u043b\u044c (YM2203): scaled = fnum \u00d7 fm_mul / 7.\n'; h += ' * 0 = bypass (\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u043d\u0435 \u043d\u0443\u0436\u043d\u043e).\n'; h += ' * ASM \u043d\u0430 Z80: \u0443\u043c\u043d\u043e\u0436\u0435\u043d\u0438\u0435 \u0447\u0435\u0440\u0435\u0437 shifts+adds, \u0434\u0435\u043b\u0435\u043d\u0438\u0435 \u043d\u0430 7 binary division */\n'; h += 'static const uint8_t freq_lut_fm_mul[FREQ_LUT_YM_COUNT] = {\n'; ymIdx = 0; for (let r = 0; r < RATIOS.length; r++) { if (!RATIOS[r].srcYM2203) continue; const rt = RATIOS[r]; const fmRatio = rt.srcYM2203 / HW_CLK_YM2203; ymIdx++; const comma = (ymIdx < ymEntries.length) ? ',' : ' '; const fmDesc = rt.fm_scale ? `\u00d7${rt.fm_scale}/7` : 'bypass (2.3%)'; h += ` ${rt.fm_scale}${comma} /* ${rt.name}: ${fmRatio.toFixed(6)} = ${fmDesc} */\n`; } h += '};\n\n'; h += '/*\n'; h += ' * Usage in vgm.c (parse_header):\n'; h += ' *\n'; h += ' * 1. Bypass check: |clock_khz - HW| <= BYPASS_TOL \u2192 native\n'; h += ' * 2. Find matching table \u2192 set page, PSG base = offset\n'; h += ' * 3. For YM2203: fm_mul = freq_lut_fm_mul[j]\n'; h += ' *\n'; h += ' * PSG: scaled_period = freq_lut_base[period_12bit]\n'; h += ' * FM: asm_scale_fm() : fb_scaled = fnum \u00d7 fm_mul / 7\n'; h += ' * if (fb_scaled > 2047) block++, fb_scaled >>= 1\n'; h += ' */\n\n'; h += '#endif /* FREQ_LUT_MAP_H */\n'; fs.writeFileSync(outPath, h); console.log(`Written: ${outPath}`); } /* ── Номер первой plugin-страницы с LUT данными ───────────────────── * Зависит от числа страниц кода: code_pages = ceil(code_size / 16384). * Передаётся через --page-base N. По умолчанию 2 (типичный случай: * CODE+DATA ≈ 15-16 КБ, занимает 2 страницы #8000-#FFFF). */ let PAGE_BASE = 2; /* ── CLI ─────────────────────────────────────────────────────────────── */ function main() { const args = process.argv.slice(2); let doBinary = null; let doAsm = null; let doCheader = null; let doInfo = false; for (let i = 0; i < args.length; i++) { if (args[i] === '--binary' && args[i + 1]) { doBinary = args[++i]; } else if (args[i] === '--asm' && args[i + 1]) { doAsm = args[++i]; } else if (args[i] === '--cheader' && args[i + 1]) { doCheader = args[++i]; } else if (args[i] === '--page-base' && args[i + 1]) { PAGE_BASE = parseInt(args[++i], 10); } else if (args[i] === '--info') { doInfo = true; } else { console.error(`Unknown arg: ${args[i]}`); console.error('Usage: node gen_freq_tables.js [--binary out.bin] [--asm out.s] [--cheader out.h] [--page-base N] [--info]'); process.exit(1); } } if (!doBinary && !doAsm && !doCheader && !doInfo) { doInfo = true; /* default: print info */ } if (doInfo) printInfo(); if (doBinary) generateBinary(doBinary); if (doAsm) generateAsm(doAsm); if (doCheader) generateCHeader(doCheader); } main();