oplon 1.0.0

A no_std, integer-only OPL2/OPL3 (Yamaha YM3812/YMF262) FM synthesis core with a clean-room, compile-time-generated ROM set
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
//! `vgmplay` — a minimal VGM / DRO player built on `oplon`.
//!
//! Parses a **VGM** (Video Game Music) or **DRO** (DOSBox Raw OPL) register-log
//! file, feeds its OPL chip writes into an [`oplon::Opl2`], and renders the
//! audio between the log's `wait` commands. Output goes to the default audio
//! device (cpal) or to a WAV file (`--wav`). Gzip-compressed streams (`.vgz`,
//! the common on-disk form) are decompressed transparently, and a `.dro` file
//! is transcoded to an in-memory VGM (both DRO v1 and v2) so the same player
//! path handles it.
//!
//! Scope: the **OPL family** — YM3812 (OPL2, VGM command `0x5A`), YM3526
//! (OPL1, `0x5B`), Y8950 (`0x5C`, melodic part only) and the YMF262 (OPL3,
//! both register banks: port 0 `0x5E`, port 1 `0x5F`, including 4-operator
//! mode). They share the OPL register map, so all drive the same core; a
//! YMF262 stream enables OPL3 mode (18 channels, stereo, waveforms 4-7) and is
//! clocked at `clock / 288` rather than the OPL2 `clock / 72`. Every other
//! chip's commands are recognised only well enough to skip the right number of
//! operand bytes and keep the OPL timeline in sync.
//!
//! Build/run: `cargo run --release --features player --bin vgmplay -- <file.vgm>`
//! Install:   `cargo install oplon --features player`

// The core lib is no_std / MSRV-pinned; the `player` feature pulls
// clap/cpal/hound/flate2, which require a much newer toolchain, so the MSRV
// does not bind this binary.
#![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;

// ---------------------------------------------------------------------------
// VGM file
// ---------------------------------------------------------------------------

/// VGM streams are timed in samples at this fixed rate (the spec's clock).
const VGM_SAMPLE_RATE: u32 = 44_100;

/// The parsed parts of a VGM header we need. Multi-byte fields are
/// little-endian on disk. Offsets that are stored *relative* are resolved to
/// absolute here.
struct Vgm {
    version: u32,
    /// Absolute offset of the first command byte.
    data_start: usize,
    /// Absolute offset of the loop point, or `None` if the tune does not loop.
    loop_start: Option<usize>,
    /// Declared YM3812 (OPL2) master clock, Hz. 0 if the chip is absent.
    ym3812_clock: u32,
    /// Declared YMF262 (OPL3) master clock, Hz. 0 if absent.
    ymf262_clock: u32,
    /// Declared YM3526 (OPL1) master clock, Hz. 0 if absent.
    ym3526_clock: u32,
    /// Declared Y8950 master clock, Hz. 0 if absent.
    y8950_clock: u32,
    /// GD3 tag: (track, game, author) in English, if present.
    gd3: Option<(String, String, String)>,
    /// The whole file image (header + data).
    raw: Vec<u8>,
}

impl Vgm {
    fn parse(bytes: Vec<u8>) -> Result<Vgm, String> {
        // Transparently gunzip a `.vgz` (gzip magic 1f 8b).
        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
        };

        // Transparently transcode a DRO (DOSBox Raw OPL) log into an equivalent
        // in-memory VGM, so the whole VGM player path is reused unchanged.
        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);

        // VGM data offset (relative to 0x34). Zero, or a pre-1.50 file, means
        // the data starts at the fixed 0x40.
        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;
        // Ignore a loop offset that points past the end of the file (a bounded
        // but wasteful spin otherwise): treat it as "no loop".
        let loop_start = match rel_loop {
            0 => None,
            _ if 0x1C + rel_loop < raw.len() => Some(0x1C + rel_loop),
            _ => None,
        };

        // Chip clocks. The OPL fields only exist from the header sizes that
        // reach them; guard each read against the actual length.
        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);

        // GD3 tag (offset relative to 0x14).
        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,
        })
    }

    /// The OPL master clock to drive the chip with (Hz). Prefers YM3812, then
    /// the other OPL variants, then the canonical clock if none is declared.
    /// (Only used for the banner; the actual pitch comes from
    /// [`Self::opl_native_rate`], which knows each chip's clock divisor.)
    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
    }

    /// The native operator rate (Hz) to run the chip at. The OPL2 family
    /// (YM3812 / YM3526 / Y8950) divides its clock by 72; the OPL3 (YMF262)
    /// divides its 4×-higher clock by 288 — both land on ~49716 Hz. Getting the
    /// divisor right is what keeps an OPL3 stream (14.3 MHz clock) in tune
    /// instead of four octaves too high.
    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); // OPL3: /288
        }
        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); // OPL2 family: /72
            }
        }
        oplon::OPL_NATIVE_RATE
    }

    /// Whether the file declares an OPL3 (YMF262) chip.
    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
    }
}

/// Emit a VGM `wait` (0x61) for a DRO delay of `ms` milliseconds, rescaled to
/// the 44100 Hz VGM sample clock (split across commands if > 65535 samples).
/// Returns the sample count added (for the VGM total-samples header field).
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
}

/// Emit a VGM OPL register write (`0x5E` port 0 / `0x5F` port 1) for a DRO write.
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);
}

/// Transcode a **DRO** (DOSBox Raw OPL) register log into an equivalent VGM
/// byte stream, so the rest of the player treats it exactly like a VGM. DRO is
/// OPL-only (no multi-chip commands), so it maps directly onto YMF262 port-0
/// (`0x5E`) / port-1 (`0x5F`) writes and `0x61` sample waits; DRO delays are in
/// milliseconds, rescaled here to the VGM 44100 Hz sample clock. Handles DRO
/// **v1** (0.1, the OpenMPT export) and **v2** (2.0, the DOSBox default, with
/// its register code-map).
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;

    // hardware type: 0 = OPL2, 1 = OPL3, 2 = dual-OPL2 (driven as OPL3 here so
    // the second register bank sounds).
    let opl3: bool;

    match (le16(8), le16(10)) {
        // DRO v1 (0.1): header magic + version + lengthMs + lengthBytes + hwType.
        (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 => {
                        // Escape: the next pair is a write to register 0x00-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;
                    }
                }
            }
        }
        // DRO v2 (2.0): lengthPairs + lengthMs + hwType + format + compression +
        // short/long delay codes + a register code-map.
        (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); // VGM end-of-data

    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)
}

/// Read a UTF-16LE, NUL-separated GD3 v1.00 tag. Returns (track, game, author)
/// in English (fields 0, 2, 6). Best-effort — returns `None` on any oddity.
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());
    // Split the UTF-16LE blob on NUL words.
    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();
    // Fields: 0 track-en, 1 track-ja, 2 game-en, 3 game-ja, 4 sys-en, 5 sys-ja,
    // 6 author-en, ...
    Some((get(0), get(2), get(6)))
}

// ---------------------------------------------------------------------------
// Player: steps the VGM command stream and renders OPL audio
// ---------------------------------------------------------------------------

struct Player {
    opl: Opl2,
    raw: Vec<u8>,
    /// Current read position in `raw`.
    pos: usize,
    loop_start: Option<usize>,
    /// How many more times to loop back at end-of-data (`0` = stop; a live
    /// player may set this to a large number for near-endless playback).
    loops_left: u32,
    /// The chip's output sample rate (Hz). VGM `wait` counts are in 1/44100 s,
    /// so they are rescaled to this rate before being drained frame-by-frame.
    output_rate: u32,
    /// Pending **output-rate** frames still owed to the caller before the next
    /// command is processed.
    wait: u64,
    /// Fractional carry for the 44100→`output_rate` wait rescale (Bresenham),
    /// so the sample count averages exactly and never drifts.
    wait_frac: u64,
    /// Output-frame counter (also the CSV sample index for `--regdump`, where
    /// the player runs at 44100 so it equals the VGM sample clock).
    sample_clock: u64,
    finished: bool,
    /// Optional sink for the register-write diagnostic (sample, reg, val). For
    /// port-1 writes the register is logged with bit 8 set (`0x1xx`).
    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);
        // An OPL3 stream is an OPL3 chip: enable OPL3 mode up front so the
        // second bank, stereo panning and waveforms 4-7 are live even before
        // the stream's own `0x105` write. Plain OPL2 tunes stay in OPL2 mode.
        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),
        }
    }

    /// Add a `wait` of `units` at the VGM sample clock (1/44100 s), converting
    /// it to output-rate frames with a Bresenham carry so the tune keeps real
    /// time regardless of the device sample rate.
    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) // treat past-EOF as end-of-data
    }

    /// Apply an OPL register write on `port` (0 = registers 0x00-0xFF / bank 0,
    /// 1 = the OPL3 second bank), logging it for `--regdump`.
    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);
        }
    }

    /// Process commands until the next `wait` accumulates some output frames,
    /// or the stream ends. Leaves the number of frames owed in `self.wait`.
    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 {
                // --- OPL family register writes: port 0 (bank 0) ---
                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);
                }
                // YMF262 port 1 (OPL3 second bank: channels 9-17, 4-op & enable).
                0x5F => {
                    let reg = self.byte(self.pos);
                    let val = self.byte(self.pos + 1);
                    self.pos += 2;
                    self.opl_write(1, reg, val);
                }
                // --- waits (in VGM 1/44100 s units; rescaled by add_wait) ---
                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), // one 60 Hz frame
                0x63 => self.add_wait(882), // one 50 Hz frame
                0x70..=0x7F => self.add_wait((cmd & 0x0F) as u64 + 1),
                // YM2612 DAC "write + wait n" — no OPL effect, just the wait.
                0x80..=0x8F => self.add_wait((cmd & 0x0F) as u64),
                // --- end of data ---
                0x66 => self.end_of_data(),
                // --- data blocks / PCM (skip their payload) ---
                0x67 => {
                    // 0x67 0x66 tt ssssssss <ss bytes>
                    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;
                }
                // PCM RAM write: `0x66 cc oo oo oo dd dd dd ss ss ss` = 11
                // operand bytes after the 0x68 command.
                0x68 => self.pos += 11,
                // --- everything else: skip the right number of operands ---
                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,
        }
    }

    /// Pull one output-rate stereo frame, stepping the command stream as
    /// needed. Returns `None` once the stream has ended and its audio drained.
    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)))
    }
}

/// Accumulated OPL sum → hard-clamped i16. Each operator peaks at ±4084 and up
/// to 9 channels sum, so a solo voice sits around −18 dBFS with ample headroom;
/// the clamp only engages on the rare fully-stacked peak (audible clip, but it
/// never wraps). A future `--gain` knob could trade headroom for loudness.
fn clamp16(v: i32) -> i16 {
    v.clamp(i16::MIN as i32, i16::MAX as i32) as i16
}

/// Operand byte count for VGM commands we don't interpret, so the stream stays
/// in sync. Covers the reserved ranges the spec defines lengths for.
fn operand_len(cmd: u8) -> usize {
    match cmd {
        0x4F | 0x50 => 1,                    // GG stereo, SN76489
        0x30..=0x3F => 1,                    // reserved, one operand
        0x40..=0x4E => 2,                    // reserved, two operands
        0x51..=0x5F => 2,                    // chip writes (reg, val)
        0xA0..=0xBF => 2,                    // AY8910 & second-chip writes
        0xC0..=0xDF => 3,                    // 16-bit-address chip writes
        0xE0..=0xFF => 4,                    // 32-bit-parameter writes
        0x90 => 4,                           // DAC stream: setup
        0x91 => 4,                           // DAC stream: set data
        0x92 => 5,                           // DAC stream: set frequency
        0x93 => 10,                          // DAC stream: start
        0x94 => 1,                           // DAC stream: stop
        0x95 => 4,                           // DAC stream: start fast
        _ => 0,
    }
}

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

#[derive(Parser)]
#[command(about = "Minimal VGM/DRO (OPL2/OPL3) player on the oplon FM engine")]
struct Args {
    /// Path to a .vgm / .vgz / .dro file.
    file: PathBuf,
    /// Seconds to play / render. Omit for the tune's natural length (looped
    /// tunes play `--loops` extra times, else once through).
    #[arg(long)]
    seconds: Option<f64>,
    /// Times to repeat from the loop point at end-of-data (looped tunes only).
    #[arg(long, default_value_t = 1)]
    loops: u32,
    /// Write a WAV file (stereo) instead of playing live.
    #[arg(long)]
    wav: Option<PathBuf>,
    /// Diagnostic: dump every OPL register write as CSV (`sample,reg,val`) to
    /// this file instead of producing audio. The ground-truth register
    /// timeline for comparing a reimplemented player against the log.
    #[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);
    // Step the whole stream (bounded by --seconds if given) purely to collect
    // the register writes; discard the audio.
    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;
            }
        }
    }
    // reg is 3 hex digits: 0x0xx = port 0, 0x1xx = port 1 (OPL3 second bank).
    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;

    // For live playback, loop generously unless the user constrained --seconds.
    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() {
                        // Mono-downmix onto every channel if the device isn't
                        // stereo; otherwise L on even, R on odd.
                        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::*;

    /// Build a minimal VGM 1.51 image with a YM3812 tone on channel 0.
    fn minimal_opl_vgm() -> Vec<u8> {
        let data_start = 0x80usize;
        let writes: &[(u8, u8)] = &[
            (0x20, 0x01),
            (0x23, 0x01),
            (0x40, 0x3F), // modulator silent
            (0x43, 0x00), // carrier loud
            (0x60, 0xF0),
            (0x63, 0xF0),
            (0x80, 0x00),
            (0x83, 0x00),
            (0xC0, 0x00),
            (0xA0, 0x98),
            (0xB0, 0x31), // key-on, block 4
        ];
        let mut data = Vec::new();
        for &(r, v) in writes {
            data.extend_from_slice(&[0x5A, r, v]);
        }
        data.push(0x61); // wait
        data.extend_from_slice(&4410u16.to_le_bytes()); // 0.1 s
        data.push(0x66); // end

        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}");
        // 0.1 s at 44100 → ~4410 samples produced.
        assert!(
            (4000..5000).contains(&player.sample_clock),
            "unexpected length {}",
            player.sample_clock
        );
    }

    /// A hand-built DRO v1 and v2 carrying the same OPL2 tone must transcode to
    /// a valid VGM, declare OPL, and render audible sound.
    #[test]
    fn dro_v1_and_v2_transcode_and_render() {
        let writes: &[(u8, u8)] = &[
            (0x20, 0x01),
            (0x40, 0x3F), // modulator silent
            (0x43, 0x00), // carrier loud
            (0x60, 0xF0),
            (0x63, 0xF0),
            (0xA0, 0x98),
            (0xB0, 0x31), // key-on
        ];
        // --- DRO v1 (0.1): magic, version, lengthMs, lengthBytes, hwType=0 ---
        let mut data = Vec::new();
        for &(r, v) in writes {
            data.extend_from_slice(&[r, v]);
        }
        data.push(0x00);
        data.push(99); // delay 100 ms
        let mut v1 = Vec::new();
        v1.extend_from_slice(b"DBRAWOPL");
        v1.extend_from_slice(&0u16.to_le_bytes()); // verHi
        v1.extend_from_slice(&1u16.to_le_bytes()); // verLo
        v1.extend_from_slice(&100u32.to_le_bytes()); // lengthMs
        v1.extend_from_slice(&(data.len() as u32).to_le_bytes());
        v1.extend_from_slice(&0u32.to_le_bytes()); // hwType 0 = OPL2
        v1.extend_from_slice(&data);

        // --- DRO v2 (2.0) with a register code-map ---
        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); // short delay 100 ms
        let mut v2 = Vec::new();
        v2.extend_from_slice(b"DBRAWOPL");
        v2.extend_from_slice(&2u16.to_le_bytes()); // verHi
        v2.extend_from_slice(&0u16.to_le_bytes()); // verLo
        v2.extend_from_slice(&((writes.len() + 1) as u32).to_le_bytes()); // lengthPairs
        v2.extend_from_slice(&100u32.to_le_bytes()); // lengthMs
        v2.extend_from_slice(&[0, 0, 0, short, long, regs.len() as u8]);
        v2.extend_from_slice(&regs);
        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() {
        // The minimal VGM waits 4410 units (0.1 s at the VGM 44100 clock).
        // At 44100 output that is 4410 frames; at 48000 it must be 4800 — real
        // time is preserved regardless of the device rate (tempo bug #1).
        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); // SN76489
        assert_eq!(operand_len(0x52), 2); // YM2612 port 0
        assert_eq!(operand_len(0xA0), 2); // AY8910
        assert_eq!(operand_len(0xC0), 3);
        assert_eq!(operand_len(0xE0), 4);
        assert_eq!(operand_len(0x93), 10); // DAC stream start
    }
}