Fixing messed up VGM tracks from Shining Force

This is Elven Town, from Shining Force II for the Sega Genesis. Here's the beginning of the original track:

Excerpt from Elven Town

It sounds kind of janky.
Computer, isolate the bass line and slow it down to 65%.

Isolated bass notes, slowed down to 65% as requested

Yea, definitely some problem with that high note. The Sega Genesis sound chip (YM2612) has 6 FM channels; these bass notes are played on Channels 0, 1, and 5. As we will see, this bass line has a software-controlled vibrato, meaning that the channel frequency is modulated rapidly by the game's music engine, not using a built-in LFO of the YM2612 chip. And the music engine evidently has a small bug 🐞.

This music comes from a VGM file. The VGM format captures a stream of commands sent from the game to synthesizer chips. I ran vgm2txt from vgmtools library to convert the VGM stream to something readable and filtered the output for Channel 1 (good) and Channel 5 (corrupt). So the difference appears in these commands highlighted below:

Output of vgm2txt comparing Channel 1 vs. Channel 5

The VGM text annotation tells us that these are frequency commands. Channel 1 has a slowly evolving values, and channel 5 has some noisy values in what would otherwise be smooth sequence. The "set LSB" byte values are plotted like this, to verify:

Vibrato vs. janky vibrato

Now it's time to figure out how to repair those values. In order to repair them, we have to understand the VGM command stream for the YM2612 a little better.

From the right-hand side above (channel 5), all the commands we are interested in have this format:

53 A6 __
53 A2 __
VGM commands

From various sources, we learn the following:

https://vgmrips.net/wiki/VGM_Specification

0x52 aa dd      YM2612 port 0, write value dd to register aa
0x53 aa dd      YM2612 port 1, write value dd to register aa

Cool. So what are registers $A6 and $A2? How do we interpret the value bytes?

https://blog.thea.codes/genesynth-part-2-basic-communication/

Port 0 = Key on/off and Channels 0,1,2
Port 1 = Channels 3,4,5

YM2612 assignments for registers $A0 onward:

Channel  High byte   Low byte
0 / 3    $A4         $A0
1 / 4    $A5         $A1
2 / 5    $A6         $A2
YM2612 registers.
Part I = port 0, Part II = port 1

https://plutiedev.com/ym2612-registers#reg-A0

The register format is:

YM2612 registers $A4, $A5, $A6: frequency (high)
Bit 7   Bit 6   Bit 5   Bit 4   Bit 3   Bit 2   Bit 1   Bit 0
0       0       BLK:2   BLK:1   BLK:0   FREQ:10 FREQ:9  FREQ:8

YM2612 registers $A0, $A1, $A2: frequency (low)
Bit 7   Bit 6   Bit 5   Bit 4   Bit 3   Bit 2   Bit 1   Bit 0
FREQ:7  FREQ:6  FREQ:5  FREQ:4  FREQ:3  FREQ:2  FREQ:1  FREQ:0

FREQ: frequency
BLK: block (octave)

…i.e. 2 unused bits, 3 octave bits, 11 frequency bits.

Now we are prepared to write some code that will interpret these pairs of "high" and "low" frequency commands, combine them together, and get the resulting frequency for channel 5.

const fs = require('fs');

const buf = fs.readFileSync('elven-town.vgm');
let i, j;  
for (i = 256; i < buf.length; i++) {
  // High byte
  const [b0, b1, hi] = buf.subarray(i);
  if (b0 === 0x53 && b1 === 0xA6) {
    i += 2;
    // High and low commands can be separated by some silence bytes (0x7n)
    for (j = i; j < i + 6; j++) {
      // Low byte
      const [b3, b4, lo] = buf.subarray(j);
      if (b3 === 0x53 && b4 === 0xA2) {
        // Combine the bytes with bitwise operations
        const baseFreq = ((hi & 7) << 8) | lo;
        const oct = (hi >> 3) - 5;
        const freq = baseFreq * Math.pow(2, oct);
        console.log(freq);
      }
    }
  }
}
High and low bytes combined to reveal frequency

Now we can see that the spurious frequencies are clearly separated from the normal vibrato stream; and we can make a simple condition to filter them out. We just reuse the previous value when we encounter one of these. Putting it all together:

const fs = require('fs');

const buf = fs.readFileSync('elven-town.vgm');
let lastHi = 0;
let lastLo = 0;
let lastFreq = 0;
let ctr = 0;

let i, j;
for (i = 256; i < buf.length; i++) {
  // High byte
  const [b0, b1, hi] = buf.subarray(i);
  if (b0 === 0x53 && b1 === 0xA6) {
    i += 2;
    // High and low commands can be separated by some silence bytes (0x7n)
    for (j = i; j < i + 6; j++) {
      // Low byte
      const [b3, b4, lo] = buf.subarray(j);
      if (b3 === 0x53 && b4 === 0xA2) {
        const baseFreq = ((hi & 7) << 8) | lo;
        const oct = (hi >> 3) - 5;
        const freq = baseFreq * Math.pow(2, oct);
        // Filter out the bad frequencies
        if (freq > 400 || (freq > 300 && (ctr < 2200 || ctr > 2800))) {
          buf[i+2] = lastHi;
          buf[j+2] = lastLo;
        } else {
          lastHi = hi;
          lastLo = lo;
          lastFreq = freq;
        }
        console.log(lastFreq);
        ctr++;
      }
    }
  }
}

fs.writeFileSync('elven-town-fixed.vgm', buf);

The result looks like this:

Spurious commands removed

Computer, now resynthesize the bass line with the repaired vibrato command stream!!

We should be a little more curious about how these unintended frequency commands got there. There is some clear structure in the interference.

Let's take another look at the VGM command stream...

Unintentional F-Num (frequency) commands sent to Ch 5

Here is what's happening. Every time there is a Key On command for Channel 3 or Channel 4, an unintentional frequency command is sent to Channel 5 immediately before. The bytes (highlighted in yellow and green) match the frequency intended for the Key On event. The commands outlined in red are mistakes. Now we can rewrite the script to surgically remove these errors:

let buf = fs.readFileSync(filename);
let patchCount = 0;
const lookback = 30;
for (let i = 0; i < buf.length - 2; i++) {
  const [b0, b1, b2] = buf.subarray(i);
  if (b0 === 0x52 && b1 === 0x28 && (b2 === 0xF4 || b2 === 0xF5)) {
    for (let j = 3; j < lookback; j++) {
      const idx = i - j;
      const [n0, n1, n2] = buf.subarray(idx);
      if (n0 === 0x53 && (n1 === 0xA6 || n1 === 0xA2)) {
        patchCount++;
        buf[i-j  ] = 0x7F;
        buf[i-j+1] = 0x7F;
        buf[i-j+2] = 0x7F;
        if (n1 === 0xA6) break;
      }
    }
  }
}

I ran this script on the entire soundtrack since the error appears throughout. Listen to repaired Shining Force II on Chip Player or download the repaired VGM files here (originals included for comparison):

Bonus: this process repaired a song that was previously unplayable in Chip Player (29 - Zeon.vgz).

Leave a Reply