Skip to main content
Novelty Ice Cubes

Restoring a 22-Year-Old Game’s Audio Hack With V86

The Internet Archive is a great place to find gems like Squealer TNT, a DOS clone of “Psycho Pig UXB” (itself a console port of the Japanese arcade game Butasan). It’s a top-down game where pigs throw bombs at each other. They squeal when you hit them with a bomb, there’s banjo music, and your pig dances when you win. It’s a delight!

…or at least it would be if it had sound. The Internet Archive’s online player has no audio, making the game a joyless exercise in silent frustration. If I throw a bomb at a cartoon pig, and it doesn’t make a funny sound, what is even the point?

Luckily I have a holiday tradition of going on esoteric quests involving PC emulation, from making a BASIC themed imitation of Compiler Explorer to stuffing 2,000 demoscene demos from Pouet into a carousel. This year, I’m doing a public service: I’m going to make those pigs squeal again.

The Silence Of The Pigs

Most DOS games use the PC speaker or Sound Blaster and work fine in online DOSBox wrappers like js-dos. This game goes in a weird direction, and fixing the audio is going to need a full PC-in-the-browser emulator like Fabian Hemmer’s V86.

To start up a DOS PC in V86, you just need a few files:

Stub some HTML for the viewport

<div id="screen_container">
    <div style="white-space: pre; font: 14px monospace; line-height: 14px"></div>
    <canvas style="display: none"></canvas>
</div>

And start the emulator like so:

var emulator = window.emulator = new V86({
        screen_container: document.getElementById("screen_container"),
        bios: { url: "seabios.bin" },
        vga_bios: { url: "vgabios.bin" },
        fda: { url: "bootdisk.img" },
        hda: { url: “hda.img” },
        autostart: true,
    });

Sure enough, this boots up a DOS prompt. I type SQUEALER to run the game, but it doesn’t even start. It hangs with a strange error:

Error saying "Illegal command: start"

A Porcine Anachronism

Squealer TNT is already strange in that it’s a DOS game written in 2002. It’s further strange in that it uses DS4QB2 as its sound driver. The game runs in real-mode DOS due to it being written with Microsoft QuickBASIC. In order to bust out of the limitations of real mode it cheats by running a command, START DS4QB2.EXE. This is a Win9X Visual Basic program that runs in the background and plays sounds using the BASS audio library.

Diagram showing

The DS4QB2 sound engine takes a "hold my beer" approach to inter-process communication. 16-bit DOS programs in Windows 95 are supposed to be isolated in their little VM86 shell, but thanks to the insanely complex 16-bit driver fallback mechanism of Windows 95, both 16-bit and 32-bit programs are granted shared access to the legacy DMA controller for floppy disk transfers and ISA cards. DOS programs signal DS4QB2 by writing to the DMA channel 0 address line (aka port 0). And it passes parameters by writing them to a file.

Diagram showing You may want to avoid playing this game from a floppy drive

So if Squealer TNT wants to play a sound, this is the sequence:

  1. The game writes parameters to a file, DS4QB2.DAT
  2. The game writes a value to port 0 and waits for it to be cleared
  3. DS4QB2 continuously polls port 0 and detects the audio request
  4. DS4QB2 reads the parameters from DS4QB2.DAT and plays a sound
  5. DS4QB2 clears port 0 and the game resumes

It was a cursed solution. Windows NT didn’t use DOS as a bootstrap and it’s NTVDM DOS layer restricts access to all but a few resources. Within a few years, most computers ran Windows XP or Vista and no game that used DS4QB2 could enable audio: the games would place a value on an isolated port 0 and wait forever for nothing to respond.

A cursed monkey's paw granting a wish
”I wish I could use DirectSound for my QuickBASIC game”

Signals From Beyond The Veil

In order to bring this game’s audio back to life, it needs an audio server to play sounds. So instead of a Windows application, we'll use a Javascript routine outside the emulator

Diagram showing SQUALER running in an emulator and talking to a javascript process

The first order of business is to detect that signal on port 0. Thankfully, V86 has some undocumented APIs to access the emulated PC's I/O ports and being Javascript, nothing can stop me from using them. DS4QB2 polls the I/O port every 50ms, so I tried doing the same:

emulator.add_listener("emulator-ready", async function() {
    setInterval(() => {
        const portValue =  emulator.v86.cpu.io.port_read8(0);
        console.log(portValue);
    }, 50);
});

I start the emulator, run SQUEALER:

Yes! DS4QB2 uses different values for different commands. A value of 3 means “load sounds”. The game is trying to load sounds!

Basically this feeling, but with cartoon pig noises

An Unexpected Medium

Detecting the I/O port is great, but the parameters for the audio command are stuffed in a file on the emulated disk drive. V86 has a file API, but the guest needs to support 9P network filesystem protocol. MS-DOS 6.22 does not have a driver for the 9P network filesystem protocol.

No, this calls for a much stupider solution. V86 allows a user to supply an ArrayBuffer instead of a URL for the hard disk.

const response = await fetch(‘hda.img’);
const hdaBuffer = await response.arrayBuffer();
const emulator = window.emulator = new V86({
        // …
        hda: { buffer: hdaBuffer },
        // …
    });

What happens inside that array buffer when the guest uses the disk? Does V86 keep its contents cached into some rarely synchronized internal data structure like DOSBox does? Or does V86 actually perform block-level reads and writes directly to that buffer? To check, I used a Javascript trick to convert the buffer to a URL and then download it:

const blob = new Blob([image], {
    type: 'application/octet-stream'
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'hda.img';
document.body.appendChild(a)
a.style.display = 'none'
a.click()
a.remove();
setTimeout(() => window.URL.revokeObjectURL(url), 1000);    

This downloads “hda.img” after detecting the signal. I mounted the image and there was DS4QB2.DAT, containing a text-formatted list of sound files to load. It wrote the file to the buffer!

Screenshot of finder showing DS4QB2.DAT inside the HD image

Hacks All The Way Down

So, the HDA array buffer likely contains the contents of the hard disk, as long as DOS isn't buffering the file. But I need a way to read files off the disk while the game is running.

Sometimes, though, stars just align: a few years ago, I wrote a WASM wrapper library for ChaN’s elegant FatFS library for reading from FAT formatted disks like DOS uses. I intended it for BasBolt, but ended up not using it and it was left to rot. Until now!

const disk = await FatFsDisk.create(hdaImage);
// ...
setInterval(() => {
    const portValue =  emulator.v86.cpu.io.port_read8(0);
    if (portValue !== 0) {
        const params = disk.session(() => disk.readFile(DS4QB2.DAT);
        console.log(new TextDecoder().decode(params));
    }
});

And sure enough: Screenshot showing the contents of DS4QB2.DAT in the console For a fleeting moment, I was a god

Then Draw The Rest Of The Owl

After getting the signal and the parameters, the rest of the effort was grunt-work to decode the audio protocol, then reproduce the effect using Javascript. When a command to play a sound was sent, I used Howler.js:

const soundContent = disk.session(() => disk.read(soundFile));
const blob = new Blob([soundContent], { type: 'audio/wav });
const soundHandle = new Howl({
    src: [URL.createObjectURL(blob)],
    format: ['wav']
}));
soundHandle.play();

And to play tracker music, I used chiptune3.js, a wrapper around the excellent OpenMPT library that plays everything from XM files to MO3. There is a library for literally everything:

const musicContent = disk.session(() => disk.read(musicFile));
const musicHandle new ChiptuneJsPlayer();
musicHandle.onInitialized(() => {
    musicHandle.play(musicContent.buffer);
});

After processing the command and writing 0 back to the port, the game unblocks. At last, glorious squealing mayhem!

You can play the game with audio here

The End Product

I published the V86 + DS4QB emulator on GitHub here:

I don't expect anyone to actually use this as a library, but structuring it this way was a nice way to separate the business logic of the emulator from the Vite configuration and game binaries of the client application.

This post is long, so I plan to make two follow-ups for some tangents:

  1. Supporting DS4QB1, which used the clipboard to communicate
  2. Some curious PIT shenanigans that caused some DS4QB games to hang

Learnings

I used V86 on this project, and it remains my favorite PC emulator for its accessibility and hackability. There a few notes

During this project I spent time scouring old QBasic community sites for games that used DS4QB2, 20 years after I'd last been there:

This project was also my first time using Vite and Rollup instead of Webpack: