UNPKG

@camoto/gamemusic

Version:

Read and write music files used by DOS games

536 lines (488 loc) 13.1 kB
/* * DOSBox .dro version 1.0 handler. * * This file format is fully documented on the ModdingWiki: * http://www.shikadi.net/moddingwiki/DRO_Format * * Copyright (C) 2010-2021 Adam Nielsen <malvineous@shikadi.net> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ const FORMAT_ID = 'mus-dro-dosbox-v1'; import Debug from '../util/debug.js'; const debug = Debug.extend(FORMAT_ID); import { RecordBuffer, RecordType } from '@camoto/record-io-buffer'; import MusicHandler from '../interface/musicHandler.js'; import { Music, UtilMusic, UtilOPL } from '../index.js'; import { generateOPL, parseOPL } from '../util/opl/index.js'; import { Tempo as TempoEvent } from '../interface/events/index.js'; const recordTypes = { header: { signature: RecordType.string.fixed.noTerm(8), verMajor: RecordType.int.u16le, verMinor: RecordType.int.u16le, }, subheader: { lenMilliseconds: RecordType.int.u32le, lenBytes: RecordType.int.u32le, flagHW: RecordType.int.u32le, // u8 in early versions }, tags: { title: RecordType.string.variable.reqTerm(256), artist: RecordType.string.variable.reqTerm(256), comment: RecordType.string.variable.reqTerm(256), app: RecordType.string.fixed.reqTerm(9), }, }; const DRO_HEADER_LEN = 8 + 2 + 2 + 4 + 4 + 1; const DRO_SHORT_DELAY = 0x00; const DRO_LONG_DELAY = 0x01; const DRO_OPL_LOW = 0x02; const DRO_OPL_HIGH = 0x03; const DRO_ESCAPE = 0x04; export default class Music_DRO_DOSBox_v1 extends MusicHandler { static metadata() { let md = { ...super.metadata(), id: FORMAT_ID, title: 'DOSBox Raw OPL Capture (version 0.1)', games: [], glob: [ '*.dro', ], caps: { channelMap: [ { name: 'OPL-1/4', mappings: [ { name: '2x melodic 2-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLT, target: 0, }, { type: Music.TrackConfiguration.ChannelType.OPLT, target: 3, }, ], }, { name: '1x melodic 4-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLF, target: 0, }, ], }, ], }, { name: 'OPL-2/5', mappings: [ { name: '2x melodic 2-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLT, target: 1, }, { type: Music.TrackConfiguration.ChannelType.OPLT, target: 4, }, ], }, { name: '1x melodic 4-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLF, target: 1, }, ], }, ], }, { name: 'OPL-3/6', mappings: [ { name: '2x melodic 2-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLT, target: 2, }, { type: Music.TrackConfiguration.ChannelType.OPLT, target: 5, }, ], }, { name: '1x melodic 4-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLF, target: 2, }, ], }, ], }, { name: 'OPL-7/8/9', mappings: [ { name: '3x melodic 2-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLT, target: 6, }, { type: Music.TrackConfiguration.ChannelType.OPLT, target: 7, }, { type: Music.TrackConfiguration.ChannelType.OPLT, target: 8, }, ], }, { name: '4x percussion 1-op + 1x percussion 2-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLR, target: UtilOPL.Rhythm.HH, }, { type: Music.TrackConfiguration.ChannelType.OPLR, target: UtilOPL.Rhythm.CY, }, { type: Music.TrackConfiguration.ChannelType.OPLR, target: UtilOPL.Rhythm.TT, }, { type: Music.TrackConfiguration.ChannelType.OPLR, target: UtilOPL.Rhythm.SD, }, { type: Music.TrackConfiguration.ChannelType.OPLR, target: UtilOPL.Rhythm.BD, }, ], }, ], }, { name: 'OPL-10/13', mappings: [ { name: '2x melodic 2-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLT, target: 9, }, { type: Music.TrackConfiguration.ChannelType.OPLT, target: 12, }, ], }, { name: '1x melodic 4-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLF, target: 9, }, ], }, ], }, { name: 'OPL-11/14', mappings: [ { name: '2x melodic 2-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLT, target: 10, }, { type: Music.TrackConfiguration.ChannelType.OPLT, target: 13, }, ], }, { name: '1x melodic 4-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLF, target: 10, }, ], }, ], }, { name: 'OPL-12/15', mappings: [ { name: '2x melodic 2-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLT, target: 11, }, { type: Music.TrackConfiguration.ChannelType.OPLT, target: 14, }, ], }, { name: '1x melodic 4-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLF, target: 11, }, ], }, ], }, { name: 'OPL-16/17/18', mappings: [ { name: '3x melodic 2-op', channels: [ { type: Music.TrackConfiguration.ChannelType.OPLT, target: 15, }, { type: Music.TrackConfiguration.ChannelType.OPLT, target: 16, }, { type: Music.TrackConfiguration.ChannelType.OPLT, target: 17, }, ], }, ], }, ], tags: {}, supportedEvents: UtilOPL.oplSupportedEvents, patchNames: false, }, }; return md; } static supps() { return null; } static identify(content) { // Files must contain at least the signature. const minLength = 12; if (content.length < minLength) { return { valid: false, reason: `File length ${content.length} is less than minimum valid size of ${minLength}.`, }; } let buffer = new RecordBuffer(content); const header = buffer.readRecord(recordTypes.header); if (header.signature !== 'DBRAWOPL') { return { valid: false, reason: `Signature doesn't match.`, }; } if ((header.verMajor !== 0) || (header.verMinor !== 1)) { return { valid: false, reason: `File is version ${header.verMajor}.${header.verMinor}, we only support 0.1.`, }; } return { valid: true, }; } static parse(content) { const debug = Debug.extend('parse'); let buffer = new RecordBuffer(content.main); if (buffer.length < DRO_HEADER_LEN) { throw new Error(`File too short, ${buffer.length} bytes isn't enough to read the ${DRO_HEADER_LEN}-byte header.`); } let oplData = []; const header = buffer.readRecord(recordTypes.header); debug(`Format version ${header.verMajor}.${header.verMinor}, reading as 0.1`); const subheader = buffer.readRecord(recordTypes.subheader); if ((subheader.flagHW >> 8) !== 0) { debug('Looks like pre-0.1 version'); // Undo UINT32LE flagHW, treat it as if it was UINT8 buffer.seekRel(-3); } let regOffset = 0; // 0 = low registers, 0x100 = high registers let len = Math.min(subheader.lenBytes, buffer.length); while (len--) { const cmd = buffer.read(RecordType.int.u8); switch (cmd) { case DRO_SHORT_DELAY: oplData.push({ delay: buffer.read(RecordType.int.u8) + 1, }); len--; break; case DRO_LONG_DELAY: oplData.push({ delay: buffer.read(RecordType.int.u16le) + 1, }); len -= 2; break; case DRO_OPL_LOW: regOffset = 0; break; case DRO_OPL_HIGH: regOffset = 0x100; break; case DRO_ESCAPE: { const reg = buffer.read(RecordType.int.u8); const val = buffer.read(RecordType.int.u8); oplData.push({ reg: regOffset + reg, val: val, }); len -= 2; break; } default: oplData.push({ reg: regOffset + cmd, val: buffer.read(RecordType.int.u8), }); len--; break; } } let music = new Music(); if (buffer.distFromEnd() >= 12) { // 12 = minimum length of empty tags // There's some trailing data const sig = buffer.read(RecordType.int.u8); if (sig === 0x1A) { try { const tags = buffer.readRecord(recordTypes.tags); music.tags = { title: tags.title, artist: tags.artist, comment: tags.comment, }; if (tags.app && tags.app.length > 0) { debug(`File was created by: ${tags.app}`); } } catch (e) { debug(`Exception reading tags: ${e}`); } } else { debug('Not reading tags, extra data but incorrect signature byte.'); } } else { debug(`Not reading tags, only ${buffer.distFromEnd()} bytes left in file`); } music.initialTempo = new TempoEvent(); music.initialTempo.usPerTick = 1000; const { events, patches } = parseOPL(oplData); music.patches = patches; // Split the single long list of events into tracks. const { trackConfig, pattern } = UtilMusic.splitEvents(events, UtilOPL.standardTrackSplitConfig); music.trackConfig = trackConfig; // TODO: Split into multiple patterns music.patterns = [pattern]; return music; } static generate(music) { // Convert all the events across all tracks in all patterns into a single // event list. let events = UtilMusic.mergePatternsAndTracks(music.patterns); // Remove any tempo events and adjust timing so the song will play at a // fixed speed. events = UtilMusic.fixedTempo( events, music.initialTempo, new TempoEvent({usPerTick: 1000}) ); const { oplData, warnings } = generateOPL( events, music.patches, music.trackConfig ); let binOPL = new RecordBuffer(65536); let msTotal = 0; let hasOPL3 = false; let lastchip = 0; for (const d of oplData) { if (d.delay > 255) { binOPL.write(RecordType.int.u8, DRO_LONG_DELAY); binOPL.write(RecordType.int.u16le, d.delay - 1); msTotal += d.delay; } else if (d.delay > 0) { binOPL.write(RecordType.int.u8, DRO_SHORT_DELAY); binOPL.write(RecordType.int.u8, d.delay - 1); msTotal += d.delay; } else if (d.reg !== undefined) { if (d.reg >= 0x100) { hasOPL3 = true; if (lastchip != 1) { binOPL.write(RecordType.int.u8, DRO_OPL_HIGH); lastchip = 1; } } else { if (lastchip != 0) { binOPL.write(RecordType.int.u8, DRO_OPL_LOW); lastchip = 0; } } const targetReg = d.reg & 0xFF; if (targetReg <= 0x04) { binOPL.write(RecordType.int.u8, DRO_ESCAPE); } binOPL.write(RecordType.int.u8, targetReg); binOPL.write(RecordType.int.u8, d.val); } else { // TempoEvent could trigger this, but because of the call to // fixedTempo() above, we'll never see any TempoEvents. const ds = JSON.stringify(d); throw new Error(`Unable to handle OPL data: ${ds}.`); } } let binFile = new RecordBuffer(binOPL.length + 32 + 1024); binFile.writeRecord(recordTypes.header, { signature: 'DBRAWOPL', verMajor: 0, verMinor: 1, }); binFile.writeRecord(recordTypes.subheader, { lenMilliseconds: msTotal, lenBytes: binOPL.length, flagHW: hasOPL3 ? 1 : 0, // we don't handle dual-OPL2 }); binFile.put(binOPL); if (Object.keys(music.tags).length) { this.writeTags(binFile, music.tags); } return { content: { main: binFile.getU8(), }, warnings, }; } static writeTags(buffer, tags) { buffer.write(RecordType.int.u8, 0x1A); buffer.writeRecord(recordTypes.tags, { title: tags.title || '', artist: tags.artist || '', comment: tags.comment || '', app: 'CAMOTOJS', }); } }