Skip to content

Repository files navigation

OpenMiles

Open-source drop-in replacement for the Miles Sound System (MSS) DLL

CI License: GPL-3.0 Built with Zig


OpenMiles is a clean-room reimplementation of the Miles Sound System (MSS) in Zig, designed as a drop-in mss32.dll replacement for legacy Windows games running on modern systems and under Wine.

One -Dmss-version flag selects which historical MSS release the export table mimics. Every selectable version (v3 through v9) reproduces its reference mss32.dll's decorated stdcall export table with zero missing exports — verified by scripts/check_all_versions.sh, which parses each PE export table and diffs the decorated names.

It replaces the proprietary MSS audio stack with miniaudio for audio output, TinySoundFont for MIDI synthesis, and native decoders for MP3, OGG, and WAV (replacing MSS's proprietary ASI plugins), plus FLAC as a bonus format not in the original MSS.

Features

  • Drop-in binary compatible -- exports the same stdcall ABI as mss32.dll
  • Digital audio -- sample playback, streaming, volume/pan/pitch/loop control
  • MIDI/XMIDI -- real-time synthesis via SF2 soundfonts, tempo control, beat callbacks, XMIDI loop/branch support
  • 3D positional audio -- full spatial audio with distance attenuation, Doppler, cones, obstruction/occlusion
  • ASI codec system -- built-in MP3/OGG/WAV/FLAC decoding; also loads external .asi plugins as fallback
  • RIB provider system -- full provider enumeration and interface registration
  • Filter API -- real-time low-pass filtering via miniaudio DSP nodes
  • Reverb -- per-sample delay-based reverb
  • Timer API -- background timer threads with configurable frequency
  • Quick API -- high-level one-call playback
  • Event system (v8/v9) -- byte-faithful event-text codec (AIL_create_event / AIL_next_event_step, all step types) plus the v9 Miles* API: variables, event enqueue, and a sound-instance lifecycle (durations, label filtering, per-label caps, state reporting)
  • SoundBank (v8/v9) -- loads the BANK-format soundbank into a global container, enumerates event/sound/preset/environment assets, resolves event bytecode + sound info/duration by name
  • Perceptual volume curve -- cubic attenuation matching the original MSS ~60dB dynamic range
  • Full export-ABI parity -- v3–v9 export tables diff to zero against the reference DLLs; every exported function is fuzzed and unit-tested

Building

Requires Zig 0.16.0. The version is declared once, as .minimum_zig_version in build.zig.zon; the Makefile, CI, and the release workflow all read it from there. make build and make test run a version check first and refuse to build on any other Zig; the raw zig build commands below do not.

# Native build (Linux/Windows -- for tests)
zig build
zig build test

# Cross-compile for Windows (game deployment)
zig build -Dtarget=x86-windows -Doptimize=ReleaseFast

# Target a specific MSS version's API surface (ABI-shape the export table)
zig build -Dtarget=x86-windows -Doptimize=ReleaseFast -Dmss-version=5

make wraps the same commands: make help lists the targets, make test, make sanitize, make lint, and make parity runs the per-version export-table sweep (scripts/check_all_versions.sh, which needs the reference DLLs under references/, not checked in). make sanitize is zig build test -Dsanitize: the same suite with the C undefined-behaviour sanitizer on, which is the only gate that sees UB in the bindings and in the vendored headers translate-C pulls in.

make check runs every check CI runs, in CI's order: make lint, make build, make test, make sanitize, make harnesses, and the Windows cross-compile. Run it before pushing.

make lint needs shellcheck, a Python 3 interpreter (the gates run under python3, or python where that is the name on PATH), ruff 0.16.4 and yamllint 1.38.0 (the pinned versions, checked before they run) besides Zig, since the scripts/ gate and the workflows are linted too; make lint names any of the four that is missing. CI brings in the same four at the same pins: Zig through setup-zig at the version in build.zig.zon, ruff and yamllint through uv tool install at the versions in the Makefile, and shellcheck from the runner image. It checks zig fmt, shellchecks scripts/*.sh, lints .github, and asserts that every declaration in src/mss.h agrees with the export table for each -Dmss-version (return type, argument count, calling convention, version range, struct layout). src/mss.h is a documented core subset, so symbols it does not declare are reported as a coverage count, not a failure; the per-symbol status for the rest is in docs/API_STATUS.md.

Every gate under scripts/ follows one contract: -h/--help prints its own usage, the findings and the summary go to stdout as the result of the check, a missing tool or an unreadable file goes to stderr as the error that stopped it, and it exits 0 for a pass, 1 for a check failure, 2 for a bad invocation. make help lists each one and what it asserts.

Running a subset of the tests

The full suite takes a couple of minutes. To run one test, filter by a substring of its name:

make test FILTER=redbook           # or: zig build test -Dtest-filter=redbook

A filter that matches no test name is a typo, so both routes refuse it and name the problem: zig build rejects a -Dtest-filter that matches nothing before any compile work, because zig otherwise exits 0 on a zero-test run.

A test run is quiet: the test build does not enable the debug log by default (a Debug library build does), so a run neither floods the terminal nor appends to openmiles.log. Set OPENMILES_DEBUG=1 for a run that wants the engine trace and the log file.

Contributing setup, the edit-test loop, and what a change is expected to carry are in CONTRIBUTING.md.

Which thread touches what

Three kinds of thread reach the library at once: the game's own thread(s), the audio thread miniaudio runs the device callback on, and one thread per running Timer. The rules the code holds to:

  • A started Timer runs its callback on its own thread. stop() joins it, deinit() joins and unregisters it. A callback that calls AIL_stop_timer or AIL_set_timer on its own timer is answered without a join (joining the current thread is fatal); the handle is reaped by the next external stop.
  • The audio thread reads the driver's SoundFont pointer through currentSoundfont(), an atomic load. Loading, unloading, or replacing a bank publishes a new pointer and closes the one it displaced only after in-flight renders have released their claim, so a render never runs against freed memory. A swap waits up to MidiDriver.swap_wait_budget_ms (500 ms) for those claims and then returns with the bank left pending for the next swap, because the render holding it may be inside a callback that is waiting on the thread doing the swap.
  • A sequence's state is guarded by its own mutex. The audio callback takes it with tryLock and renders silence when a control call holds it, so playback never blocks on the game's thread. Fields a callback can read from inside itself (user data, beat and measure, channel mapping) are atomic for that reason.
  • Module-level state the API needs on every call, the current driver handles, the timer registry, the driver table, the locked-channel table, and the debug log, each has one mutex or atomic and is read through it.

Driving time in tests

Every deadline, period, and elapsed counter in the library reads one clock, openmiles.clock. Production reads the platform clock. A test can install a virtual one instead:

openmiles.useVirtualClock(0);        // elapsed counters re-base on ns 0
openmiles.clock.advance(ns);          // move simulated time forward
openmiles.sleep(.fromMilliseconds(250)); // advances it instead of blocking

AIL_sleep, AIL_delay, AIL_ms_count, and AIL_us_count all follow the installed clock, so a test that sleeps 250 ms spends no wall time and reads back exactly 250. A Timer started under a virtual clock spawns no thread; call timer.tick() once per period you want to elapse, and the callback sees the exact simulated timestamp. A timer set started through AIL_start_all_timers steps the same way with openmiles.tickAllTimers(), which fires every registered timer once and advances the clock by the sum of their periods. Replaying the same step sequence replays the same run, which is what makes a failing sequence reproducible.

A run that also invents names (today, the one temporary file an ASI provider image is written under) needs a seed as well as a clock. openmiles.startSimulation(seed) installs both, and logs the seed so a failing run can be replayed from it; endSimulation() returns to the platform clock and to secure entropy. Unseeded, those names come from OS entropy and are not predictable, which is what production wants.

Injecting disk faults

Every file the library opens, writes, or removes goes through openmiles.fs_compat, which carries an optional fault schedule. A schedule names the paths it applies to and the fault to produce, so the faults a real disk will not produce on demand can be replayed:

const schedule: openmiles.fs_compat.Fault = .{
    .open = failOpen,          // an open or create that returns an error
    .truncate_read = shortRead, // a whole-file read that stops short
    .truncate_write = shortWrite, // a whole-file write that stores a prefix
    .remove = failRemove,      // a delete that returns an error
};
openmiles.fs_compat.fault = &schedule; // null in production

open covers AIL_file_read, Sample.loadFromFile, soundfont and soundbank loads, the plugin directory scans, and both routes the ASI temp image is created by. truncate_read covers every whole-file read: readWholeFile refuses the short read, and AIL_file_read zero-fills the tail the read did not reach. truncate_write covers the ASI temp image, which is the only file the library writes; a short write there is discarded rather than loaded as a module the caller never handed over. remove covers the removal of that image, so the locked-image case, where the file stays on disk for the life of the process, is a scheduled step rather than something a run can only hit by luck. Without a schedule installed, every one of these is a plain whole-file read, write, or delete.

Replaying plugin discovery

Both plugin scans (openmiles.loadApplicationProviders and DigitalDriver.loadAllAsi) collect the directory's plugin names and sort them before loading, so the provider list RIB_enumerate_providers walks is a function of the directory's contents. A directory read returns entries in filesystem order, which differs per machine and per run; loading in that order made the enumeration unreplayable and could hand a query to a different provider on each install.

Release archive

scripts/package_release.sh <out.zip> [sha256sums] packages zig-out/bin/mss32.dll with the header a consumer compiles against (mss.h), the license, this README, the changelog, the security policy, the contributor guide, the vendored headers with their attribution and digests (deps/), the docs/ this README links, so every relative link in it resolves in an unpacked archive, and the third-party inventory (SBOM.cdx.json). It needs zip on PATH and says so if it is missing, and takes the digests for the optional checksum file from sha256sum or, where that is not the name (GNU vs BSD), from shasum -a 256. --help prints the same usage. The optional second argument writes a SHA256SUMS naming exactly the archive entries, so sha256sum -c passes on an unpacked download; the script creates the directory it lands in, while the directory the archive itself goes into has to exist. The archive is reproducible: entry order is fixed, every entry takes one timestamp (SOURCE_DATE_EPOCH, defaulting to the HEAD commit time) pinned to UTC, and no host metadata is stored. Packaging it twice yields byte-identical files, on any host timezone and locale, which the release workflow checks with cmp after repackaging under a different TZ and LC_ALL.

The archive is checkable rather than merely described. deps/SHA256SUMS and SBOM.cdx.json name the vendored headers, so the headers ship beside those records: cd deps && sha256sum -c SHA256SUMS on an unpacked archive passes, where an archive holding the manifests alone failed on every entry. The packager stages what its entry list names and does not look at what those files point at, so make check-release-archive reads that list back out of package_release.sh and fails when the archive would drop a file deps/SHA256SUMS records or a file the shipped docs link. It runs in make lint, so CI rejects a vendored header or a written doc that the next release would not carry.

Publishing is a tag push: set .version in build.zig.zon, push v<version>, and the workflow builds, smoke-tests the DLL, verifies the archive is reproducible, and creates the GitHub release. The workflow refuses a tag whose version does not match build.zig.zon. A failed run is retried with a manual dispatch of the same tag from the Actions tab. Both triggers refuse a tag whose release already exists: a published version is not republished, because a consumer may have pinned the archive and its recorded checksum. A fix to a published release ships as a new version.

Targeting an MSS version

-Dmss-version=<3|4|5|6|6.0|6.1|6.5|6.6|7|8|9> (default 9) selects which Miles release the export table mimics. 6 and 6.6 are the same build (both encode 66); the other values are distinct. Each value reproduces that version's reference mss32.dll export table with zero missing decorated exports — including the per-version ABI quirks (functions whose stdcall arity changed across releases, e.g. init_sample @4→@12→@8, the v4/v5 5-arg 3D-distance variants, the v7-only DSP-stage API, and the v8 vs v9 Miles* event-API arities).

Version Adds Missing vs ref
3 Core, Digital, Sample, Streaming, MIDI, Redbook, Timer 0
4 RIB/ASI plugin system + ASI compression, Quick, Input, Memory 0
5 3D audio, filters 0
6.1 Filter API maturity 0
6.5 Low-pass cutoff controls, per-stream level and exclusion calls 0
7 Unified 2D/3D sample API, master/speaker reverb, DSP stages 0
8 Event system, soundbanks, channel levels, in-memory I/O 0
9 Miles* event/variable API, environment presets, 64-bit counters 0

6/6.6 (66) and 6.0 (60) have no reference DLL in the sweep: 66 carries the 6.5/6.6 sub-line surface, which neither the 6.1d nor the 6.5h reference covers, and no 6.0 or 6.6 binary is committed. src/mss.h is still checked against the export table for both, by scripts/check_header.py.

scripts/check_all_versions.sh reproduces the zero-missing diff per major version against the reference DLLs listed in that script (3.6a, 4.0h, 5.0b, 6.1d, 6.5h, 7.0k, 8.0e, 9.1d). Those references are proprietary Miles binaries and are not committed; drop them under references/ to run the sweep. Its verdict, not this table, is the parity claim: 0 missing and 0 decoration mismatch for every version it covers.

Each build is a superset of its reference: it exports every name the real DLL does (0 missing) plus a small set of harmless cross-era extras. The export count exceeds the reference count for that reason; the faithfulness metric is the zero-missing diff.

The v7 unified audio API runs on the engine (3D on the normal HSAMPLE, master/sample reverb, low-pass). The v8/v9 event system is byte-faithful (text constructor + decoder for every step type), the soundbank loader reads real BANK files into a global container, and the event execution VM parses enqueued events into tracked sound instances with a full PENDING→PLAYING→COMPLETE lifecycle (durations resolved from the bank, label-query filtering, per-label concurrent caps, event-length, cache/persist accounting). The remaining gap is routing those instances through the miniaudio mixer for actual audio output (blocked on the bank's embedded-audio data format) — until then event-driven sounds are tracked and queryable but silent.

The .asi plugin ABI (RIB_INTERFACE_ENTRY layout + ASI/RIB callback signatures) is stable across MSS v4–v9, so a plugin built for any v4+ release loads into any v4+ build; the loader is absent only from a v3 build.

Note: Native builds on an aarch64 host are not supported, on any operating system. Zig maps callconv(.winapi) to aarch64_aapcs_win by architecture, not by target OS, and its stage2 backend does not implement that convention, so the stdcall exports do not compile: a native aarch64-linux or aarch64-macos build fails in src/engine/digital.zig at the first .winapi declaration. The constraint is the architecture, not the platform, so a Linux or Windows host builds the tree and cross-compiles the shipped DLL normally, and only an ARM build host is blocked.

The output DLL is at zig-out/bin/mss32.dll.

Versioning and compatibility

0.1.0 is the first tagged release. While the version is 0.x, SemVer promises nothing: a minor bump may carry a behavioural change, so pin by the -Dmss-version you build and check the changelog section for the tag you upgrade to.

The export table, not the version number, is the compatibility contract. A release that holds the export counts its -Dmss-version values are swept against drops into the same game set as the release before it, and make check-header / make check-versions are what check that. A release that lowers a count is breaking for that MSS version and says so in a Breaking section of the changelog.

0.1.0 does not freeze that promise. Reaching 1.0 means every -Dmss-version surface, and the struct layouts mss.h declares, are stable from there: any later change to one is a major bump naming the MSS version it affects. The Zig API under src/ carries no promise before 1.0; its breaks are listed under Breaking in the changelog.

A published version is immutable. A fix to a release that already has a GitHub release ships as a new version, never as a rebuilt archive under the old tag.

Usage

  1. Build the Windows DLL with zig build -Dtarget=x86-windows -Doptimize=ReleaseFast
  2. Back up the original mss32.dll / MSS32.DLL in your game directory
  3. Copy zig-out/bin/mss32.dll to the game directory (as both mss32.dll and MSS32.DLL on case-sensitive filesystems)
  4. Run the game (natively on Windows, or via Wine on Linux/macOS)

Linking from your own code

An unmodified game already ships its own mss.h and needs none of this. If you are calling the API from new code, mss.h declares the core surface: zig build installs it at zig-out/include/mss.h, and a release archive carries it next to mss32.dll, so -Izig-out/include (or the directory you unpacked the archive into) is all the include path your program needs. Set OPENMILES_MSS_VERSION to the build you linked (default 90, the build zig build produces); the header only declares what that build exports, so a mismatched version fails at compile time instead of at link time. The valid values are 30, 40, 50, 60, 61, 65, 66, 70, 80, and 90 (major*10+minor); any other value is rejected by #error rather than read as "at least this version", which would silently select a declaration set no release has.

A complete program that plays one second of a tone: it builds the sound image in memory, so it needs no asset on disk, and every call that can fail reports itself through AIL_last_error(). Compile it with the header on the include path and link against the import library zig build emits next to the DLL, zig-out/lib/mss32.lib (-lm for sin, or replace the sine with a table of your own). That library carries the MSVC stdcall decoration (__AIL_startup@0), so a clang-cl or MSVC caller resolves the names the DLL exports; a MinGW (zig cc -target x86-windows-gnu) caller asks for _AIL_startup@0 and does not match it, and resolves each entry point at runtime with GetProcAddress instead, as the harnesses in tests/ do (tests/test_utils.h loads both spellings).

#define OPENMILES_MSS_VERSION 90
#include "mss.h"
#include <stdio.h>
#include <math.h>
#include <string.h>

/* One second of a 440 Hz sine, held as a mono 16-bit 44100 Hz WAV in memory.
 * AIL_set_sample_file reads its header out of the image, so a real game passes
 * the bytes it unpacked from its own archive and the call is the same. */
static unsigned char tone[44 + 44100 * 2];

static void put_u32(unsigned char *p, unsigned int v)
{
    p[0] = (unsigned char)(v & 0xFFu);
    p[1] = (unsigned char)((v >> 8) & 0xFFu);
    p[2] = (unsigned char)((v >> 16) & 0xFFu);
    p[3] = (unsigned char)((v >> 24) & 0xFFu);
}

static void put_u16(unsigned char *p, unsigned int v)
{
    p[0] = (unsigned char)(v & 0xFFu);
    p[1] = (unsigned char)((v >> 8) & 0xFFu);
}

static void build_tone(void)
{
    const unsigned int rate = 44100;
    const unsigned int frames = rate;
    unsigned int i;
    unsigned char *pcm = tone + 44;

    memcpy(tone, "RIFF", 4);
    put_u32(tone + 4, 36 + frames * 2);
    memcpy(tone + 8, "WAVEfmt ", 8);
    put_u32(tone + 16, 16);            /* fmt chunk size */
    put_u16(tone + 20, 1);             /* PCM */
    put_u16(tone + 22, 1);             /* mono */
    put_u32(tone + 24, rate);
    put_u32(tone + 28, rate * 2);      /* bytes per second */
    put_u16(tone + 32, 2);             /* block align */
    put_u16(tone + 34, 16);            /* bits per sample */
    memcpy(tone + 36, "data", 4);
    put_u32(tone + 40, frames * 2);

    for (i = 0; i < frames; ++i) {
        double phase = 2.0 * 3.14159265358979 * 440.0 * (double)i / (double)rate;
        int sample = (int)(12000.0 * sin(phase));
        put_u16(pcm + i * 2, (unsigned int)(short)sample);
    }
}

int main(void)
{
    HDIGDRIVER dig;
    HSAMPLE S;

    build_tone();
    if (!AIL_startup()) {
        fprintf(stderr, "AIL_startup failed: %s\n", AIL_last_error());
        return 1;
    }

    dig = AIL_open_digital_driver(44100, 16, 2, 0);
    if (dig == NULL) {
        fprintf(stderr, "AIL_open_digital_driver failed: %s\n", AIL_last_error());
        AIL_shutdown();
        return 1;
    }

    S = AIL_allocate_sample_handle(dig);
    if (S == NULL) {
        fprintf(stderr, "AIL_allocate_sample_handle failed: %s\n", AIL_last_error());
        AIL_close_digital_driver(dig);
        AIL_shutdown();
        return 1;
    }

    /* AIL_set_sample_file returns 0 on failure, and AIL_last_error() names the
     * reason; the handle stays allocated either way. */
    if (AIL_set_sample_file(S, tone, 0) == 0) {
        fprintf(stderr, "AIL_set_sample_file failed: %s\n", AIL_last_error());
        AIL_release_sample_handle(S);
        AIL_close_digital_driver(dig);
        AIL_shutdown();
        return 1;
    }

    AIL_start_sample(S);
    while ((AIL_sample_status(S) & SMP_PLAYING) != 0) {
        AIL_serve();
    }
    AIL_release_sample_handle(S);
    AIL_close_digital_driver(dig);
    AIL_shutdown();
    return 0;
}

AIL_serve is the only thing that advances the mixer, and the loop above is the shortest form that terminates. A real game calls it once per frame from its own loop rather than spinning on it, because a tight while here keeps a core busy for as long as the sound plays.

The header covers playback, streaming, 3D, RIB, filters, the timers, the v6.5+ unified level/pan/reverb/low-pass and v7+ 3D calls on HSAMPLE, double-buffered streaming for an asset the game holds in its own archive (AIL_load_sample_buffer, AIL_sample_buffer_available on 8.0 and later, and AIL_set_sample_buffer_count for a ring deeper than two), file I/O (AIL_file_read, AIL_file_size, AIL_file_type, whose result is one of the AILFILETYPE_* constants, and AIL_set_file_callbacks for routing file access through the game's own VFS), and the Miles* event-system and SoundBank API a v8 or v9 build exports (MilesStartupEventSystem, MilesAddSoundBank, MilesEnqueueEvent, MilesEnumerateSoundInstances, and the rest, with the MSS_FIRST walk convention and the MILESEVENTSOUNDSTATUS_* and MILESEVENT_ENQUEUE_* constants). Two groups are declared only for the version builds that export them, so a version-gated #include on its own is not enough to know whether they are there: the XMIDI/sequence pair is a 6.1-to-7.0 declaration only (from 8.0 on, use the Miles* event API), and the Quick API and the redbook pair are declared for the builds below 7.1. The v7 DSP-stage, the AIL_add_*_event_step event-text builders, the v9 per-bus mixer calls, and the legacy waveOut/midiOut exports are not declared; see docs/API_STATUS.md for the full list, and add your own declaration from the export table in src/main.zig if you need one. That includes AIL_create_event and the step builders MilesEnqueueEvent takes its event text from, which the event section of docs/API_STATUS.md lists beside the Miles* calls they feed.

AIL_get_preference and AIL_set_preference take a slot number, and the header names the slots as DIG_*, MDI_* and AIL_* constants. The names are version-specific: MSS 9.0 renumbered the table, so the header defines one number per name for the OPENMILES_MSS_VERSION in the build, and scripts/check_header.py holds that against the engine's table. The engine stores any slot index below the table length, so a slot this build does not name round-trips through AIL_set_preference and AIL_get_preference like any other; only an index past the last slot is dropped and logged.

make check-header re-checks every declaration in mss.h against that export table, and the AILSOUNDINFO layout against src/root.zig, for all ten distinct version encodings, and compiles the header once per encoding with the same warning set build.zig compiles C with (-Wall -Wextra -Werror plus the pedantic, shadow, prototype, VLA, format, write-strings, enum-conversion, init-self, redundant-declaration, nested-extern, pointer-arithmetic and signed-overflow groups), so a declaration that only parses is caught here rather than in your build. It runs as part of make lint, together with make check-examples, which compiles every c snippet in this file and in docs/ against the header at the OPENMILES_MSS_VERSION each one names, so the example above cannot drift from the surface it documents. A constant in the header is outside what that script can see, so src/header_test.zig reads the AILFILETYPE_* block out of mss.h and drives each name through the real classifier: a constant the engine renumbers, or one added to the header without a fixture, fails the test build.

Using the Zig module

The package is fetchable as a Zig dependency, and the module it exposes is the library's Zig API: the same openmiles module the tests import, so a Zig program can call the engine directly instead of going through the C ABI. zig build -Dmss-version only shapes the DLL's export table; the module is the same whatever version the dependency is configured for, and openmiles.mss_version reads back the encoding it was configured with, so code can branch on it.

// build.zig
const openmiles_dep = b.dependency("openmiles", .{ .mss_version = 9 });
exe.root_module.addImport("openmiles", openmiles_dep.module("openmiles"));

The dependency field is mss_version and not mss-version: b.dependency turns a struct field into a -D flag, and a field name cannot hold a hyphen, so the option answers to both spellings. zig fetch --save=openmiles <url> (or a .path entry for a local checkout) puts it in build.zig.zon.

Configuration

The library reads its runtime configuration from the process environment. It reads three variables and has no config file, so the table below is the whole surface: nothing else in the environment changes its behaviour.

Variable Values Default Effect
OPENMILES_DEBUG 1/0, true/false, yes/no, on/off, any case logging on in a Debug build, off otherwise Verbose trace to the debug log and to the debugger, capped at 64 MiB
OPENMILES_LOG_PATH a file path, absolute or relative to the current directory, at most 1023 bytes openmiles.log in the current directory Where the debug log is written
TMPDIR an absolute directory path of at most 778 bytes %TEMP% on Windows, the game directory on other systems Where the in-memory ASI plugin image is unpacked before it is loaded

An OPENMILES_DEBUG value outside that set (including an empty one) is reported on stderr and leaves the default in place, rather than silently meaning off. A TMPDIR that is empty, too long, or not absolute is reported the same way and the image is written to the game directory instead. A temporary directory that is accepted but turns out to be unusable (the platform resolves none, it leaves no room for the image name under the path limit, or the image cannot be written there) is reported on stderr for the same reason: every one of them ends with the image unpacked into the game directory, and an operator whose TMPDIR is wrong would otherwise never learn why.

-Dmss-version and -Doptimize are build-time options (zig build --help), not runtime configuration: the shipped DLL is the same build everywhere, and selecting a version requires a rebuild.

Debug logging

OPENMILES_DEBUG=1 wine YourGame.exe
OPENMILES_DEBUG=1 OPENMILES_LOG_PATH=/tmp/openmiles.log wine YourGame.exe

The log's first line is the effective configuration, naming whether OPENMILES_DEBUG or the build default decided it, which file it appends to, and which -Dmss-version the loaded DLL was built for, so a log that never appears reads back as a configuration answer and a game compiled against a different OPENMILES_MSS_VERSION than the DLL it loads is visible in the first line.

A run that exports OPENMILES_DEBUG also gets that line on stderr, once, whether the value asked for logging on or off. The off case is the one that has no log to read it from, and an operator who exported the variables is asking what the library resolved them to.

Every record opens with a fixed-width UTC timestamp, so a log can be sorted by time and a window cut out of a capped one:

2026-09-28T08:27:37.123Z openmiles: debug log on, enabled by OPENMILES_DEBUG ...
2026-09-28T08:27:37.140Z AIL_startup

The stamp is UTC and says so with the Z, because the library reads no time zone. A record whose message exceeded the per-record budget is replaced by a marker naming the format string that produced it, and keeps its stamp.

Architecture

graph TD
    Game["Game (.exe)"] --> DLL["mss32.dll (OpenMiles)"]

    subgraph OpenMiles
        DLL --> API["src/api/<br/>C ABI exports (stdcall)"]
        DLL --> Engine["src/engine/<br/>Zig engine layer<br/>Sample, Sequence, DigitalDriver, Filter"]
        DLL --> RIB["src/rib/<br/>RIB provider system"]
        DLL --> Utils["src/utils/<br/>Logging, filesystem compat"]
        DLL --> Bindings["src/bindings/<br/>C implementations<br/>(AIL_debug_printf, AIL_sprintf)"]
    end

    Engine --> MA["miniaudio.h<br/>Audio output, decoding, mixing, 3D"]
    Engine --> TSF["tsf.h<br/>SoundFont (SF2) synthesis"]
    Engine --> TML["tml.h<br/>MIDI file parsing"]

    MA --> Backend["WASAPI / PulseAudio / CoreAudio"]
Loading

API Coverage

The default (v9) DLL exports 394 functions spanning the v3–v9 API surface (legacy waveOut/midiOut compatibility included; DIG_/MDI_ prefix aliases not yet exported). Every exported function is covered by the fuzz harness and by unit or C-integration tests. See docs/API_STATUS.md for the per-function implementation matrix.

Beyond export-table parity, behaviour is cross-checked against the MSS SDK source: getter round-trips, null/error return sentinels, init defaults, and the sample/stream lifecycle state machines are verified function-by-function against wavefile.cpp/m3d.cpp/mssstrm.cpp (see the Behavioural fidelity audit section of docs/API_STATUS.md).

Category Status
Core System Mostly implemented (some Windows/hardware-specific APIs are no-ops)
Digital Audio (Samples & Streams) Fully implemented
MIDI / XMIDI Core playback fully implemented; DLS/SF2 loaded via TinySoundFont
3D Positional Audio Fully implemented
RIB / ASI Plugin System Fully implemented
Filter API Low-pass filter implemented
Timer API Fully implemented
Quick API Fully implemented
Event System (v8/v9) Byte-faithful text codec; execution VM tracks sound instances (lifecycle, durations, label filtering/caps, state counts) — audio output not yet wired
SoundBank (v8/v9) BANK loader + global container: asset enumeration, event-bytecode + sound-info/duration lookup
Redbook (CD) API Emulated (no audio -- games proceed gracefully)

Tested Games

Game Status
Europa 1400: The Guild (Gold Edition) Working -- MP3 streaming, WAV SFX, multiple drivers

Documentation

Dependencies

All dependencies are vendored single-header C libraries in deps/:

Library Version License Purpose
miniaudio v0.11.25 MIT-0 / Public Domain Audio output, decoding, mixing, 3D
TinySoundFont v0.9 MIT SF2 synthesis
TinyMidiLoader v0.7 Zlib MIDI parsing

There is nothing to install and nothing a package manager resolves. The exact bytes of each vendored file are pinned in deps/SHA256SUMS, and the upstream commit each was fetched from is recorded in deps/README.md; make check-vendored, which make lint and CI run, verifies both. See that file for the update procedure.

SBOM.cdx.json is the same inventory as CycloneDX: the vendored headers above with their licenses and digests, plus the pip pins the parity sweep needs. It is generated from those same records by scripts/gen_sbom.py and shipped inside the release archive, so a consumer can tell what third-party code the DLL carries without cloning this repository. make check-sbom fails when it no longer matches the tree.

License

This project is licensed under the GNU General Public License v3.0.

OpenMiles is a clean-room reimplementation. It does not contain any code from the original Miles Sound System by RAD Game Tools.

About

An open source reimplementation of the Miles Sound System for older games

Topics

Resources

Contributing

Security policy

Stars

7 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages