#![forbid(unsafe_code)]
#![allow(clippy::incompatible_msrv)]
use std::collections::VecDeque;
use std::io::Read;
use std::path::PathBuf;
use std::sync::mpsc;
use clap::Parser;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use oplon::Opl2;
const VGM_SAMPLE_RATE: u32 = 44_100;
struct Vgm {
version: u32,
data_start: usize,
loop_start: Option<usize>,
ym3812_clock: u32,
ymf262_clock: u32,
ym3526_clock: u32,
y8950_clock: u32,
gd3: Option<(String, String, String)>,
raw: Vec<u8>,
}
impl Vgm {
fn parse(bytes: Vec<u8>) -> Result<Vgm, String> {
let raw = if bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b {
let mut d = flate2::read::GzDecoder::new(&bytes[..]);
let mut out = Vec::new();
d.read_to_end(&mut out)
.map_err(|e| format!("gzip decode failed: {e}"))?;
out
} else {
bytes
};
let raw = if raw.len() >= 8 && &raw[0..8] == b"DBRAWOPL" {
dro_to_vgm(&raw)?
} else {
raw
};
if raw.len() < 0x40 || &raw[0..4] != b"Vgm " {
return Err("not a VGM or DRO file (bad magic)".into());
}
let le32 = |o: usize| -> u32 {
u32::from_le_bytes([raw[o], raw[o + 1], raw[o + 2], raw[o + 3]])
};
let version = le32(0x08);
let rel_data = le32(0x34) as usize;
let data_start = if version < 0x150 || rel_data == 0 {
0x40
} else {
0x34 + rel_data
};
let rel_loop = le32(0x1C) as usize;
let loop_start = match rel_loop {
0 => None,
_ if 0x1C + rel_loop < raw.len() => Some(0x1C + rel_loop),
_ => None,
};
let clock_at = |o: usize| -> u32 {
if o + 4 <= data_start && o + 4 <= raw.len() {
le32(o)
} else {
0
}
};
let ym3812_clock = clock_at(0x50);
let ym3526_clock = clock_at(0x54);
let y8950_clock = clock_at(0x58);
let ymf262_clock = clock_at(0x5C);
let rel_gd3 = le32(0x14) as usize;
let gd3 = if rel_gd3 != 0 {
parse_gd3(&raw, 0x14 + rel_gd3)
} else {
None
};
if data_start >= raw.len() {
return Err("VGM data offset past end of file".into());
}
Ok(Vgm {
version,
data_start,
loop_start,
ym3812_clock,
ymf262_clock,
ym3526_clock,
y8950_clock,
gd3,
raw,
})
}
fn opl_clock(&self) -> u32 {
let pick = |c: u32| c & 0x3FFF_FFFF;
for &c in &[
self.ym3812_clock,
self.ymf262_clock,
self.ym3526_clock,
self.y8950_clock,
] {
let c = pick(c);
if c != 0 {
return c;
}
}
oplon::YM3812_CLOCK
}
fn opl_native_rate(&self) -> u32 {
let pick = |c: u32| c & 0x3FFF_FFFF;
let ymf = pick(self.ymf262_clock);
if ymf != 0 {
return ((ymf + 144) / 288).max(1); }
for &c in &[self.ym3812_clock, self.ym3526_clock, self.y8950_clock] {
let c = pick(c);
if c != 0 {
return ((c + 36) / 72).max(1); }
}
oplon::OPL_NATIVE_RATE
}
fn is_opl3(&self) -> bool {
self.ymf262_clock & 0x3FFF_FFFF != 0
}
fn has_opl(&self) -> bool {
(self.ym3812_clock | self.ymf262_clock | self.ym3526_clock | self.y8950_clock)
& 0x3FFF_FFFF
!= 0
}
}
fn dro_push_wait(data: &mut Vec<u8>, ms: u32) -> u64 {
let total = ms as u64 * u64::from(VGM_SAMPLE_RATE) / 1000;
let mut samples = total;
while samples > 0 {
let n = samples.min(65535) as u16;
data.push(0x61);
data.extend_from_slice(&n.to_le_bytes());
samples -= u64::from(n);
}
total
}
fn dro_push_write(data: &mut Vec<u8>, port: u8, reg: u8, val: u8) {
data.push(if port == 0 { 0x5E } else { 0x5F });
data.push(reg);
data.push(val);
}
fn dro_to_vgm(dro: &[u8]) -> Result<Vec<u8>, String> {
if dro.len() < 12 {
return Err("DRO file too short".into());
}
let byte = |i: usize| dro.get(i).copied().unwrap_or(0);
let le16 = |o: usize| u16::from_le_bytes([byte(o), byte(o + 1)]);
let le32 = |o: usize| u32::from_le_bytes([byte(o), byte(o + 1), byte(o + 2), byte(o + 3)]);
let mut data: Vec<u8> = Vec::new();
let mut total_samples: u64 = 0;
let opl3: bool;
match (le16(8), le16(10)) {
(0, 1) => {
if dro.len() < 24 {
return Err("truncated DRO v1 header".into());
}
let length_bytes = le32(16) as usize;
opl3 = matches!(le32(20), 1 | 2);
let end = if length_bytes > 0 {
(24 + length_bytes).min(dro.len())
} else {
dro.len()
};
let mut p = 24;
let mut port = 0u8;
while p < end {
let b = byte(p);
p += 1;
match b {
0x00 => {
total_samples += dro_push_wait(&mut data, byte(p) as u32 + 1);
p += 1;
}
0x01 => {
total_samples += dro_push_wait(&mut data, le16(p) as u32 + 1);
p += 2;
}
0x02 => port = 0,
0x03 => port = 1,
0x04 => {
dro_push_write(&mut data, port, byte(p), byte(p + 1));
p += 2;
}
reg => {
dro_push_write(&mut data, port, reg, byte(p));
p += 1;
}
}
}
}
(2, 0) => {
if dro.len() < 26 {
return Err("truncated DRO v2 header".into());
}
let length_pairs = le32(12) as usize;
opl3 = matches!(byte(20), 1 | 2);
let short_code = byte(23);
let long_code = byte(24);
let codemap_len = byte(25) as usize;
let codemap_start = 26;
let codemap = |i: usize| byte(codemap_start + i);
let mut p = codemap_start + codemap_len;
for _ in 0..length_pairs {
if p + 1 >= dro.len() {
break;
}
let (code, value) = (byte(p), byte(p + 1));
p += 2;
if code == short_code {
total_samples += dro_push_wait(&mut data, value as u32 + 1);
} else if code == long_code {
total_samples += dro_push_wait(&mut data, (value as u32 + 1) * 256);
} else {
let port = (code >> 7) & 1;
dro_push_write(&mut data, port, codemap((code & 0x7f) as usize), value);
}
}
}
(hi, lo) => return Err(format!("unsupported DRO version {hi}.{lo}")),
}
data.push(0x66);
let mut vgm = vec![0u8; 0x80];
vgm[0..4].copy_from_slice(b"Vgm ");
vgm[0x08..0x0C].copy_from_slice(&0x151u32.to_le_bytes());
vgm[0x18..0x1C].copy_from_slice(&(total_samples.min(u32::MAX as u64) as u32).to_le_bytes());
vgm[0x34..0x38].copy_from_slice(&((0x80 - 0x34) as u32).to_le_bytes());
if opl3 {
vgm[0x5C..0x60].copy_from_slice(&oplon::YMF262_CLOCK.to_le_bytes());
} else {
vgm[0x50..0x54].copy_from_slice(&oplon::YM3812_CLOCK.to_le_bytes());
}
vgm.extend_from_slice(&data);
Ok(vgm)
}
fn parse_gd3(raw: &[u8], off: usize) -> Option<(String, String, String)> {
if off + 12 > raw.len() || &raw[off..off + 4] != b"Gd3 " {
return None;
}
let len = u32::from_le_bytes([raw[off + 8], raw[off + 9], raw[off + 10], raw[off + 11]])
as usize;
let start = off + 12;
let end = (start + len).min(raw.len());
let mut fields: Vec<String> = Vec::new();
let mut cur = String::new();
let mut i = start;
while i + 1 < end {
let u = u16::from_le_bytes([raw[i], raw[i + 1]]);
i += 2;
if u == 0 {
fields.push(std::mem::take(&mut cur));
} else if let Some(c) = char::from_u32(u as u32) {
cur.push(c);
}
}
let get = |n: usize| fields.get(n).cloned().unwrap_or_default();
Some((get(0), get(2), get(6)))
}
struct Player {
opl: Opl2,
raw: Vec<u8>,
pos: usize,
loop_start: Option<usize>,
loops_left: u32,
output_rate: u32,
wait: u64,
wait_frac: u64,
sample_clock: u64,
finished: bool,
regdump: Option<Vec<(u64, u16, u8)>>,
}
impl Player {
fn new(vgm: &Vgm, output_rate: u32, loops: u32, regdump: bool) -> Player {
let mut opl = Opl2::with_native_rate(vgm.opl_native_rate(), output_rate);
opl.set_opl3_enabled(vgm.is_opl3());
Player {
opl,
raw: vgm.raw.clone(),
pos: vgm.data_start,
loop_start: vgm.loop_start,
loops_left: loops,
output_rate,
wait: 0,
wait_frac: 0,
sample_clock: 0,
finished: false,
regdump: regdump.then(Vec::new),
}
}
fn add_wait(&mut self, units: u64) {
self.wait_frac += units * self.output_rate as u64;
self.wait += self.wait_frac / VGM_SAMPLE_RATE as u64;
self.wait_frac %= VGM_SAMPLE_RATE as u64;
}
#[inline]
fn byte(&self, at: usize) -> u8 {
self.raw.get(at).copied().unwrap_or(0x66) }
fn opl_write(&mut self, port: u8, reg: u8, val: u8) {
if let Some(d) = &mut self.regdump {
let r = ((port as u16) << 8) | reg as u16;
d.push((self.sample_clock, r, val));
}
if port == 0 {
self.opl.write_reg(reg, val);
} else {
self.opl.write_reg_high(reg, val);
}
}
fn advance_to_next_wait(&mut self) {
while self.wait == 0 && !self.finished {
if self.pos >= self.raw.len() {
self.end_of_data();
continue;
}
let cmd = self.byte(self.pos);
self.pos += 1;
match cmd {
0x5A | 0x5B | 0x5C | 0x5E => {
let reg = self.byte(self.pos);
let val = self.byte(self.pos + 1);
self.pos += 2;
self.opl_write(0, reg, val);
}
0x5F => {
let reg = self.byte(self.pos);
let val = self.byte(self.pos + 1);
self.pos += 2;
self.opl_write(1, reg, val);
}
0x61 => {
let n = u16::from_le_bytes([self.byte(self.pos), self.byte(self.pos + 1)]);
self.pos += 2;
self.add_wait(n as u64);
}
0x62 => self.add_wait(735), 0x63 => self.add_wait(882), 0x70..=0x7F => self.add_wait((cmd & 0x0F) as u64 + 1),
0x80..=0x8F => self.add_wait((cmd & 0x0F) as u64),
0x66 => self.end_of_data(),
0x67 => {
let size = u32::from_le_bytes([
self.byte(self.pos + 2),
self.byte(self.pos + 3),
self.byte(self.pos + 4),
self.byte(self.pos + 5),
]) as usize;
self.pos += 6 + size;
}
0x68 => self.pos += 11,
other => {
self.pos += operand_len(other);
}
}
}
}
fn end_of_data(&mut self) {
match (self.loops_left, self.loop_start) {
(n, Some(ls)) if n > 0 => {
self.loops_left -= 1;
self.pos = ls;
}
_ => self.finished = true,
}
}
fn next_frame(&mut self) -> Option<(i16, i16)> {
if self.wait == 0 {
self.advance_to_next_wait();
}
if self.wait == 0 && self.finished {
return None;
}
if self.wait > 0 {
self.wait -= 1;
}
self.sample_clock += 1;
let (l, r) = self.opl.render_frame();
Some((clamp16(l), clamp16(r)))
}
}
fn clamp16(v: i32) -> i16 {
v.clamp(i16::MIN as i32, i16::MAX as i32) as i16
}
fn operand_len(cmd: u8) -> usize {
match cmd {
0x4F | 0x50 => 1, 0x30..=0x3F => 1, 0x40..=0x4E => 2, 0x51..=0x5F => 2, 0xA0..=0xBF => 2, 0xC0..=0xDF => 3, 0xE0..=0xFF => 4, 0x90 => 4, 0x91 => 4, 0x92 => 5, 0x93 => 10, 0x94 => 1, 0x95 => 4, _ => 0,
}
}
#[derive(Parser)]
#[command(about = "Minimal VGM/DRO (OPL2/OPL3) player on the oplon FM engine")]
struct Args {
file: PathBuf,
#[arg(long)]
seconds: Option<f64>,
#[arg(long, default_value_t = 1)]
loops: u32,
#[arg(long)]
wav: Option<PathBuf>,
#[arg(long)]
regdump: Option<PathBuf>,
}
fn main() {
let args = Args::parse();
let bytes = std::fs::read(&args.file).unwrap_or_else(|e| {
eprintln!("error reading {}: {e}", args.file.display());
std::process::exit(1);
});
let vgm = Vgm::parse(bytes).unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
if !vgm.has_opl() {
eprintln!(
"warning: this VGM declares no OPL chip (YM3812/YM3526/Y8950/YMF262) — \
oplon only synthesises the OPL family, so it will be silent."
);
}
let (track, game, author) = vgm.gd3.clone().unwrap_or_default();
let title = if track.is_empty() { "<untitled>".into() } else { track };
eprintln!(
"{title}{}{} [VGM {}.{:02x} | {} | OPL clock {} Hz → native {} Hz]",
if game.is_empty() { String::new() } else { format!(" — {game}") },
if author.is_empty() { String::new() } else { format!(" — {author}") },
vgm.version >> 8,
vgm.version & 0xFF,
if vgm.is_opl3() { "OPL3" } else { "OPL2" },
vgm.opl_clock(),
vgm.opl_native_rate(),
);
if let Some(path) = &args.regdump {
dump_registers(&vgm, &args, path);
return;
}
match &args.wav {
Some(path) => render_wav(&vgm, &args, path),
None => play_live(&vgm, &args),
}
}
fn dump_registers(vgm: &Vgm, args: &Args, path: &PathBuf) {
let mut player = Player::new(vgm, VGM_SAMPLE_RATE, args.loops, true);
let limit = args.seconds.map(|s| (s * VGM_SAMPLE_RATE as f64) as u64);
while player.next_frame().is_some() {
if let Some(l) = limit {
if player.sample_clock >= l {
break;
}
}
}
let mut out = String::from("sample,reg,val\n");
for (s, reg, val) in player.regdump.take().unwrap_or_default() {
out.push_str(&format!("{s},{reg:03x},{val:02x}\n"));
}
std::fs::write(path, &out).unwrap();
eprintln!("wrote register dump to {}", path.display());
}
fn render_wav(vgm: &Vgm, args: &Args, path: &PathBuf) {
let mut player = Player::new(vgm, VGM_SAMPLE_RATE, args.loops, false);
let spec = hound::WavSpec {
channels: 2,
sample_rate: VGM_SAMPLE_RATE,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut w = hound::WavWriter::create(path, spec).expect("cannot create WAV");
let limit = args.seconds.map(|s| (s * VGM_SAMPLE_RATE as f64) as u64);
while let Some((l, r)) = player.next_frame() {
w.write_sample(l).unwrap();
w.write_sample(r).unwrap();
if let Some(lim) = limit {
if player.sample_clock >= lim {
break;
}
}
}
w.finalize().unwrap();
let secs = player.sample_clock as f64 / VGM_SAMPLE_RATE as f64;
eprintln!("wrote {} ({:.1}s)", path.display(), secs);
}
fn play_live(vgm: &Vgm, args: &Args) {
let host = cpal::default_host();
let device = host
.default_output_device()
.expect("no output audio device");
let config = device.default_output_config().expect("no output config");
let sample_format = config.sample_format();
let stream_config: cpal::StreamConfig = config.into();
let out_rate = stream_config.sample_rate;
let channels = stream_config.channels as usize;
let loops = if args.seconds.is_some() { args.loops } else { 1_000_000 };
let mut player = Player::new(vgm, out_rate, loops, false);
let (tx, rx) = mpsc::sync_channel::<Vec<(i16, i16)>>(16);
let frames_limit = args.seconds.map(|s| (s * out_rate as f64) as u64);
std::thread::spawn(move || loop {
let mut chunk = Vec::with_capacity(1024);
for _ in 0..1024 {
match player.next_frame() {
Some(f) => chunk.push(f),
None => break,
}
if let Some(l) = frames_limit {
if player.sample_clock >= l {
break;
}
}
}
let done = chunk.len() < 1024;
if tx.send(chunk).is_err() || done {
break;
}
});
let err_fn = |e| eprintln!("audio stream error: {e}");
let stream = match sample_format {
cpal::SampleFormat::F32 => build_stream::<f32>(&device, stream_config, rx, channels, err_fn),
cpal::SampleFormat::I16 => build_stream::<i16>(&device, stream_config, rx, channels, err_fn),
cpal::SampleFormat::U16 => build_stream::<u16>(&device, stream_config, rx, channels, err_fn),
other => panic!("unsupported sample format {other:?}"),
};
stream.play().expect("cannot start stream");
match args.seconds {
Some(s) => std::thread::sleep(std::time::Duration::from_secs_f64(s + 0.3)),
None => {
eprintln!("playing… (Ctrl-C to stop)");
loop {
std::thread::sleep(std::time::Duration::from_secs(3600));
}
}
}
}
fn build_stream<T>(
device: &cpal::Device,
config: cpal::StreamConfig,
rx: mpsc::Receiver<Vec<(i16, i16)>>,
channels: usize,
err_fn: impl FnMut(cpal::Error) + Send + 'static,
) -> cpal::Stream
where
T: cpal::SizedSample + cpal::FromSample<f32>,
{
let mut buf: VecDeque<(i16, i16)> = VecDeque::new();
device
.build_output_stream(
config,
move |out: &mut [T], _: &cpal::OutputCallbackInfo| {
for frame in out.chunks_mut(channels) {
if buf.is_empty() {
if let Ok(chunk) = rx.try_recv() {
buf.extend(chunk);
}
}
let (l, r) = buf.pop_front().unwrap_or((0, 0));
let lf = l as f32 / 32768.0;
let rf = r as f32 / 32768.0;
for (i, d) in frame.iter_mut().enumerate() {
let s = if channels >= 2 {
if i % 2 == 0 { lf } else { rf }
} else {
(lf + rf) * 0.5
};
*d = T::from_sample(s);
}
}
},
err_fn,
None,
)
.expect("cannot build output stream")
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_opl_vgm() -> Vec<u8> {
let data_start = 0x80usize;
let writes: &[(u8, u8)] = &[
(0x20, 0x01),
(0x23, 0x01),
(0x40, 0x3F), (0x43, 0x00), (0x60, 0xF0),
(0x63, 0xF0),
(0x80, 0x00),
(0x83, 0x00),
(0xC0, 0x00),
(0xA0, 0x98),
(0xB0, 0x31), ];
let mut data = Vec::new();
for &(r, v) in writes {
data.extend_from_slice(&[0x5A, r, v]);
}
data.push(0x61); data.extend_from_slice(&4410u16.to_le_bytes()); data.push(0x66);
let mut img = vec![0u8; data_start];
img[0x00..0x04].copy_from_slice(b"Vgm ");
img[0x08..0x0C].copy_from_slice(&0x151u32.to_le_bytes());
img[0x34..0x38].copy_from_slice(&((data_start - 0x34) as u32).to_le_bytes());
img[0x50..0x54].copy_from_slice(&3_579_545u32.to_le_bytes());
img.extend_from_slice(&data);
img
}
#[test]
fn parses_and_renders_a_tone() {
let vgm = Vgm::parse(minimal_opl_vgm()).expect("parse");
assert!(vgm.has_opl());
assert_eq!(vgm.data_start, 0x80);
assert_eq!(vgm.opl_clock(), 3_579_545);
let mut player = Player::new(&vgm, VGM_SAMPLE_RATE, 0, false);
let mut peak = 0i16;
while let Some((l, _r)) = player.next_frame() {
peak = peak.max(l.abs());
}
assert!(peak > 100, "VGM tone rendered silent, peak={peak}");
assert!(
(4000..5000).contains(&player.sample_clock),
"unexpected length {}",
player.sample_clock
);
}
#[test]
fn dro_v1_and_v2_transcode_and_render() {
let writes: &[(u8, u8)] = &[
(0x20, 0x01),
(0x40, 0x3F), (0x43, 0x00), (0x60, 0xF0),
(0x63, 0xF0),
(0xA0, 0x98),
(0xB0, 0x31), ];
let mut data = Vec::new();
for &(r, v) in writes {
data.extend_from_slice(&[r, v]);
}
data.push(0x00);
data.push(99); let mut v1 = Vec::new();
v1.extend_from_slice(b"DBRAWOPL");
v1.extend_from_slice(&0u16.to_le_bytes()); v1.extend_from_slice(&1u16.to_le_bytes()); v1.extend_from_slice(&100u32.to_le_bytes()); v1.extend_from_slice(&(data.len() as u32).to_le_bytes());
v1.extend_from_slice(&0u32.to_le_bytes()); v1.extend_from_slice(&data);
let regs: Vec<u8> = writes.iter().map(|&(r, _)| r).collect();
let (short, long) = (0xFFu8, 0xFEu8);
let mut d2 = Vec::new();
for &(r, v) in writes {
d2.push(regs.iter().position(|&x| x == r).unwrap() as u8);
d2.push(v);
}
d2.push(short);
d2.push(99); let mut v2 = Vec::new();
v2.extend_from_slice(b"DBRAWOPL");
v2.extend_from_slice(&2u16.to_le_bytes()); v2.extend_from_slice(&0u16.to_le_bytes()); v2.extend_from_slice(&((writes.len() + 1) as u32).to_le_bytes()); v2.extend_from_slice(&100u32.to_le_bytes()); v2.extend_from_slice(&[0, 0, 0, short, long, regs.len() as u8]);
v2.extend_from_slice(®s);
v2.extend_from_slice(&d2);
for (label, dro) in [("v1", v1), ("v2", v2)] {
let vgm = Vgm::parse(dro).unwrap_or_else(|e| panic!("DRO {label} parse: {e}"));
assert!(vgm.has_opl(), "DRO {label} declares no OPL");
let mut player = Player::new(&vgm, VGM_SAMPLE_RATE, 0, false);
let mut peak = 0i16;
while let Some((l, _)) = player.next_frame() {
peak = peak.max(l.abs());
}
assert!(peak > 100, "DRO {label} rendered silent (peak={peak})");
}
}
#[test]
fn gzip_stream_is_transparent() {
use flate2::{write::GzEncoder, Compression};
use std::io::Write;
let raw = minimal_opl_vgm();
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
enc.write_all(&raw).unwrap();
let gz = enc.finish().unwrap();
assert_eq!(&gz[0..2], &[0x1f, 0x8b]);
let vgm = Vgm::parse(gz).expect("gunzip+parse");
assert!(vgm.has_opl());
assert_eq!(vgm.raw, raw);
}
#[test]
fn wait_rescales_to_output_rate() {
let vgm = Vgm::parse(minimal_opl_vgm()).unwrap();
for (rate, expect) in [(44_100u32, 4410u64), (48_000, 4800)] {
let mut p = Player::new(&vgm, rate, 0, false);
while p.next_frame().is_some() {}
assert_eq!(p.sample_clock, expect, "rate {rate}");
}
}
#[test]
fn operand_lengths_keep_stream_in_sync() {
assert_eq!(operand_len(0x50), 1); assert_eq!(operand_len(0x52), 2); assert_eq!(operand_len(0xA0), 2); assert_eq!(operand_len(0xC0), 3);
assert_eq!(operand_len(0xE0), 4);
assert_eq!(operand_len(0x93), 10); }
}