Converting Rikki & Vikki (Steam) to an A78 with Music

From 8BitDev.org - Atari 7800 Development Wiki
Jump to: navigation, search

Converting Rikki & Vikki (Steam) to an .a78 with Music

The Steam release of Rikki & Vikki includes the game ROM plus the soft-synth music files. The Python script below packs them into a single 1 MB .a78 file that plays the game with its original music in js7800.

Notes

  • This script is specific to Rikki & Vikki. It won't work for other games.
  • This is not the way to make files for the 7800GD (GameDrive). The GameDrive can't run the soft-synth, and it uses a different set of files. Don't use this script's output on a GameDrive.
  • At present, js7800 is the only emulator known to play the music in the converted file.

Requirements

  • Python 3.6 or newer. No extra packages are needed.

Steps

  1. Copy all files from the game's Data and Music folders into your own temporary folder somewhere. Afterward, the temporary folder should contain FoxBox.bin, FoxBox.cdf, RV_Samples.smp, RV_Macros.ins and a bunch of RV_*.mus files.
  2. Save the script below into that folder as rikki_vikki_music_a78.py.
  3. Open a terminal or command prompt in that folder and run:
    python3 rikki_vikki_music_a78.py
    (On Windows, use python rikki_vikki_music_a78.py, or just double-click the script.)
  4. The script writes rikki_and_vikki_music.a78 (1,048,704 bytes) into the same folder. Load that file in js7800.

If anything is missing or doesn't match the expected files, the script stops with an error message explaining why. Your original game files are never modified.

Script

#!/usr/bin/env python3
"""
Build a music-enabled .a78 of Rikki & Vikki from the game's distribution files.

The game ships as FoxBox.bin (the 512 KB game ROM), FoxBox.cdf (an index) and the
CoreTone soft-synth data the CDF lists (RV_Samples.smp, RV_Macros.ins, 32 x .mus).
This script packs them into the 1 MB image layout that js7800 expects for
Souper carts with BupChip music:

    128-byte A78 header
    512 KB  game ROM (FoxBox.bin)
    512 KB  music: samples, instruments, songs 0-31 back to back in CDF order,
            zero-padded

Usage (Python 3.6+, no extra packages):
    python3 rikki_vikki_music_a78.py                 # run inside the game folder
    python3 rikki_vikki_music_a78.py <game folder> [-o output.a78]
"""
import argparse
import hashlib
import os
import sys

ROM_SIZE = 512 * 1024
MUSIC_SIZE = 512 * 1024
OUTPUT_NAME = "rikki_and_vikki_music.a78"
KNOWN_ROM_MD5 = "b3bc889e9cc498636990c5a4d980e85c"  # FoxBox.bin v1.0.0.3

# Sizes the music-region consumers expect, in CDF order: samples, instruments,
# then songs 0-31. Chunk boundaries come only from this list, so any mismatch
# would scramble the music.
EXPECTED_SIZES = [
    121119, 3177,
    787, 3681, 1429, 3733, 3549, 4821, 4964, 660, 1146, 7260, 5216, 4849, 4527,
    5467, 2809, 1620, 4030, 1280, 542, 660, 655, 819, 1831, 2536, 2722, 885,
    1678, 490, 484, 1529, 15487, 293,
]


def make_header():
    h = bytearray(128)
    h[0] = 1                                   # header version
    h[1:17] = b"ATARI7800       "
    h[17:17 + 15] = b"Rikki And Vikki"         # title, 32 bytes at 17..48
    size = ROM_SIZE + MUSIC_SIZE
    h[49:53] = size.to_bytes(4, "big")         # image size
    h[53] = 0x10                               # cart type bit 12: Souper
    h[54] = 0x00
    h[55] = 1                                  # left controller: joystick
    h[56] = 1                                  # right controller: joystick
    h[100:128] = b"ACTUAL CART DATA STARTS HERE"
    return bytes(h)


def die(msg):
    print("ERROR: " + msg, file=sys.stderr)
    sys.exit(1)


def find_file(folder, name):
    """Case-insensitive lookup; CDF paths use Windows separators and casing."""
    name = name.replace("\\", "/").split("/")[-1]
    exact = os.path.join(folder, name)
    if os.path.isfile(exact):
        return exact
    lower = name.lower()
    for entry in os.listdir(folder):
        if entry.lower() == lower and os.path.isfile(os.path.join(folder, entry)):
            return os.path.join(folder, entry)
    return None


def find_cdf(folder):
    cdfs = [e for e in os.listdir(folder) if e.lower().endswith(".cdf")]
    if not cdfs:
        return None
    for preferred in ("foxbox.cdf",):
        for e in cdfs:
            if e.lower() == preferred:
                return os.path.join(folder, e)
    if len(cdfs) > 1:
        die("several .cdf files found ({}); pass the folder of the original "
            "game files".format(", ".join(cdfs)))
    return os.path.join(folder, cdfs[0])


def read_cdf(path):
    with open(path, "rb") as f:
        text = f.read().decode("latin-1")
    lines = [l.strip() for l in text.splitlines() if l.strip()]
    # Line layout: emulator, mapper, title, ROM file, then a CORETONE section.
    if len(lines) < 4:
        die("{} is too short to be the game's CDF".format(path))
    if lines[1].upper() != "SOUPER":
        die("{} is not a Souper CDF (mapper line is '{}')".format(path, lines[1]))
    upper = [l.upper() for l in lines]
    if "CORETONE" not in upper:
        die("{} has no CORETONE section. This looks like the GameDrive variant "
            "(ADPCM audio), which can't be converted. Use the original Steam "
            "files.".format(path))
    synth = lines[upper.index("CORETONE") + 1:]
    return lines[3], synth


def main():
    ap = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
    ap.add_argument("folder", nargs="?", help="folder with FoxBox.bin/.cdf (default: current)")
    ap.add_argument("-o", "--output", help="output .a78 path (default: {} in the folder)".format(OUTPUT_NAME))
    args = ap.parse_args()

    folder = args.folder
    if folder is None:
        folder = os.getcwd()
        if find_cdf(folder) is None:  # double-clicked: try the script's own folder
            here = os.path.dirname(os.path.abspath(__file__))
            if find_cdf(here) is not None:
                folder = here
    if not os.path.isdir(folder):
        die("folder not found: {}".format(folder))

    cdf = find_cdf(folder)
    if cdf is None:
        die("no .cdf file found in {}. Run this inside the Rikki & Vikki game "
            "folder (the one containing FoxBox.bin and FoxBox.cdf).".format(folder))
    rom_name, synth_names = read_cdf(cdf)
    print("Using {}".format(cdf))

    rom_path = find_file(folder, rom_name)
    if rom_path is None:
        die("game ROM '{}' (listed in the CDF) not found".format(rom_name))
    with open(rom_path, "rb") as f:
        rom = f.read()
    if len(rom) != ROM_SIZE:
        die("{} is {} bytes; expected {}".format(rom_path, len(rom), ROM_SIZE))
    if hashlib.md5(rom).hexdigest() != KNOWN_ROM_MD5:
        print("Note: {} isn't the v1.0.0.3 ROM this script was tested with; "
              "continuing.".format(os.path.basename(rom_path)))

    if len(synth_names) != len(EXPECTED_SIZES):
        die("CDF lists {} synth files; expected {}".format(len(synth_names), len(EXPECTED_SIZES)))

    music = bytearray()
    for i, (name, want) in enumerate(zip(synth_names, EXPECTED_SIZES)):
        path = find_file(folder, name)
        if path is None:
            die("synth file '{}' (listed in the CDF) not found".format(name))
        with open(path, "rb") as f:
            data = f.read()
        magic = b"CSMP" if i == 0 else b"CINS" if i == 1 else b"CMUS"
        if data[:4] != magic:
            die("{} doesn't look like CoreTone data (expected '{}' header)".format(
                name, magic.decode()))
        if len(data) != want:
            die("{} is {} bytes; expected {}. This script only supports the "
                "v1.0.0.3 music files.".format(name, len(data), want))
        music += data
    music += bytes(MUSIC_SIZE - len(music))

    out_path = args.output or os.path.join(folder, OUTPUT_NAME)
    image = make_header() + rom + bytes(music)
    with open(out_path, "wb") as f:
        f.write(image)
    print("Wrote {} ({} bytes, MD5 {})".format(out_path, len(image), hashlib.md5(image).hexdigest()))


if __name__ == "__main__":
    try:
        main()
    finally:
        # Keep the console open when double-clicked on Windows.
        if os.name == "nt" and len(sys.argv) == 1 and sys.stdin and sys.stdin.isatty():
            try:
                input("Press Enter to close...")
            except EOFError:
                pass