Plays VGM/VGZ logs through ValleyBell's libvgm — originally just the OPL family (YM3812/OPL2, YMF262/OPL3, YM3526/OPL1, Y8950, i.e. the AdLib / Sound Blaster PC chips), now everything GME cannot decode.
The other .vgm/.vgz handler is gmeplugin, whose blargg Vgm_Emu only
decodes SN76489 (PSG), YM2413 (OPLL), YM2612 and AY8910. Fed an OPL log it
renders silence (OPL3) or aborts with a Blip_Buffer.cpp assertion
(OPL2). So:
LibVGMPlugin::canHandleclaimsvgm/vgzwhen the header carries any chip GME cannot handle, or a dual-chip flag on one it can (see../../vgm_opl_detect.h).GMEPlugin::canHandledeclines those same files.
The two gates are disjoint, so plain Sega / AY console logs stay on GME.
libvgm is vendored once at zxtune/3rdparty/vgm/ (zxtune ships the same tree
for its own — currently disabled — VGM support), plus the USE_ZLIB
FileLoader/MemoryLoader, which read .vgz directly with no external gunzip.
libvgm ships several emulation cores per chip and enables all of them unless
SNDDEV_SELECT is defined — its headers say so outright: "if not asked to
select certain sound devices, just include everything (comfort option)". This
plugin defines SNDDEV_SELECT and lists chips (SNDDEV_*) and cores (EC_*)
explicitly in CMakeLists.txt. The SNDDEV_* list is copied verbatim from
libvgm's own default block in emu/SoundEmu.c, so a chip added upstream must
be added there too or it silently disappears.
Selecting cores is what lets the Mac App Store build leave libvgm's
GPL-licensed cores out (see LEGAL / LEGAL-PLUS for the licence side). Five
GPL cores turned out to be unreachable dead code — libvgm's own preference order
already put a permissive core first — and are compiled in no build:
ym2413.c and nukedopll.c (emu2413 wins), ymf262.c (AdLibEmu wins, and
VGMPlayer hard-requests FCC_ADLE for an OPL4's linked OPL3), nes_apu.c
(NSFPlay wins) and ym2612.c (GPGX, inside fmopn.c, wins). fmopl.c's
YM3812/OPL2 path is dead the same way (AdLibEmu is listed first), and its YM3526
path is now dead too — see the table. A sixth, pwm.c, is no longer compiled in
either build for a different reason: it was replaced rather than out-ranked
(see the 32X PWM section).
Two chips changed core in both variants, so there is no divergence to justify — one for sound, one because the GPL core was replaced outright:
| chip | plus and mas | why |
|---|---|---|
| YM3526 (OPL1) | ymfm, via opl_ymfm.cpp |
preferred by ear over MAME |
| Sega 32X PWM | pwm_32x.c, written here |
replaces the Gens core |
| Virtual Boy VSU | vsu_vb.c, written here |
replaces the Mednafen core |
| WonderSwan | ws_audio_cm.c, written here |
replaces the in_wsr/OSWAN core |
With those in place the App Store build contains no GPL-licensed chip core at
all — checked with nm over liblibvgmplugin.a, not just the build graph.
One chip is simply absent from mas: the MSM5232 (msm5232.c, MAME, GPL).
It needs no replacement and no index gate, because it is unreachable — libvgm
reads its clock from header offset 0xF4, one of its own extension slots that
the VGM format has never defined. Even a v1.71 header ends at 0xD0, and VGMRips
has no MSM5232 pack at all, so no file can select it. SNDDEV_MSM5232 is the one
deliberate omission from the chip whitelist; the chip cannot be gated apart from
its core, because this is one of the few chips whose DEV_DECL lives inside the
core file rather than a separate *intf.c.
Six more differ per variant. plus keeps what it always played:
| chip | plus | mas |
|---|---|---|
| YM2203 / YM2608 / YM2610 | fmopn.c (MAME) |
ymfm, via opn_ymfm.cpp |
| YM2612 | GPGX (in fmopn.c) |
ym3438.c (Nuked OPN2) |
| YM2151 | ym2151.c (MAME) |
nukedopm.c (Nuked OPM) |
| HuC6280 | Ootake_PSG.c |
c6280_mame.c |
| Y8950 | fmopl.c (MAME) |
ymfm, via opl_ymfm.cpp |
| YMF278B / OPL4 wavetable | ymf278b.c (openMSX) |
ymfm, via opl_ymfm.cpp |
| RF5C164 (Sega/Mega CD) | scd_pcm.c (Gens) |
rf5c68.c (MAME) |
The OPL4's FM half is a separate linked YMF262 device and is AdLibEmu in both.
Measured against the MAME/openMSX/Ootake cores on real game music, RMS ratios are 1.00 (OPN), 1.12 (YM2612), 1.00 (YM2151), 0.98 (HuC6280), 1.00 (Y8950, 99% of 70 MSX tracks within ±3%), 0.97 (OPL4) and 1.006 (RF5C164, 117 Sega CD tracks, all inside 1.004–1.015). Caveats:
- Nuked OPM is slower than the MAME YM2151. It matters on the X68000 corpus.
- The MAME HuC6280 runs hot on noise/DDA-heavy material and clips where
Ootake does not (one Battle of the Bits rip: 1.49x, 785 clipped samples in
30 s against Ootake's 3). Cause: libvgm's
_CHIP_VOLUMEtable inplayer/vgmplayer.cppholds one volume per chip type, hand-corrected for K051649 and C140/C219 only, so it is calibrated for whichever core is listed first. A per-core correction there (~0.7x for MAME) would fix it. - The ymfm YM3526 renders OPL rhythm mode differently from MAME. 25 of 28
arcade tracks land within ±3%; the three that do not are all Athena (SNK),
which leans on the percussion channels — "Theme of Titan" measures 0.71x.
A/B'd by ear, ymfm was preferred there, which is why this chip moved in
both variants rather than only in
mas. - The ymfm OPL4 does not interpolate between wavetable samples and
ymf278b.cdoes (linear, between adjacent samples). Real OPL4 hardware does not interpolate either, so ymfm is arguably the more faithful of the two, but it is audibly brighter. Across 33 arcade tracks that carry their own sample ROM (Strikers 1945 II, Gunbird 2) the ratio stays inside 0.91–1.03 with no clipping. Closing the gap would mean patching ymfm'spcm_channel::fetch_sample, which would end the "vendored unmodified" guarantee inexternal/ymfm/PROVENANCE.md; it was left alone deliberately.
MSX MoonSound rips are the OPL4's problem case: they log their own sample upload
(VGM data block 0x87) and never the ROM (0x84), because their instruments
live in the cartridge's 2 MB Yamaha YRW801 — which we cannot ship. 158 tracks in
the corpus (Bombaman Extra Ammo 72, Pumpkin Adventure 3 58, SootSound 14, Sonyc
12, MoonDriver 2), against ~300 arcade OPL4 tracks that do embed their ROM and
were never affected.
Two things were wrong, and they are separable:
- The address map. Sample RAM is mapped directly above the ROM, and real
hardware puts the YRW801 on /MCS0 at
0x000000-0x1FFFFF. With no ROM at allROMSize == 0, so the log's own samples landed 2 MB low and every read from the driver's0x200000+addresses fell in the unmapped hole returning0xFF— DC through the envelope, which is the buzz these files used to play as. Fixed by allocating a 2 MB stub at device start in both cores; 72 tracks became audible on that alone, andplusandmasagreed for the first time (median RMS ratio 1.000 over the loud tracks). - The missing instruments.
cm_yrw801.cfills that stub with a generated wavetable in YRW801 layout — own code, own data, both variants, no Yamaha bytes anywhere. It is a substitute, not a reproduction: notes, rhythm, envelopes and dynamics are right; the timbres are ours.CM_YRW801_ROM=<path>loads a real dump instead, for anyone who has one.
Where the tuning came from, since it is the part that looks impossible. A wave header carries no pitch, so every driver ships a per-wave base-pitch table that we do not have. It was measured out of the music itself: notes for a given wave lie on a 100-cent grid whose phase is wave-specific (222 of 263 waves cluster above 0.9), which gives the sub-semitone part directly; the semitone and octave were scored against concurrent notes of known absolute pitch — the log's own uploaded samples (pitch by autocorrelation) and the OPL4's FM half where a track drives it (pitch exact from F-number and block) — then propagated to the rest by relaxation. 18 waves from FM, 80 from RAM samples, 75 propagated, 17 percussion, 211 too rarely played to determine. Mean interval consonance against all concurrent evidence 0.364, versus 0.179 for random assignment.
Four traps in the synthesis, all of them heard before they were measured:
- Waveforms must be exactly periodic over the stored length, hence
cyclesin the table (stored period =length/cycles, ~1 cent of detune). Rounding a non-integer period instead leaves up to 0.55 samples of phase step at the loop point — a click on every cycle. - The chip resamples with linear interpolation and no filtering, so a wave
played above its stored pitch folds every partial.
partialsis capped per wave from the highest ratio that wave is driven at anywhere in the corpus; a 95th-percentile cap is not enough (MoonDriver drives wave 279 at 7.24x). - Percussion must loop a run of silence. Looping the last two samples is a ~11-22 kHz square wave that never stops.
- Schroeder phases, not zero phases: same spectrum, much lower crest factor, so peak-normalised waves come out far louder.
Note that a track can still be missing instruments for a reason this cannot fix: SootSound's "moon rider" has user waves whose headers point into ROM space (a custom envelope wrapped around a factory instrument), which lands mid-waveform in our bank.
Not an adapter — an own implementation, written against the documented registers
and replacing libvgm's Gens-derived pwm.c in both variants (the VIC-I
precedent in vtplugin). The 32X's sound hardware is a PWM DAC, not a
synthesiser: the pulse width is the sample, so the work is timing and centring.
rate = clock / cycle(register 1), pushed to libvgm throughSetSampleRateChangeCallbackwhen a log writes the cycle register.Resampler.cwires that callback for any device that offers one.sample = width - cycle/2, scaled to fill 16 bits. Silence is mid-period.- Three-entry FIFO per channel, one pop per output sample, holding the last value on underrun.
PWM_CTRLis deliberately ignored. Its low bits nominally select an output mode per side, but real 32X logs write it once as0x100/0x200/0x300— mode bits clear — and still expect stereo, so honouring a "mode 0 = mute" reading would silence the whole platform.
Measured against the Gens core over 40 PWM logs (VGMRips 32X packs), aggregate 1.014, median 1.000, no clipping, 36/40 within ±1%. The outliers are all files whose cycle is ~1045 rather than 1475, plus one homebrew test track that feeds the PWM through DAC Stream Control at 11010 Hz into a ~22 kHz device and measures 1.19. Worth knowing when comparing: the Gens core reports a fixed 22020 Hz regardless of the cycle register, so on underrun it loses energy where this one holds the previous sample.
CM_LIBVGM_LEGACY_PWM=ON builds the old GPL core instead, purely so a future
change here can be A/B'd with this file as the single variable. It is refused
when CM_HAVE_LIBVGM_GPL_CORES=OFF.
The other own implementation, replacing the Mednafen vsu.c in both variants.
Written from Nintendo's Virtual Boy Development Manual, Part 6 chapters 2–4
— every constant in the file traces to a numbered section there, which is why
the file cites them. The manual is in the SDK-manuals archive on archive.org
(item virtual-boy.-7z, Technical_Informations.pdf, pages 134–152); it is a
scan with no text layer.
Six sources: 1–5 play a 32-word, 6-bit waveform from one of five RAM banks, 5
adds sweep/modulation, 6 is noise. f = clock / ((2048 - F) × 32), noise
f = (clock/10) / (2048 - F), output level ((L/R × envelope) >> 3) + 1 and
zero if either input is zero. The timer periods land on exact sample counts at
the real clock — 3.84 ms is 160 samples, 15.36 ms is 640, 0.96/7.68 ms are
40/320 — but are derived from the reported rate so an odd header clock cannot
desynchronise them.
Two things cost real debugging time:
- The VGM offset is a word index, not the manual's byte address. Every VSU
register is four-byte aligned, so command
0xC7carriesaddress >> 2: 0–159 waveform RAM, 160–191 modulation RAM, 256–351 channel registers, 352SSTOP. Decode it as a byte address and the chip renders pure silence. - A decay envelope reaching 0 must not disable the channel. The manual says
the output "is stopped", but it also states plainly that "when the envelope
value is 0, the sound is still being output at level 0, and the sound output
is not considered to have stopped". Games drive
SxEV0as a real-time volume with automatic enveloping on, so a channel that has decayed to zero has to come back on the nextSxEV0write. Gating it made one track render at 0.40x while all 352 others measured 1.00.
Measured against Mednafen over the whole 353-file corpus (all 17 VGMRips VSU packs): median 1.000, aggregate 1.0018, 87% within ±1%, 96% within ±3%, 100% within ±10%, and slightly less clipping than the reference (146,275 vs 146,338 samples; one track clips where the reference does not).
CM_LIBVGM_LEGACY_VSU=ON rebuilds the old GPL core for single-variable A/B, and
like its PWM counterpart is refused when CM_HAVE_LIBVGM_GPL_CORES=OFF.
The third own implementation, and the one that closes a hole the licence sweep
above had missed. libvgm's emu/cores/ws_audio.c carries no licence header at
all, but it is Mamiya's in_wsr — the Japanese comments (// Voice出力,
//OSWANの擬似乱数の処理と同等のつもり) and the function names are verbatim — and
in_wsr's own readme says it was cut from OSWAN 0.70, which is GPL-2.0. It was
compiled into mas, and it is not dead code: the DB has 12 WonderSwan rows (9
VGMRips packs, 3 Battle of the Bits).
Written from the WSdev Wiki (https://ws.nesdev.org/wiki/Sound), which
documents every port $80–$9E down to the LFSR tap table. Four channels of 32
× 4-bit wavetable in internal RAM at $8F << 6; the sample index advances every
2048 - divisor master cycles and the DAC runs at clock / 128 = 24000 Hz;
per-channel level is a plain sample * volume; channel 2 swaps in 8-bit unsigned
PCM at 100/50/0% per side, channel 3 adds a signed sweep every ($8D & 0x1F) + 1
ticks of a 375 Hz clock, channel 4 an XNOR-feedback 15-bit LFSR.
Four things worth knowing:
$FFFFin a frequency register means silence — the whole 16-bit word, not the 11-bit divisor. That rule is not in the register documentation; it is in Mamiya's own readme (archived asawesome-wsdev/archive/in_wsr.txt), whose 2006/4/14 entry records finding it because Rockman & Forte otherwise plays a spurious tone. A divisor of$7FFalone still sounds — Digimon D-Project's noise depends on it — so the test has to be on the full word.- The noise LFSR feeds back through an XNOR, so all-ones is the lock-up state, not all-zero. Reset seeds it with 0, and the hardware's own "LFSR reset" bit can safely clear it.
- The phase accumulator must wrap into the 32-step table. Left free-running, a 16.16 counter overflows 32 bits after about a million samples, and the unsigned step difference the noise path takes from it then reads as ~2^32 steps. The symptom is not a wrong sound but an apparent hang about 40 seconds into any noise-using track — which is exactly what it looked like when a 251-file corpus run went from 3 seconds to over 20 minutes.
- Port
$91's output-enable bits are deliberately ignored, likePWM_CTRLinpwm_32x.c. This core always renders the stereo headphone path. VGM logs are emulator register dumps and many never write$91, so honouring "not enabled = silent" would mute whole packs. Its speaker shift only affects the mono speaker mix, which is not the output used here.
Hyper Voice ($64–$6B) and Sound DMA ($40–$4A) are unreachable from a
VGM log — VGMPlayer builds the port as 0x80 + (data & 0x7F), so nothing below
$80 can be written, and DMA would need cartridge ROM the log does not carry.
Writes there land in the register file and do nothing else.
Measured against the in_wsr core over 251 files (all 9 VGMRips WonderSwan packs): median 0.985, aggregate 0.990, 99.2% within ±10%, no track silent that was not silent before, and no clipping in either core (peak 30523 vs 32557). The two outliers are +10% and +11% (Final Fantasy II "Join the Party!", Front Mission "Defeat"). The spread is wider than the VSU's because this is a from-scratch implementation of a chip whose sweep and noise timing the old core derived empirically, not a port. It is also ~100x faster — the corpus renders in 3 s against roughly 6 min, the old core doing per-write float maths.
CM_LIBVGM_LEGACY_WSWAN=ON rebuilds the GPL core for single-variable A/B, and
like the other two is refused when CM_HAVE_LIBVGM_GPL_CORES=OFF.
The RF5C164 is not a core-list swap but a default swap. rf5cintf.c
already offers both cores for DEVID_RF5C68; what picks Gens for the RF5C164 is
one line in player/vgmplayer.cpp (case DEVID_RF5C68), and only when nothing
else asked. That line now follows EC_RF5C68_GENS — a local patch, .orig
next to it. It has to follow the build, because SndEmu_StartCore does not
fall back: a requested-but-absent core returns EERR_NOT_FOUND and VGMPlayer
then plays that chip silent. The MAME core ignores cfg->flags entirely, so it
treats RF5C68 / RF5C164 / RF5C105 alike; the Gens core uses the flag only for a
Cosmic Fantasy Stories MCD workaround.
Beware MSX MoonSound rips when measuring the OPL4. They carry only the
small user-sample RAM upload (VGM data block 0x87) and no ROM block (0x84):
the instruments live in the MoonSound's internal 2 MB YRW801 ROM, which VGM logs
do not embed. Both variants render those files as noise, and an early A/B of
this swap was run almost entirely on them before that was spotted. Check for a
0x84 block before trusting a number.
A core that is not compiled does not fail cleanly: VGMPlayer sets
devDef = NULL and continues, so the file still plays with that chip silent.
That is why cores are only dropped when something replaces them.
libvgm has no permissive OPN emulation at all — fmopn.c is MAME-derived and
GPL, and it is the only core it ships for YM2203/YM2608/YM2610. opn_ymfm.cpp
wraps Aaron Giles' ymfm (BSD-3, vendored at external/ymfm) in libvgm's C
DEV_DEF interface so the App Store build has one. Upstream libvgm has its own
ymfmintf.cpp, but only for the YM2414 (OPZ); it is a good template and nothing
more.
Four things the OPN family needs that a naive wrapper gets wrong:
- The SSG must stay a separate device. libvgm models the YM2203/YM2608 SSG
as a linked
DEVID_AY8910with its own mixer channel and volume, andfmopn.cforwards registers0x00-0x0Fto it. ymfm has an internal SSG, which would fold it into the FM output at ymfm's own balance. The adapter installs anymfm::ssg_overridethat forwards to the linked device, and replicatesopnintf.c'sinit_ssg_devinfo(clock divider 1 for YM2203, 2 for YM2608/YM2610) — without that declaration libvgm never creates the SSG. - YM2608 and YM2610 output must be doubled. Both halve their output sum on
real hardware and ymfm does it, but libvgm's
fmopn.cleaves that step commented out (//lt >>= 1; // shift right verified on real YM2608), so libvgm runs 2x hot and_CHIP_VOLUMEis calibrated to that. Measured exactly 0.50 before the correction. YM2203 needs none. - ymfm's sample rate must be folded down 3:1 for YM2203/YM2608. ymfm's rate
is prescaler-independent (
clock/24,clock/48) — three times whatfmopnreports. A 166 kHz device rate is outside anything libvgm's own cores produce (they sit at or below ~60 kHz) and destabilises its resampler: the fixed-point step maths inResmpl_Exec_LinearDown, which carries its own comment about overflow "with extremely high chip sample rates", intermittently produced an absurd sample count, andResmpl_EnsureBuffers()callsabort()when the resulting malloc fails. The symptom was a heisen-abort that vanished under lldb, Guard Malloc and ASan and became an apparent hang whenever the huge allocation happened to succeed. YM2610 was stable from the start because ymfm'sclock/144already equals fmopn's rate. - YM2612 comes with it. The GPGX core lives inside
fmopn.c, so dropping that file moves the chip to Nuked OPN2.
The YM2608 rhythm section reads the chip-internal ADPCM-A ROM dump that ships
with libvgm (fmopn_2608rom.h); without it every PC-98 track loses its drums.
-UOPN_YMFM_RHYTHM_ROM omits it.
That file no longer contains Yamaha's ROM. It used to: 8192 bytes, CRC32
23c9e0d8, copyrighted firmware under no licence. It is now generated bygen_2608rom_synth.c— see the header comment there for the full rationale.
gen_2608rom_synth.c synthesizes the six voices and encodes them to ADPCM-A in
the exact byte layout YM2608_ADPCM_ROM_addr expects, so no address table
changes. It is the companion to s98plugin's fmgen/gen_rhythm_synth.c — same
voice designs, but at the chip's own 18512 Hz with the ROM's sample counts, and
encoding rather than emitting PCM.
It emits both consumers of this ROM, which declare it differently:
cc -O2 gen_2608rom_synth.c -lm -o /tmp/g
/tmp/g ../../../../zxtune/3rdparty/vgm/emu/cores/fmopn_2608rom.h
/tmp/g ../dmfplugin/furnace/src/engine/platform/sound/rss.h nonstatic
Furnace uses the identical address layout (set_start_end() in its
ymfm_opn.cpp), so one generator serves both. There is deliberately no
.orig kept beside either file, unlike the opnintf.c patch — keeping one
would mean keeping the ROM.
Three things worth knowing about the encoder:
- It is closed-loop and exact. It runs libvgm's own decoder
(
ADPCMA_calc_chan) and picks, per sample, the nibble landing nearest the target. Fed a representable signal it reproduces it exactly. - Voices must not start abruptly. The decoder begins with accumulator and step index at 0, where the largest jump is 30 units. A voice opening at peak is unencodable, so every voice gets a 24-sample (~1.3 ms) attack ramp.
- Never let the accumulator leave 12-bit range. It wraps rather than saturating, and in a differential codec that displaces every later sample permanently. The real ROM's cymbal does exactly this — two nibbles early in its stream leave it with a +477 DC offset (23% of full scale) for all 643 ms, which is why its raw RMS reads 490 against a true signal of 112. The encoder refuses any nibble that would wrap, so the generated voices carry no offset.
Residual encode error is under 2% on the tonal voices (BD, TOM) and about 15% on the noise-based ones (SD, TOP, HH). That 15% is ADPCM-A's 4-bit quantization floor on noise, not a synthesis or tuning problem — retuning barely moves it. The error is noise added to noise, so it reads as slight extra grain.
Note the oscillator and filter frequencies are lower than the 55466 Hz
gen_rhythm_synth.c uses: Nyquist here is 9256 Hz, and content near it
alternates sign almost every sample, which is precisely what a differential
codec cannot track.
opnintf.c / opnintf.h carry a local patch adding the EC_*_MAME /
EC_*_YMFM selection — grep chipmachine local patch, .orig copies sit next
to them, re-apply on revendor. ymfm itself is unmodified.
Same shape, for the three chips whose only libvgm core is GPL: the YM3526
and Y8950 (fmopl.c, MAME) and the YMF278B OPL4 wavetable
(ymf278b.c, an openMSX port). Easier than the OPN in two ways — ymfm's OPL
sample rate is clock/72, exactly what oplintf.c reports, and its ymf278b
returns clock/768, exactly what ymf278b.c reports, so nothing needs the
rate folding the OPN required and nothing goes near the resampler's ceiling.
The OPL4 is the one that needed thought. libvgm does not model it as one
chip: ymf278b.c is the wavetable half only, and the FM half is a separate
linked DEVID_YMF262 that VGMPlayer hard-requests FCC_ADLE for. ymfm's
ymf278b contains its own FM and offers no fm_override hook, so either
- (a) let ymfm do the FM and drop the link — one device instead of two, which
moves the FM onto a different
_CHIP_VOLUMEentry and swaps AdLibEmu for ymfm's OPL3 at the same time: two changes at once, for no gain; or - (b) keep libvgm's structure, forward ports 0–3 to the linked YMF262 and emit ymfm's PCM alone.
(b) is what shipped, so both variants share the same FM core and the same two volume-table entries and only the wavetable engine changes. Three traps in it:
- ymfm's PCM writes are gated on the FM register file.
write_data_pcm()returns early unless NEW2 is set, and NEW2 arrives through an FM port. So ports 0–3 must reach ymfm too, not just the linked device — feed it only ports 4/5 and the chip is silent. - Which means ymfm's FM output has to be discarded, or the FM renders
twice.
ymf278b::generate()mixes FM and PCM into lanes 4/5 before the caller sees them, soopl4_pcmsubclasses it and reimplements the loop: both engines still clock identically (the FM drives timers and the BUSY/LD status bits, and skipping it desynchronises the PCM envelopes), only the FM output is dropped. - The FM mix level's power-on value differs.
ymf278b.c's reset setsfm_l = fm_r = 3; ymfm's register file defaults to 0, which is +6 dB. That level is passed to the linked YMF262 as a device volume, so reading it back out of ymfm ran the FM 2.7x hot on every log that never writes register 0xF8 — most MSX MoonSound music. The adapter tracks it itself.
The PCM level also needs a x3/4 calibration, measured rather than derived: the two engines carry different internal headroom (libvgm applies a documented -15 dB trim per slot, ymfm scales inside its PCM channel) and they do not cancel. Unscaled, ymfm rendered 1.315x libvgm.
oplintf.c / oplintf.h carry the same kind of local patch as opnintf,
introducing EC_YM3526_* and EC_Y8950_* — upstream has no EC_ macros for
those two chips at all and reaches into fmopl directly under SNDDEV_*.
.orig copies sit next to them. ymf278b.c needed no patch: it is a
single-core file, so opl_ymfm.cpp simply supplies the sndDev_YMF278B
DEV_DECL in the build where that file is absent.
The ymfm_interface that serves sample memory out of libvgm's
DEVRW_MEMSIZE / DEVRW_BLOCK calls is shared by both adapters and lives in
ymfm_rom_intf.h.
vgm_core_probe.cpp is that tool, kept in the tree. It is off by default;
build it per variant and diff the two:
cmake -B build-probe-plus -S chipmachine -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCM_VARIANT=plus -DCM_BUILD_VGM_PROBE=ON
ninja -C build-probe-plus vgm_core_probe
build-probe-plus/plugins/libvgmplugin/vgm_core_probe -t 30 [-w wavdir] file.vgz...
It links the OBJECT library directly, before the ld -r visibility pass, so
it sees exactly that variant's source list and SNDDEV_/EC_ selection. For
each file it prints the core libvgm actually chose (GetSongDeviceInfo →
PLR_DEV_INFO.core, a FourCC) including linked devices, plus RMS, peak and
clipped-sample count; -w dumps WAVs so a swap can be judged by ear too.
Notes:
PlayerA::Render()fills at most one internal buffer per call and returns the byte count — pull in chunks and honour the return, or 95% of what you "rendered" is untouched silence and every measurement is wrong.- Call
Start()beforeGetSongDeviceInfo(), or every core reads 0 and every rate reads 0 — the devices do not exist yet. testmus/libvgm/pc98-opn.vgzhas only 4 SSG writes, so it does not exercise the SSG path; use a VGMRips PC-98 OPNA rip for that.- Pick A/B material by the registers the cores differ on, but always listen to ordinary game music too — register-extreme compo tracks are unrepresentative.
- Ratios on near-silent tracks are noise. Filter by absolute RMS (≥100 or so) before summarising, or a jingle going from RMS 20 to RMS 57 reports as a 2.8x regression.
- Check the file actually contains the sample data the chip needs — see the MoonSound warning above. Aggregate over a corpus, not one track.
OPL fixtures in testmus/libvgm/: arcade-ym3526.vgz (Karnov),
arcade-ym3526-rhythm.vgz (Athena — the rhythm-mode divergence),
msx-y8950.vgz (Gorby no Pipeline Daisakusen),
arcade-ymf278b-opl4.vgz (Gunbird 2, with its ROM block) and
megacd-rf5c164.vgz (Willy Beamish — deliberately a track with no YM2612,
so it isolates the RF5C164 instead of also measuring the OPN2 swap).
virtualboy-vsu.vgz covers the VSU, 32x-pwm.vgz the 32X PWM and
wonderswan-golden-axe.vgz the WonderSwan.
libvgm's chip cores share global C symbol names with GME / s98 / famitracker /
kss. As with dmf/goattracker/ned/mikmod, the slice is compiled
-fvisibility=hidden and partial-linked with ld -r to demote those externs to
locals; only LibVGMPlugin.cpp stays at default visibility (the exported plugin
surface). ymfm is C++17 and compiled with the same flags; opn_ymfm.cpp and
opl_ymfm.cpp deliberately do not include ymfm_fm.ipp (ymfm_opn.cpp /
ymfm_opl.cpp already instantiate those templates, and a second copy cannot be
merged once ld -r has made the weak symbols private externs). Calling a
template member from an adapter is fine — without the .ipp the compiler emits
an ordinary out-of-line call, which resolves against that instantiation.