joeylib2/scripts/verify-x68000-audio.sh

227 lines
9.2 KiB
Bash
Executable file

#!/usr/bin/env bash
# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
# See LICENSE.md for license information.
# SPDX-License-Identifier: MIT
#
# verify-x68000-audio.sh - X68000 audio gate, read off the chips.
#
# Boots a built example on a Human68k floppy pair under headless MAME and
# watches the two sound devices through memory taps, so the checks are on
# what the hardware was actually told, not on a log line:
#
# 1. The MSM6258's rate and channels, as IOCS sets them from the mode
# audioPcm.c asks for: PPI port C ($E9A005) bits 3,2 pick the divider
# (1024, 768, 512) and bits 1,0 gate the channels (0 both), and the
# YM2151's CT1 pin (register $1B bit 7) the clock (0 8 MHz, the chip's
# reset state; 1 4 MHz). The gate wants 7812 Hz, the rate the mixer
# renders at, on both channels. (A mode byte where IOCS wants a word
# -- this port's old 0x05 -- is 3906 Hz out of one side.)
# 2. Every YM2151 noise write ($E90000 register 0x0F). The low five
# bits must be OPM_NOISE_PERIODS - pitch for a pitch of 1 to
# NOISE_PITCH_MAX (0 plays as 1), both read out of
# src/x68000/audio.c rather than duplicated: that is the YM2149's
# 125000 / pitch Hz on the X68000's 4 MHz OPM, and a code outside it
# is the bug this gate exists for (a plain `31 - pitch` ran the
# generator an octave bright).
# 3. The ADPCM byte feed rate WHILE A SOUND IS PLAYING. Two samples per
# byte at 7812 Hz is 3906 bytes/s; half that means the divider is
# wrong again and every sample the port plays is an octave low. It
# is the rate inside one burst of feeding that counts, not the
# average: the port stops the chip between sounds, so an average
# over a mostly silent run would say nothing.
#
# The default subject is JoeyLib's audio probe, examples/audio built to
# press its own keys: it sweeps the noise generator through all 32 pitches,
# which reaches every noise code, plays its tracker song's
# noise drums, and keeps a phrase streaming through the ADPCM.
#
# Usage: scripts/verify-x68000-audio.sh [program] [seconds]
# program the built program: its path (a project's, say), or an
# example's name. Default build/x68000/probe/AUDIOPRB.X
# (make -f make/x68000.mk x68000-verify-audio builds it
# and runs this). Its DATA folder, if it has one beside
# it, goes on the floppy with it.
# Env: X68K_DIR dir holding HUMAN302.XDF + roms/ (default: the installer's toolchains/firmware/x68000)
# Requires: the program built, MAME with x68000.
set -uo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
source "$repo/scripts/lib/joeyEnv.sh"
joeyNeed MAME mame
joeyNeed X68K_DIR x68kFirmware
joeyNeedFile "$X68K_DIR/roms/x68000/cgrom.dat" x68kCgrom
source "$repo/scripts/lib/x68kDisk.sh"
bin=$(joeyProgramPath "${1:-$repo/build/x68000/probe/AUDIOPRB.X}" x68000) || exit 2
seconds=${2:-160}
PROG=$(basename "$bin" .X)
source=$repo/src/x68000/audio.c
[[ -f $bin ]] || { echo "verify-x68000-audio: $bin not built" >&2; exit 2; }
[[ -f $X68K_SYSTEM_DISK ]] || { echo "verify-x68000-audio: missing $X68K_SYSTEM_DISK" >&2; exit 2; }
# The noise codes, from the port's own constants so the two cannot drift
# apart: OPM_NOISE_PERIODS - pitch, pitch 1 .. NOISE_PITCH_MAX.
table=$(python3 - "$source" <<'PY'
import re, sys
src = open(sys.argv[1]).read()
def const(name):
m = re.search(r'#define\s+' + name + r'\s+(\d+)u?', src)
if not m:
sys.exit(name + " not found")
return int(m.group(1))
periods = const("OPM_NOISE_PERIODS")
print(",".join(str(periods - p) for p in range(1, const("NOISE_PITCH_MAX") + 1)))
PY
) || { echo "verify-x68000-audio: cannot read the noise constants from $source" >&2; exit 2; }
work=$(mktemp -d -t joey-x68audio.XXXXXX)
trap 'rm -rf "$work"' EXIT
x68kBootDisk "$work/boot.xdf" 'B:' "B:\\$PROG.X" || exit 1
x68kDataDisk "$work/data.xdf" || exit 1
x68kXdf add "$work/data.xdf" "$bin" "$PROG.X" >/dev/null || exit 1
# The program's DATA tree sits next to it: its own folder's for a program
# that ships in one (ID_FOLDER), the shared bin/DATA for the rest.
if [[ -d $(dirname "$bin")/DATA ]]; then
x68kAddTree "$work/data.xdf" "$(dirname "$bin")/DATA" DATA || exit 1
fi
# The taps keep to arithmetic and file writes: a tap that reads memory
# takes MAME down with it.
cat > "$work/tap.lua" <<LUA
local cpu = manager.machine.devices[":maincpu"]
local mem = cpu.spaces["program"]
local out = io.open("audio.txt", "w")
local sel = -1
local pcmWrites = 0
local burstCount = 0
local burstStart = 0
local burstLast = -1
local bestRate = 0
local stop = $seconds
-- A local tap object is garbage collected and stops firing. Keep globals.
opmTap = mem:install_write_tap(0xE90000, 0xE90003, "opm", function(offset, data, mask)
local v = data & 0xFF
if (mask & 0xFF) == 0 then v = (data >> 8) & 0xFF end
if offset < 0xE90002 then
sel = v
elseif sel == 0x0F then
out:write(string.format("NOISE %02X\\n", v))
elseif sel == 0x1B then
out:write(string.format("CT %02X\\n", v))
end
return data
end)
ppiTap = mem:install_write_tap(0xE9A004, 0xE9A005, "ppi", function(offset, data, mask)
local v = data & 0xFF
if (mask & 0xFF) == 0 then v = (data >> 8) & 0xFF end
out:write(string.format("PORTC %02X\\n", v))
return data
end)
-- The port stops the chip between sounds, so the honest figure is the rate
-- inside one burst of feeding, not an average across the silence. A gap of
-- more than 100 ms ends a burst; bursts shorter than 300 ms are too short to
-- time accurately and are skipped.
local function closeBurst()
if burstLast > burstStart + 0.3 and burstCount > 0 then
local r = burstCount / (burstLast - burstStart)
if r > bestRate then bestRate = r end
end
burstCount = 0
end
pcmTap = mem:install_write_tap(0xE92000, 0xE92003, "pcm", function(offset, data, mask)
local t = manager.machine.time:as_double()
pcmWrites = pcmWrites + 1
if burstLast < 0 or (t - burstLast) > 0.1 then
closeBurst()
burstStart = t
end
burstLast = t
burstCount = burstCount + 1
return data
end)
emu.register_frame_done(function()
local t = manager.machine.time:as_double()
if t >= stop then
closeBurst()
out:write(string.format("PCMTOTAL %d\\n", pcmWrites))
out:write(string.format("PCMRATE %.0f\\n", bestRate))
out:write("DONE\\n")
out:close()
manager.machine:exit()
end
end)
LUA
( cd "$work" && SDL_AUDIODRIVER=dummy timeout 1800 "$MAME" x68000 \
-bios ipl10 -rompath "$X68K_DIR/roms" \
-flop1 boot.xdf -flop2 data.xdf \
-video none -sound none -nothrottle -skip_gameinfo \
-autoboot_script tap.lua </dev/null >mame.log 2>&1 ) || true
[[ -f $work/audio.txt ]] || { echo "verify-x68000-audio: FAIL (MAME produced no tap output)" >&2; exit 1; }
python3 - "$work/audio.txt" "$table" "$PROG" <<'PY'
import sys
path, table, prog = sys.argv[1], sys.argv[2], sys.argv[3]
allowed = {int(v) for v in table.split(",")}
noise, portc, ct, rate, done = [], [], [], None, False
for line in open(path):
p = line.split()
if not p:
continue
if p[0] == "NOISE":
noise.append(int(p[1], 16))
elif p[0] == "PORTC":
portc.append(int(p[1], 16))
elif p[0] == "CT":
ct.append(int(p[1], 16))
elif p[0] == "PCMRATE":
rate = float(p[1])
elif p[0] == "DONE":
done = True
fails = []
if not done:
fails.append("the run never reached its time limit")
# 1. The ADPCM rate and channels: the last values written win; the ROM's
# own boot value of port C (0x0B) does not count as the port's.
ours = [v for v in portc if v != 0x0B]
if not ours:
fails.append("PPI port C was never programmed, so the ADPCM never played")
else:
got = ours[-1] & 0x0F
clock = 4000000 if ct and ct[-1] & 0x80 else 8000000
divider = (1024, 768, 512, 512)[(got >> 2) & 3]
hz = clock // divider
if hz != 7812 or got & 3:
fails.append(f"port C low nibble {got:#04x} with a {clock // 1000000} MHz clock: "
f"{hz} Hz, channels {got & 3}; want 7812 Hz on both (0)")
# 2. Every enabled noise write must be one of the port's codes.
on = {v & 0x1F for v in noise if v & 0x80}
if not on:
fails.append("no noise writes seen at all")
stray = sorted(on - allowed)
if stray:
fails.append(f"noise codes no pitch makes: {stray}")
# 3. Byte feed rate: two samples per byte at 7812 Hz.
if rate is None:
fails.append("the ADPCM channel was never fed")
elif not (3500.0 <= rate <= 4300.0):
fails.append(f"ADPCM in-burst feed {rate:.0f} bytes/s, want ~3906 "
f"({'half rate, the divider is wrong' if rate < 2600 else 'off rate'})")
portcText = "/".join(f"{v:02X}" for v in ours[-2:]) if ours else "never set"
rateText = f"{rate:.0f} bytes/s in-burst" if rate is not None else "silent"
print(f"verify-x68000-audio: {prog} noise writes {len(noise)} "
f"({len(on)} distinct codes, {len(allowed - on)} of the port's unused), "
f"port C {portcText}, CT writes {len(ct)}, ADPCM {rateText}")
if fails:
for f in fails:
print(f"verify-x68000-audio: FAIL ({f})", file=sys.stderr)
sys.exit(1)
print("verify-x68000-audio: PASS")
PY