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
+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();