Add OPL4 playback and analyzer support

This commit is contained in:
2026-09-15 14:59:29 +03:00
commit 1686838e03
553 changed files with 50524 additions and 0 deletions
+465
View File
@@ -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();
+97
View File
@@ -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}`);
+257
View File
@@ -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();