Add OPL4 playback and analyzer support
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* gen_freq_tables.js — Генерация LUT таблиц масштабирования частот PSG
|
||||
*
|
||||
* Использование:
|
||||
* node scripts/gen_freq_tables.js [--binary <output.bin>] [--asm <output.s>] [--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();
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @file gen_pos_table.js
|
||||
* @brief Генератор таблицы позиций INT для ISR
|
||||
*
|
||||
* Параметры:
|
||||
* entries = 56 (прерываний на кадр)
|
||||
* step = 1280 (T-states между INT)
|
||||
* line_len = 224 (T-states в строке = 448 пикс / 2)
|
||||
*
|
||||
* Частота ISR: 3,500,000 / 1280 = 2734.375 Гц (~44100/16)
|
||||
* Замыкание: 56 x 1280 = 71680 T = 320 строк x 224 T (1 TV-кадр)
|
||||
*
|
||||
* Формат записи (5 байт):
|
||||
* [VSINTH, VSINTL, HSINT, next_lo, next_hi]
|
||||
*
|
||||
* OUTI-цикл в ISR: BC начинается с #24AF:
|
||||
* OUTI #24AF -> VSINTH (бит0 = VCNT[8])
|
||||
* OUTI #23AF -> VSINTL (VCNT[7:0])
|
||||
* OUTI #22AF -> HSINT (HCNT[8:1], 0–223)
|
||||
*
|
||||
* Использование:
|
||||
* node scripts/gen_pos_table.js
|
||||
* Вывод: asm/pos_table.s
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/* CLI: --entries N --step N */
|
||||
let entries = 56, step = 1280;
|
||||
for (let i = 2; i < process.argv.length; i++) {
|
||||
if (process.argv[i] === '--entries' && process.argv[i+1]) entries = parseInt(process.argv[++i], 10);
|
||||
else if (process.argv[i] === '--step' && process.argv[i+1]) step = parseInt(process.argv[++i], 10);
|
||||
}
|
||||
|
||||
const ENTRIES = entries;
|
||||
const STEP = step;
|
||||
const LINE_LEN = 224; /* T-states per line at 3.5 MHz */
|
||||
const ISR_FREQ = Math.round(3_500_000 / STEP);
|
||||
|
||||
const BASE_ADDR_STR = 'pos_table'; /* метка в ASM */
|
||||
|
||||
/* Вычислить символьный адрес записи по индексу.
|
||||
* В sdas каждая запись занимает 5 байт: 3 байта DB + 2 байта DW.
|
||||
* sdas не умеет $+N в секции _CODE напрямую, поэтому используем
|
||||
* явные метки _pt_0, _pt_1, ... для ссылок next_ptr. */
|
||||
|
||||
const lines = [
|
||||
';============================================================================',
|
||||
'; pos_table.s — Таблица позиций INT (СГЕНЕРИРОВАНО, не редактировать)',
|
||||
';',
|
||||
`; entries=${ENTRIES}, step=${STEP}, line_len=${LINE_LEN}`,
|
||||
`; ISR freq: 3,500,000 / ${STEP} = ${ISR_FREQ} Hz (~44100/16)`,
|
||||
';============================================================================',
|
||||
'',
|
||||
' .module pos_table_mod',
|
||||
' .globl pos_table',
|
||||
'',
|
||||
' .area _CODE',
|
||||
'',
|
||||
'pos_table:',
|
||||
];
|
||||
|
||||
for (let i = 0; i < ENTRIES; i++) {
|
||||
const pos = i * STEP;
|
||||
const line = Math.floor(pos / LINE_LEN);
|
||||
const hpos = pos % LINE_LEN;
|
||||
|
||||
const vsinth = (line >> 8) & 0x01; /* только бит 0 (VCNT[8]) */
|
||||
const vsintl = line & 0xFF;
|
||||
const hsint = hpos & 0xFF;
|
||||
|
||||
const nextLabel = (i < ENTRIES - 1)
|
||||
? `_pt_${i + 1}`
|
||||
: `pos_table`;
|
||||
|
||||
lines.push(`_pt_${i}:`);
|
||||
lines.push(
|
||||
` .db 0x${vsinth.toString(16).padStart(2,'0').toUpperCase()}` +
|
||||
`, 0x${vsintl.toString(16).padStart(2,'0').toUpperCase()}` +
|
||||
`, 0x${hsint .toString(16).padStart(2,'0').toUpperCase()}` +
|
||||
` ; [${String(i).padStart(2)}] line=${String(line).padStart(3)}, hpos=${String(hpos).padStart(3)}`
|
||||
);
|
||||
lines.push(` .dw ${nextLabel}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(' .globl pos_table_end');
|
||||
lines.push('pos_table_end:');
|
||||
lines.push('');
|
||||
|
||||
const outPath = path.join(__dirname, '..', 'asm', 'pos_table.s');
|
||||
fs.writeFileSync(outPath, lines.join('\n'), 'utf8');
|
||||
|
||||
console.log(`OK: ${ENTRIES} entries, ${ENTRIES * 5} bytes → ${outPath}`);
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* ihx2wmf.js — Конвертер SDCC .ihx (Intel HEX) + патч заголовка WC → .wmf
|
||||
*
|
||||
* Использование:
|
||||
* node scripts/ihx2wmf.js <input.ihx> <output.wmf> [--extra <data.bin>]
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. Парсить Intel HEX, получить бинарные данные (относительные адреса).
|
||||
* 2. Определить реальный размер кода (до последнего не-нулевого байта).
|
||||
* 3. Вычислить PAGES = ceil(code_size / 16384).
|
||||
* 4. Вычислить BLKSIZE = ceil(code_size / 512).
|
||||
* 5. Подготовить заголовок плагина (512 байт) с правильными PAGES/BLKSIZE.
|
||||
* 6. Если задан --extra: добавить доп. страницы данных (LUT таблицы и т.п.).
|
||||
* 7. Записать: [header 512 байт][код][extra data pages].
|
||||
*
|
||||
* Формат WMF-заголовка (Wild Commander Plugin):
|
||||
* +0 16 байт: зарезервировано
|
||||
* +16 16 байт: "WildCommanderMDL"
|
||||
* +32 1 байт: версия системы плагинов (#10)
|
||||
* +33 1 байт: зарезервировано
|
||||
* +34 1 байт: число 16KB страниц (PAGES)
|
||||
* +35 1 байт: начальная страница (0)
|
||||
* +36 12 байт: 6 × 2 байта блоков данных (blockN: [page, blkcount])
|
||||
* +48 15 байт: зарезервировано
|
||||
* +63 1 байт: флаги (0 = активировать по Enter и F3)
|
||||
* +64 96 байт: расширения (32 × 3 байта, "VGM" + нули)
|
||||
* +160 1 байт: маркер конца расширений (0)
|
||||
* +161 4 байта: макс. размер файла (0xFFFFFFFF = без ограничений)
|
||||
* +165 32 байта: имя плагина (дополнить пробелами)
|
||||
* +197 1 байт: тип активации (0 = по расширению)
|
||||
* +198 6 байт: текст кнопок F2/F4
|
||||
* +204 24 байта: текст меню
|
||||
* +228 32 байта: зарезервировано
|
||||
* +260 1 байт: флаги INI
|
||||
* +261 ... паддинг до 512 байт
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/* ── Константы заголовка ─────────────────────────────────────────────── */
|
||||
const HEADER_SIZE = 512;
|
||||
const PLUGIN_ORG = 0x8000; /* виртуальный адрес загрузки WC */
|
||||
const PLUGIN_NAME = 'VGM Player OPL3 v2.0 SDCC '; /* 32 байта */
|
||||
|
||||
/* ── Парсер Intel HEX ────────────────────────────────────────────────── */
|
||||
function parseIHX(src) {
|
||||
const buf = Buffer.alloc(0x10000, 0x00);
|
||||
let minA = 0x10000;
|
||||
let maxA = 0;
|
||||
|
||||
for (const rawLine of src.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith(':')) continue;
|
||||
|
||||
const byteCount = parseInt(line.slice(1, 3), 16);
|
||||
const address = parseInt(line.slice(3, 7), 16);
|
||||
const recordType = parseInt(line.slice(7, 9), 16);
|
||||
|
||||
if (recordType === 0x00) { /* Data */
|
||||
for (let i = 0; i < byteCount; i++) {
|
||||
const b = parseInt(line.slice(9 + i * 2, 11 + i * 2), 16);
|
||||
const a = address + i;
|
||||
buf[a] = b;
|
||||
if (a < minA) minA = a;
|
||||
if (a > maxA) maxA = a;
|
||||
}
|
||||
}
|
||||
/* recordType 01 = EOF, игнорируем остальное */
|
||||
}
|
||||
|
||||
return { buf, minAddr: minA, maxAddr: maxA };
|
||||
}
|
||||
|
||||
/* ── Построить заголовок WC ──────────────────────────────────────────── */
|
||||
/**
|
||||
* @param {number} totalPages — общее число 16KB страниц
|
||||
* @param {Array<{page: number, blocks: number}>} blockEntries — блоки
|
||||
*/
|
||||
function buildHeader(totalPages, blockEntries) {
|
||||
const hdr = Buffer.alloc(HEADER_SIZE, 0x00);
|
||||
|
||||
/* +16: сигнатура */
|
||||
Buffer.from('WildCommanderMDL').copy(hdr, 16);
|
||||
|
||||
/* +32: версия системы плагинов */
|
||||
hdr[32] = 0x10;
|
||||
|
||||
/* +34: число страниц */
|
||||
hdr[34] = totalPages;
|
||||
/* +35: начальная страница */
|
||||
hdr[35] = 0;
|
||||
|
||||
/* +36: block table — до 6 записей × 2 байта */
|
||||
for (let i = 0; i < Math.min(blockEntries.length, 6); i++) {
|
||||
hdr[36 + i * 2] = blockEntries[i].page;
|
||||
hdr[36 + i * 2 + 1] = blockEntries[i].blocks;
|
||||
}
|
||||
|
||||
/* +63: флаги */
|
||||
hdr[63] = 0;
|
||||
|
||||
/* +64: расширения: "VGM", "VGZ" */
|
||||
hdr[64] = 0x56; hdr[65] = 0x47; hdr[66] = 0x4D; /* V G M */
|
||||
hdr[67] = 0x56; hdr[68] = 0x47; hdr[69] = 0x5A; /* V G Z */
|
||||
/* остальные 30 × 3 = 90 нулей уже есть */
|
||||
|
||||
/* +160: маркер конца */
|
||||
hdr[160] = 0;
|
||||
|
||||
/* +161: макс. размер файла = 0xFFFFFFFF */
|
||||
hdr[161] = 0xFF; hdr[162] = 0xFF; hdr[163] = 0xFF; hdr[164] = 0xFF;
|
||||
|
||||
/* +165: имя плагина (32 байта, пробелами) */
|
||||
const name = PLUGIN_NAME.slice(0, 32).padEnd(32, ' ');
|
||||
Buffer.from(name, 'ascii').copy(hdr, 165);
|
||||
|
||||
/* +197: тип активации */
|
||||
hdr[197] = 0;
|
||||
|
||||
return hdr;
|
||||
}
|
||||
|
||||
/* ── Основная логика ─────────────────────────────────────────────────── */
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let inFile = null, outFile = null, extraFile = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--extra' && args[i + 1]) {
|
||||
extraFile = args[++i];
|
||||
} else if (!inFile) {
|
||||
inFile = args[i];
|
||||
} else if (!outFile) {
|
||||
outFile = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!inFile || !outFile) {
|
||||
console.error('Usage: node ihx2wmf.js <input.ihx> <output.wmf> [--extra <data.bin>]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(inFile, 'utf8');
|
||||
const { buf, minAddr, maxAddr } = parseIHX(src);
|
||||
|
||||
if (minAddr > maxAddr) {
|
||||
console.error('ERROR: empty IHX');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* Код начинается с PLUGIN_ORG = 0x8000 */
|
||||
if (minAddr < PLUGIN_ORG) {
|
||||
console.error(`ERROR: code starts at 0x${minAddr.toString(16)}, expected >= 0x${PLUGIN_ORG.toString(16)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const codeSize = maxAddr - PLUGIN_ORG + 1;
|
||||
const codePages = Math.max(1, Math.ceil(codeSize / 16384));
|
||||
const codeBlocks = Math.max(1, Math.ceil(codeSize / 512));
|
||||
|
||||
console.log(`Code: 0x${PLUGIN_ORG.toString(16)} - 0x${maxAddr.toString(16)} (${codeSize} bytes)`);
|
||||
console.log(`Code pages: ${codePages}, Code blocks: ${codeBlocks}`);
|
||||
|
||||
/* Блоки: каждая 16KB-страница = один block entry (max 32 секторов).
|
||||
*
|
||||
* WC читает WMF последовательно и загружает каждый block в свою страницу.
|
||||
* Максимум 32 сектора на запись (= 16384 байт = 1 страница).
|
||||
* Если код занимает >1 страницы — разбиваем на отдельные block entry.
|
||||
*
|
||||
* При наличии extra: код паддится до границы 16KB перед extra,
|
||||
* поэтому последняя страница кода всегда = 32 сектора (кроме случая
|
||||
* когда это единственная страница и extra нет).
|
||||
*/
|
||||
let extraBuf = null;
|
||||
|
||||
if (extraFile) {
|
||||
extraBuf = fs.readFileSync(extraFile);
|
||||
}
|
||||
|
||||
/* ── Block entries для кода: по одной на страницу ────────────── */
|
||||
const blockEntries = [];
|
||||
for (let p = 0; p < codePages; p++) {
|
||||
if (p < codePages - 1) {
|
||||
/* Промежуточные страницы всегда полные */
|
||||
blockEntries.push({ page: p, blocks: 32 });
|
||||
} else {
|
||||
/* Последняя (или единственная) страница кода */
|
||||
if (extraBuf) {
|
||||
/* С extra: код паддится до 16KB → полная страница */
|
||||
blockEntries.push({ page: p, blocks: 32 });
|
||||
} else {
|
||||
/* Без extra: реальный размер */
|
||||
const pageStart = p * 16384;
|
||||
const remaining = codeSize - pageStart;
|
||||
blockEntries.push({ page: p, blocks: Math.ceil(remaining / 512) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Block entries для extra data: по одной на страницу ──────── */
|
||||
if (extraBuf) {
|
||||
const extraPages = Math.ceil(extraBuf.length / 16384);
|
||||
console.log(`Extra: ${extraFile} (${extraBuf.length} bytes, ${extraPages} pages)`);
|
||||
|
||||
for (let p = 0; p < extraPages; p++) {
|
||||
const pageStart = p * 16384;
|
||||
const remaining = extraBuf.length - pageStart;
|
||||
const pageSize = Math.min(remaining, 16384);
|
||||
const blk = Math.ceil(pageSize / 512);
|
||||
blockEntries.push({ page: codePages + p, blocks: blk });
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = blockEntries.reduce((sum, e) => Math.max(sum, e.page + 1), 0);
|
||||
|
||||
if (blockEntries.length > 6) {
|
||||
console.error(`ERROR: too many block entries (${blockEntries.length}, max 6)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hdr = buildHeader(totalPages, blockEntries);
|
||||
const codeSlice = buf.slice(PLUGIN_ORG, PLUGIN_ORG + codeSize);
|
||||
|
||||
/* Итоговый файл: [512 header][код][padding до 16KB boundary][extra pages] */
|
||||
const parts = [hdr, codeSlice];
|
||||
|
||||
if (extraBuf) {
|
||||
/* Паддинг кода до границы 16KB-страницы
|
||||
* (WC загружает каждый блок в свою страницу) */
|
||||
const codePadded = codePages * 16384;
|
||||
const pad = codePadded - codeSize;
|
||||
if (pad > 0) {
|
||||
parts.push(Buffer.alloc(pad, 0x00));
|
||||
}
|
||||
parts.push(extraBuf);
|
||||
/* Паддинг extra до конца последней страницы */
|
||||
const extraPadded = Math.ceil(extraBuf.length / 16384) * 16384;
|
||||
const extraPad = extraPadded - extraBuf.length;
|
||||
if (extraPad > 0) {
|
||||
parts.push(Buffer.alloc(extraPad, 0x00));
|
||||
}
|
||||
}
|
||||
|
||||
const out = Buffer.concat(parts);
|
||||
fs.writeFileSync(outFile, out);
|
||||
|
||||
console.log(`Pages: ${totalPages}, Block entries: ${blockEntries.length}`);
|
||||
for (let i = 0; i < blockEntries.length; i++) {
|
||||
console.log(` block[${i}]: page=${blockEntries[i].page}, sectors=${blockEntries[i].blocks}`);
|
||||
}
|
||||
console.log(`Written: ${outFile} (${out.length} bytes)`);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user